let ENEMY_COLOR = '#f60';
let PLAYER_COLOR = '#4bf';
let DIR_OFFSETS: [number, number][] = [[1, 0], [0, -1], [-1, 0], [0, 1]];
let FIRE_PERIOD = 0.5;
let BULLET_SPEED = 15;
let BULLET_COLOR = '#999';
let BULLET_RAD = 0.1;
let INVINCIBLE_RECOVER_TIME = 1;
let TILE_LIGHT_TIME = 0.4;
let SHAKE_DURATION = 0.2;

class Tile {
    outDirs: boolean[] = [false, false, false, false];
    modi: boolean = false;
    exit: boolean = false;
    lastModTime: number = -TILE_LIGHT_TIME;
    visibleAfter = 0;
    invisibleAfter = Number.POSITIVE_INFINITY;
}

function makeTiles(s: string[]): Tile[][] {
    let tiles: Tile[][] = [];
    let gh = s.length;
    let gw = s[0].length;
    for (let col = 0; col < gw; col++) {
        tiles.push([]);
    }
    for (let row = 0; row < gh; row++) {
        for (let col = 0; col < gw; col++) {
            let t = new Tile();
            let c = s[row][col]; // transposed
            setDirs(t, c);
            tiles[col][row] = t;
        }
    }
    return tiles;
}

function setDirs(t: Tile, c: string) {
    let clo = c.toLowerCase();
    t.outDirs = [false, false, false, false];
    let od = t.outDirs;
    if (c == '-') { od[0] = true; od[2] = true; }
    if (c == '|') { od[1] = true; od[3] = true; }
    if (clo == 'q') { od[0] = true; od[3] = true; }
    if (clo == 'w') { od[2] = true; od[3] = true; }
    if (clo == 'a') { od[0] = true; od[1] = true; }
    if (clo == 's') { od[1] = true; od[2] = true; }
    if (clo != c) { t.modi = true; }
    if (clo == '!') { t.exit = true; od[0] = true; od[1] = true; od[2] = true; od[3] = true; }
}

function rotateTile(t: Tile) {
    let od = t.outDirs;
    let last = od[3];
    od.splice(3, 1);
    od.splice(0, 0, last);
}

function rotateTileUntilConnecting(coords: [number, number], tiles: Tile[][]) {
    // Rotate a tile until it connects to its neighbours.

    // First figure out which neighbours have incoming edges.
    let t = tiles[coords[0]][coords[1]];
    let neighbin = []
    let w = tiles.length;
    let h = tiles[0].length;
    for (let k = 0; k < 4; k++) {
        let offset = DIR_OFFSETS[k];
        let neighb = [coords[0] + offset[0], coords[1] + offset[1]];
        if (neighb[0] < 0 || neighb[0] >= w || neighb[1] < 0 || neighb[1] >= h) {
            neighbin[k] = 0;
        }
        else {
            neighbin[k] = tiles[neighb[0]][neighb[1]].outDirs[(k + 2) % 4];
        }
    }

    // Now call rotateTile until things are in agreement
    for (let k = 0; k < 3; k++) { // shouldn't have to rotate more than 3 times...!
        rotateTile(t);
        let ok = true;
        for (let l = 0; l < 4; l++) {
            if (t.outDirs[l] && !neighbin[l]) {
                ok = false;
            }
        }
        if (ok) return;
    }
}

function getLambda(x: number, lower: number, upper: number) {
    return Math.min(1, Math.max(0, (x - lower) / (upper - lower)));
}

function lerp(lam: number, a: number, b: number) {
    return (1 - lam) * a + lam * b;
}

function boundCoords(pos: [number, number], bounds: [number, number, number, number], rad: number): [number, number] {
    let x = Math.max(bounds[0] + rad, Math.min(bounds[0] + bounds[2] - rad, pos[0]));
    let y = Math.max(bounds[1] + rad, Math.min(bounds[1] + bounds[3] - rad, pos[1]));
    return [x, y];
}

function getInvincibleAlpha(t: number) {
    // if t > 0, choose a good value of the drawing alpha that will show that the player/enemy is invincible.
    if (t > 0) {
        let parity = Math.ceil(t / 0.2) % 2;
        return 0.9 - 0.5 * parity;
    }
    else {
        return 1;
    }
}

class Player {
    pos: [number, number];
    vel: [number, number];
    rad: number = 0.2;
    lastOverTile: [number, number];
    lives = 5;
    invincibleTime = INVINCIBLE_RECOVER_TIME;
    firing = false;
    nextTimeCanFire = 0;

    constructor(pos: [number, number], lastOverTile: [number, number]) {
        this.pos = pos;
        this.vel = [0, 0];
        this.lastOverTile = lastOverTile;
    }
}

class Ball {
    lastPos: [number, number];
    nextPos: [number, number];
    lastTime: number;
    nextTime: number;
    stepTime = 0.5;
    rad = 0.2;
    passedEdge = false; // Have I passed the edge (half-way between tile centres)?
    visibleAfter = 0;
    invisibleAfter = Number.POSITIVE_INFINITY;

    constructor(lastPos: [number, number], nextPos: [number, number]) {
        this.lastPos = lastPos;
        this.nextPos = nextPos;
        this.lastTime = 0;
        this.nextTime = this.stepTime;
    }

    getTravelDir(): number { // 0 = east, 1 = north, 2 = west, 3 = south.
        return getTravelDir(this.lastPos, this.nextPos);
    }
}

function getTravelDir(lastPos: [number, number], nextPos: [number, number]) {
    if (nextPos[0] > lastPos[0]) return 0;
    if (nextPos[1] < lastPos[1]) return 1;
    if (nextPos[0] < lastPos[0]) return 2;
    if (nextPos[1] > lastPos[1]) return 3;
    throw new Error("Couldn't figure out ball direction.");
}

class Bullet {
    pos: [number, number] = [0, 0];
    vel: [number, number] = [0, 0];
    destroyed = false;
}

class Enemy {
    gl: GameLayer;
    pos: [number, number];
    activeTime: number;
    rad = 0.2;
    destroyed = false;
    destroyTime: number | null = null;

    constructor(gl: GameLayer, pos: [number, number]) {
        this.gl = gl;
        this.pos = pos;
        this.activeTime = gl.time + 1.5;
    }
    update(deltat: number) { };
    draw(ex: number, ey: number, s: number) { };

    destroy() {
        this.gl.makeParticleExplosion(this.pos, this.rad, 10, ENEMY_COLOR);
        this.gl.shake();
        this.destroyed = true;
        this.gl.game.soundPlayer.playSound(this.gl.game.data["ld47hit"], false);
    }
}

class Wanderer extends Enemy {
    lastPos: [number, number];
    nextPos: [number, number];
    lastTime: number;
    nextTime: number;
    stepTime = 0.5;
    stepRad = 0.5;
    sightRad = 2.0; // starts homing in from this distance

    constructor(gl: GameLayer, pos: [number, number]) {
        super(gl, pos);
        this.lastPos = pos;
        this.nextPos = pos;
        this.lastTime = gl.time;
        this.nextTime = this.activeTime;
    }

    update(deltat: number) {
        let lam = getLambda(this.gl.time, this.lastTime, this.nextTime);
        this.pos = [lerp(lam, this.lastPos[0], this.nextPos[0]), lerp(lam, this.lastPos[1], this.nextPos[1])];

        if (this.gl.time >= this.nextTime) {
            let bounds = this.gl.bounds();
            let np = this.nextPos;
            this.lastPos = np;
            // If player is close, follow them
            let pPos = this.gl.player.pos;
            let pDisp = [pPos[0] - np[0], pPos[1] - np[1]]
            let pDist = (pDisp[0] ** 2 + pDisp[1] ** 2) ** 0.5;
            let newX, newY: number;
            if (pDist > 0 && pDist < this.sightRad) {
                newX = np[0] + pDisp[0] * this.stepRad / pDist;
                newY = np[1] + pDisp[1] * this.stepRad / pDist;
            }
            else {
                // step randomly
                let ang = Math.random() * Math.PI * 2;
                let xStep = Math.cos(ang) * this.stepRad;
                let yStep = Math.sin(ang) * this.stepRad;
                newX = np[0] + xStep;
                newY = np[1] + yStep;
            }
            this.nextPos = boundCoords([newX, newY], bounds, this.rad);
            this.lastTime = this.nextTime;
            this.nextTime += this.stepTime;
        }
    }

    draw(ex: number, ey: number, s: number) {
        let ctx = this.gl.game.ctx;

        ctx.beginPath();
        let points = 8;
        for (let k = 0; k < points; k++) {
            let ang = (this.gl.time - this.activeTime) + (k / points) * Math.PI * 2;
            let r = ((k % 2) * 1.2 + 0.4) * this.rad * s;
            let offx = r * Math.cos(ang);
            let offy = r * Math.sin(ang);
            if (k == 0) ctx.moveTo(ex + offx, ey + offy);
            else ctx.lineTo(ex + offx, ey + offy);
        }
        ctx.fillStyle = ENEMY_COLOR;
        ctx.fill();
    }
}

class Flyer extends Enemy {
    angleFacing = 0;
    speed = 1.5;
    turningSpeed = 30 * Math.PI / 180;

    constructor(gl: GameLayer, pos: [number, number]) {
        super(gl, pos);
        this.angleFacing = this.getAngleToPlayer();
    }

    getAngleToPlayer() {
        let pPos = this.gl.player.pos;
        let disp = [pPos[0] - this.pos[0], pPos[1] - this.pos[1]];
        return Math.atan2(disp[1], disp[0]);
    }

    update(deltat: number) {
        if (this.gl.time > this.activeTime) {
            // update velocity
            let vx = Math.cos(this.angleFacing) * this.speed * deltat;
            let vy = Math.sin(this.angleFacing) * this.speed * deltat;
            this.pos = [this.pos[0] + vx, this.pos[1] + vy];
            let bounds = this.gl.bounds();
            if (this.pos[0] < bounds[0] || this.pos[0] > bounds[0] + bounds[2] ||
                this.pos[1] < bounds[1] || this.pos[1] > bounds[1] + bounds[3]) {
                this.destroy();
            }

            // maybe turn
            let angToPlayer = this.getAngleToPlayer();
            let angDiff = angToPlayer - this.angleFacing;
            while (angDiff > Math.PI) angDiff -= 2 * Math.PI;
            while (angDiff < -Math.PI) angDiff += 2 * Math.PI;
            let ts: number;
            if (angDiff < 0) ts = -this.turningSpeed;
            else ts = this.turningSpeed;
            this.angleFacing += ts * deltat;
            if (this.angleFacing > Math.PI) this.angleFacing -= 2 * Math.PI;
            if (this.angleFacing < -Math.PI) this.angleFacing += 2 * Math.PI;
        }
    }

    draw(ex: number, ey: number, s: number) {
        let ctx = this.gl.game.ctx;

        ctx.beginPath();
        let ux = Math.cos(this.angleFacing) * s * this.rad;
        let uy = Math.sin(this.angleFacing) * s * this.rad;

        let pts = [[1, 0], [-0.7, 0.9], [-0.3, 0], [-0.7, -0.9]];

        for (let k = 0; k < pts.length; k++) {
            let x = pts[k][0];
            let y = pts[k][1];
            pts[k] = [ux * x - uy * y, uy * x + ux * y];
        }

        ctx.moveTo(ex + pts[0][0], ey + pts[0][1]);
        for (let k = 1; k < pts.length; k++) {
            ctx.lineTo(ex + pts[k][0], ey + pts[k][1]);
        }
        ctx.fillStyle = ENEMY_COLOR;
        ctx.fill();
    }
}

class Particle {
    pos: [number, number] = [0, 0];
    vel: [number, number] = [0, 0];
    inactiveTime = 0;
    color: string = '#fff';
}

function calculateNextBallDir(curPos: [number, number], travelDir: number, tiles: Tile[][]): number {
    let t = tiles[curPos[0]][curPos[1]];
    let nextDir: number = -1;
    let oppDir = (travelDir + 2) % 4;
    for (let kOff = 4; kOff >= 1; kOff--) {
        let k = (oppDir + kOff) % 4;
        if (t.outDirs[k]) nextDir = k;
    }
    if (nextDir == -1) {
        throw new Error("Couldn't figure out which way to go.");
    }
    return nextDir;
}

function calculateNextBallPos(curPos: [number, number], travelDir: number, tiles: Tile[][]): [number, number] {
    let nextDir = calculateNextBallDir(curPos, travelDir, tiles);
    let nextBallPos: [number, number] = [curPos[0] + DIR_OFFSETS[nextDir][0], curPos[1] + DIR_OFFSETS[nextDir][1]];
    return nextBallPos;
}

function checkBounceNeeded(nextPos: [number, number], travelDir: number, tiles: Tile[][]) {
    let bounce = false;
    let gw = tiles.length;
    let gh = tiles[0].length;

    if (nextPos[0] < 0 || nextPos[0] >= gw || nextPos[1] < 0 || nextPos[1] >= gh) {
        bounce = true;
    }
    else {
        let t = tiles[nextPos[0]][nextPos[1]];
        let oppDir = (travelDir + 2) % 4;
        if (!t.outDirs[oppDir]) bounce = true;
    }
    return bounce;
}

function calcEnemySpawnTime(difficulty: number) {
    return 8.0 * Math.pow(0.8, difficulty - 1)
}

function neonStroke(ctx: CanvasRenderingContext2D, backColor: string) {
    ctx.lineWidth = 4;
    ctx.strokeStyle = backColor;
    ctx.stroke();
    ctx.lineWidth = 2;
    ctx.strokeStyle = '#aaa';
    ctx.stroke();
}

class GameLayer extends PH.Layer {
    game: Game;
    tiles: Tile[][] = [];
    player: Player;
    ball: Ball;
    enemies: Enemy[];
    time: number;
    timeLevelWon: number | null = null;
    timeLevelLost: number | null = null;
    moveDir: boolean[] = [false, false, false, false]; // East, North, West, South
    moveKeys: string[][] = [['ArrowRight', 'KeyD'], ['ArrowUp', 'KeyW'], ['ArrowLeft', 'KeyA'], ['ArrowDown', 'KeyS']];
    gw: number = 0;
    gh: number = 0;
    particles: Particle[];
    maxParticles = 200;
    nextParticle = 0;
    bullets: Bullet[] = [];
    mousePos: PH.MousePosition = null;
    difficulty: number = 0;
    nextEnemySpawnTime: number = 0;
    levelsComplete: number = 0;
    shakeEndTime: number = 0;

    constructor(game: Game) {
        super();
        this.game = game;
        this.ball = new Ball([0, 0], [0, 0]);
        let sideLen = 4;
        let levelDesc = makeLevel(sideLen, sideLen, true);
        this.enemies = [];
        this.time = 0;
        this.particles = []
        for (let k = 0; k < this.maxParticles; k++) {
            this.particles.push(new Particle());
        }
        this.startLevel(levelDesc);
        this.game.jukeBox.setMusic(this.game.data['ld47_2']);
        let bounds = this.bounds();
        let pPos: [number, number] = [bounds[2] / 2, bounds[3] / 2];
        let lot = this.calcLastOverTile(pPos);
        this.player = new Player(pPos, lot);
    }

    gridTLOffset() {
        // Compute the ARENA coordinates of the top left of the GRID.
        let bounds = this.bounds();
        let l = bounds[0] + (bounds[2] - this.gw) / 2;
        let t = bounds[1] + (bounds[3] - this.gh) / 2;
        return [l, t];
    }

    calcLastOverTile(pos: [number, number]): [number, number] {
        let tl = this.gridTLOffset();
        return [Math.floor(pos[0] - tl[0]), Math.floor(pos[1] - tl[1])];
    }

    shake() {
        this.shakeEndTime = this.time + SHAKE_DURATION;
    }

    startLevel(levelDesc: LevelDesc) {
        this.tiles = levelDesc.tiles;
        this.gw = this.tiles.length;
        this.gh = this.tiles[0].length;
        let bp = levelDesc.ballPos;
        let bdir = levelDesc.ballDir;
        this.ball = new Ball(bp, [bp[0] + DIR_OFFSETS[bdir][0], bp[1] + DIR_OFFSETS[bdir][1]]);
        this.enemies = [];
        let preDelay = 0;
        if(this.difficulty === 0 || this.difficulty === 1) {
            preDelay = 2.0; // give player some extra time to read the text
        }
        this.showHideTiles(true, preDelay);
        this.nextEnemySpawnTime = this.time + 2 + preDelay;
        this.ball.visibleAfter = this.time + preDelay;
    }

    nextLevel() {
        this.difficulty += 1;
        let sideLen = Math.floor(Math.min(this.difficulty / 4 + 4, 6));
        let levelDesc = makeLevel(sideLen, sideLen, false);
        this.startLevel(levelDesc);
    }

    makeParticle(pos: [number, number], vel: [number, number], color: string) {
        let p = this.particles[this.nextParticle];
        p.pos[0] = pos[0];
        p.pos[1] = pos[1];
        p.vel[0] = vel[0];
        p.vel[1] = vel[1];
        p.inactiveTime = this.time + Math.random() * 0.5;
        p.color = color;
        this.nextParticle = (this.nextParticle + 1) % this.maxParticles;
    }

    makeParticleExplosion(pos: [number, number], rad: number, count: number, color: string) {
        for (let k = 0; k < count; k++) {
            let r = Math.random() * rad;
            let a = Math.random() * Math.PI * 2;
            let offx = r * Math.cos(a);
            let offy = r * Math.sin(a);
            this.makeParticle([pos[0] + offx, pos[1] + offy], [offx * 25, offy * 25], color);
        }
    }

    bounds(): [number, number, number, number] {
        // Return the left, top, width and height coordinates of the game bounds.
        return [0, 0, 8, 8];
    }

    draw() {
        let ctx = this.game.ctx;
        let dims = this.game.getDims();
        let s = dims.scale;
        let gw = this.gw;
        let gh = this.gh;
        let bounds = this.bounds();
        let arenaLeft = dims.cx - s * bounds[2] / 2;
        let arenaTop = dims.cy - s * bounds[3] / 2;
        let gridLeft = dims.cx - s * gw / 2;
        let gridTop = dims.cy - s * gh / 2;
        let pulser = Math.cos(this.time * Math.PI * 2 * (118 / 60 / 2));

        ctx.resetTransform();
        if (this.time < this.shakeEndTime) {
            let xoff = (Math.random() - 0.5) * s / 20;
            let yoff = (Math.random() - 0.5) * s / 20;
            ctx.translate(xoff, yoff);
        }

        // Draw the boundary around the maze
        ctx.beginPath();
        ctx.rect(arenaLeft, arenaTop,
            s * bounds[2], s * bounds[3]);
        neonStroke(ctx, '#44b');

        // Draw the exit and modifiable tiles
        let exitBright = Math.floor(120 + 24 * pulser);
        let exitStyle = 'rgb(' + exitBright / 2 + ',0,' + exitBright + ')';
        let o = this.player.rad * s;
        let sc = o * (1 + 0.05 * pulser);
        for (let row = 0; row < gh; row++) {
            let ct = gridTop + s * row; // cell top coord
            for (let col = 0; col < gw; col++) {
                let cl = gridLeft + s * col; // cell left coord
                let t = this.tiles[col][row];
                if (this.isTileVisible(t)) {
                    if (t.exit) {
                        ctx.fillStyle = exitStyle;
                        ctx.fillRect(cl, ct, s, s);
                        ctx.fillStyle = '#70e';
                        ctx.fillRect(cl + sc, ct + sc, s - sc * 2, s - sc * 2);
                    }
                    if (t.modi) {
                        ctx.fillStyle = '#036';
                        ctx.fillRect(cl, ct, s, s);
                        let tSince = this.time - t.lastModTime;
                        if (tSince < TILE_LIGHT_TIME) {
                            let b = Math.floor(Math.max(0, 1 - tSince / TILE_LIGHT_TIME) * 127);
                            ctx.fillStyle = 'rgb(0,' + (64 + b) + ',' + (128 + b) + ')';
                        }
                        else {
                            ctx.fillStyle = 'rgb(0,64,128)';
                        }
                        ctx.fillRect(cl + o, ct + o, s - o * 2, s - o * 2);
                    }
                }
            }
        }

        // Draw the maze lines and modifiable tiles

        ctx.beginPath();
        for (let row = 0; row < gh; row++) {
            let ct = gridTop + s * row; // cell top coord
            for (let col = 0; col < gw; col++) {
                let t = this.tiles[col][row];
                if (this.isTileVisible(t)) {
                    let cl = gridLeft + s * col; // cell left coord
                    if (t.outDirs[1]) {
                        ctx.moveTo(cl + s / 2, ct + s / 2);
                        ctx.lineTo(cl + s / 2, ct);
                    }
                    if (t.outDirs[3]) {
                        ctx.moveTo(cl + s / 2, ct + s / 2);
                        ctx.lineTo(cl + s / 2, ct + s);
                    }
                    if (t.outDirs[0]) {
                        ctx.moveTo(cl + s / 2, ct + s / 2);
                        ctx.lineTo(cl + s, ct + s / 2);
                    }
                    if (t.outDirs[2]) {
                        ctx.moveTo(cl + s / 2, ct + s / 2);
                        ctx.lineTo(cl, ct + s / 2);
                    }
                }
            }
        }
        neonStroke(ctx, '#08f');

        // Draw particles
        for (let k = 0; k < this.maxParticles; k++) {
            let p = this.particles[k];
            if (p.inactiveTime > this.time) {
                ctx.beginPath();
                ctx.fillStyle = p.color;
                let dpx = arenaLeft + s * p.pos[0];
                let dpy = arenaTop + s * p.pos[1];
                ctx.moveTo(dpx, dpy);
                ctx.arc(dpx, dpy, s * 0.1, 0, Math.PI * 2);
                ctx.fill();
            }
        }

        // Draw bullets
        ctx.beginPath();
        ctx.fillStyle = BULLET_COLOR;
        for (let k = 0; k < this.bullets.length; k++) {
            let b = this.bullets[k];
            let dbx = arenaLeft + s * b.pos[0];
            let dby = arenaTop + s * b.pos[1];
            ctx.moveTo(dbx, dby);
            ctx.arc(dbx, dby, s * BULLET_RAD, 0, Math.PI * 2);
        }
        ctx.fill();


        // Draw the ball
        let b = this.ball;
        if (this.isBallVisible()) {
            let blam = this.getBallLambda();
            let bx = gridLeft + s * (0.5 + lerp(blam, b.lastPos[0], b.nextPos[0]));
            let by = gridTop + s * (0.5 + lerp(blam, b.lastPos[1], b.nextPos[1]));
            let br = s * this.ball.rad;
            ctx.beginPath();
            ctx.arc(bx, by, br, 0, 2 * Math.PI);
            let ballBright = 200 + 16 * pulser;
            ctx.fillStyle = 'rgb(' + ballBright + ',0,0)';
            ctx.fill();
        }

        // Draw the player
        let pr = s * this.player.rad;
        if (this.timeLevelLost === null) {
            let px = this.player.pos[0] * s + arenaLeft;
            let py = this.player.pos[1] * s + arenaTop;
            ctx.globalAlpha = getInvincibleAlpha(this.player.invincibleTime - this.time);
            this.drawPlayer(px, py, pr);
            ctx.globalAlpha = 1;
        }

        // Draw enemies
        for (let eIdx = 0; eIdx < this.enemies.length; eIdx++) {
            let e = this.enemies[eIdx];
            ctx.globalAlpha = getInvincibleAlpha(e.activeTime - this.time);
            let ex = arenaLeft + s * e.pos[0];
            let ey = arenaTop + s * e.pos[1];
            e.draw(ex, ey, s);
        }
        ctx.globalAlpha = 1;

        // Draw player lives
        let mf = this.game.mainFont;
        let textTop = dims.titley - mf.getLineHeight() / 2;
        let infoOffset = 3.5
        for (let k = 0; k < this.player.lives; k++) {
            let str = "Lives:";
            let tw = mf.getWidthOfText(str);
            let l = dims.cx - s * infoOffset;
            mf.drawText(ctx, str, l, textTop);
            let plx = s * (k + 1) * 0.5 + l + tw;
            this.drawPlayer(plx, dims.titley, pr);
        }

        // Draw levels complete
        for (let k = 0; k < this.player.lives; k++) {
            let str = "Completed: " + this.levelsComplete.toString();
            let tw = mf.getWidthOfText(str);
            mf.drawText(ctx, str, dims.cx + s * infoOffset - tw, textTop);
        }

        // Draw message if lost
        if (this.timeLevelLost !== null) {
            this.game.mainFont.drawCenteredText(ctx, "Game Over", dims.cx, dims.titley);
        }

        // Draw instructions
        if (this.timeLevelWon === null) {
            if (this.levelsComplete == 0) {
                let lh = mf.getLineHeight();
                let y = dims.instructiony;
                mf.drawCenteredText(ctx, "Use arrow keys or WASD to move.", dims.cx, y); y += lh;
                mf.drawCenteredText(ctx, "Drive over switches to change direction.", dims.cx, y); y += lh;
                mf.drawCenteredText(ctx, "Get the ball to the exit.", dims.cx, y); y += lh;
            }
            else if (this.levelsComplete == 1) {
                mf.drawCenteredText(ctx, "Avoid enemies. Use left mouse button to fire.", dims.cx, dims.instructiony);
            }
        }
    }

    drawPlayer(screenX: number, screenY: number, screenR: number) {
        let ctx = this.game.ctx;
        ctx.fillStyle = PLAYER_COLOR;
        ctx.fillRect(screenX - screenR, screenY - screenR, screenR * 2, screenR * 2);
    }

    getBallLambda() {
        // Compute the interpolation factor between the last tile and the
        // next tile the ball will be on.
        let b = this.ball;
        return getLambda(this.time, b.lastTime, b.nextTime);
    }

    update(deltat: number) {
        this.time += deltat;

        if (this.timeLevelLost !== null) {
            if (this.time > this.timeLevelLost + 2.0) {
                this.game.endGameplay(this.levelsComplete);
            }

            return true;
        }

        let maxAcc = 100.0;
        let maxVel = 5.0;
        let fricAcc = 50.0;

        // Figure out the player acceleration from the keyboard keys that are down
        let acc = [0, 0];
        if (this.moveDir[0]) acc[0] += 1.0;
        if (this.moveDir[1]) acc[1] -= 1.0;
        if (this.moveDir[2]) acc[0] -= 1.0;
        if (this.moveDir[3]) acc[1] += 1.0;
        let magAcc = (acc[0] * acc[0] + acc[1] * acc[1]) ** 0.5;
        if (magAcc > 0) {
            // Always apply maximum acceleration if it's nonzero
            acc[0] = acc[0] * maxAcc / magAcc;
            acc[1] = acc[1] * maxAcc / magAcc;
        }

        // update player velocity
        let v = this.player.vel;
        v[0] += acc[0] * deltat;
        v[1] += acc[1] * deltat;
        let magVel = (v[0] * v[0] + v[1] * v[1]) ** 0.5;
        if (magVel > 0) {
            let r = 1 - Math.min(1, deltat * fricAcc / magVel);
            v[0] *= r;
            v[1] *= r;
            magVel *= r;
        }
        if (magVel > maxVel) {
            v[0] = v[0] * maxVel / magVel;
            v[1] = v[1] * maxVel / magVel;
        }

        // update player position
        let p = this.player.pos;
        let pr = this.player.rad;
        let bounds = this.bounds();
        p[0] += v[0] * deltat;
        p[1] += v[1] * deltat;
        this.player.pos = boundCoords(p, bounds, pr);

        // check if we should fire any new bullets
        if (this.player.firing && this.player.nextTimeCanFire < this.time) {
            let mp = this.mousePos;
            if (mp != null) {
                mp = this.canvasToArenaCoords(mp);

                // fire the bullet
                let disp = [mp[0] - p[0], mp[1] - p[1]];
                let magDisp = (disp[0] ** 2 + disp[1] ** 2) ** .5;
                let dir = [disp[0] / magDisp, disp[1] / magDisp];

                let b = new Bullet();
                b.pos[0] = p[0] + dir[0] * this.player.rad;
                b.pos[1] = p[1] + dir[1] * this.player.rad;
                b.vel[0] = BULLET_SPEED * dir[0];
                b.vel[1] = BULLET_SPEED * dir[1];
                this.bullets.push(b);
                this.player.nextTimeCanFire = this.time + FIRE_PERIOD;
                this.game.soundPlayer.playSound(this.game.data["ld47shoot"], false);
            }
        }

        // Check if we moved to a new tile
        let nt = this.calcLastOverTile(this.player.pos);
        let ot = this.player.lastOverTile;
        if (nt[0] != ot[0] || nt[1] != ot[1]) {
            this.changePlayerTile(nt);
        }
        this.player.lastOverTile = nt;

        // Update the ball
        let blam = this.getBallLambda();
        if (blam >= 0.5 && !this.ball.passedEdge) {
            // Time to check if we can pass to the next tile, or bounce back.
            this.ball.passedEdge = true;
            let travelDir = getTravelDir(this.ball.lastPos, this.ball.nextPos);
            let bounce = checkBounceNeeded(this.ball.nextPos, travelDir, this.tiles);
            if (bounce) {
                let nbp = this.ball.nextPos;
                this.ball.nextPos = this.ball.lastPos;
                this.ball.lastPos = nbp;
            }
        }
        if (blam >= 1) {
            // Reached the centre of the tile - decide which way to go next.
            this.ball.passedEdge = false;
            let nbp = this.ball.nextPos;
            let t = this.tiles[nbp[0]][nbp[1]];
            if (t.exit) {
                if (this.timeLevelWon === null) this.winLevel();
            }
            else {
                // Choose which way to go next. {refer not to go back the way we came.
                // But you'll have to if it's a dead end.
                let travelDir = getTravelDir(this.ball.lastPos, nbp);
                this.ball.nextPos = calculateNextBallPos(nbp, travelDir, this.tiles)
                this.ball.lastPos = nbp;
                this.ball.lastTime = this.ball.nextTime;
                this.ball.nextTime = this.time + this.ball.stepTime;
            }
        }

        // update all the enemies and check if they collide with the player
        for (let eIdx = 0; eIdx < this.enemies.length; eIdx++) {
            let e = this.enemies[eIdx];
            e.update(deltat);

            if (e.activeTime < this.time && this.player.invincibleTime < this.time &&
                this.timeLevelWon === null) {
                let dx = e.pos[0] - this.player.pos[0];
                let dy = e.pos[1] - this.player.pos[1];
                let dist = (dx * dx + dy * dy) ** 0.5;

                if (dist < (e.rad + this.player.rad) * 0.9) {
                    this.player.lives -= 1;
                    this.player.invincibleTime = this.time + INVINCIBLE_RECOVER_TIME;
                    e.destroy();
                }

            }
            
            if(e.destroyTime !== null && this.time > e.destroyTime) e.destroy();
        }

        // update bullets and do collisions with enemies
        for (let k = 0; k < this.bullets.length; k++) {
            let b = this.bullets[k];
            b.pos[0] += deltat * b.vel[0];
            b.pos[1] += deltat * b.vel[1];
            if (b.pos[0] < bounds[0] || b.pos[0] > bounds[0] + bounds[2] ||
                b.pos[1] < bounds[1] || b.pos[1] > bounds[1] + bounds[3]) {
                b.destroyed = true
            }
            else {
                for (let l = 0; l < this.enemies.length; l++) {
                    let e = this.enemies[l];
                    if (e.activeTime < this.time && this.timeLevelWon === null) {
                        let dist = ((e.pos[0] - b.pos[0]) ** 2 + (e.pos[1] - b.pos[1]) ** 2) ** 0.5;
                        if (dist < e.rad + BULLET_RAD) {
                            e.destroy();
                            b.destroyed = true;
                        }
                    }
                }
            }
        }
        this.bullets = this.bullets.filter(b => !b.destroyed);
        this.enemies = this.enemies.filter(e => !e.destroyed);

        // Decide whether to create some new enemies
        if (this.time > this.nextEnemySpawnTime && this.timeLevelWon === null) {
            this.nextEnemySpawnTime = this.time + calcEnemySpawnTime(this.difficulty);
            this.doEnemySpawn();
        }

        // update particles
        for (let k = 0; k < this.maxParticles; k++) {
            let p = this.particles[k];
            if (p.inactiveTime > this.time) {
                p.pos[0] += p.vel[0] * deltat;
                p.pos[1] += p.vel[1] * deltat;
            }
        }

        // check if game is lost
        if (this.player.lives <= 0) {
            this.player.lives = 0;
            this.timeLevelLost = this.time;
            this.game.jukeBox.setMusic();
            this.game.soundPlayer.playSound(this.game.data["ld47lost"], false);
        }

        // after the level's won, restart
        if (this.timeLevelWon !== null && this.time > this.timeLevelWon + 2.0) {
            this.timeLevelWon = null;
            this.nextLevel();
        }

        return true;
    }

    async doEnemySpawn() {
        // Spawn some enemies

        // For early levels, only the basic enemies are generated.
        // Later, we randomly choose a spawning pattern.
        let spawnTypes: number;
        if (this.difficulty < 3) spawnTypes = 1;
        else spawnTypes = 2;
        let bounds = this.bounds();
        let maxEnemies = this.difficulty + 6;

        let spawnType = randInt(spawnTypes);
        if (spawnType == 0) {
            // Place one or more wanderers randomly around the level.
            let count = Math.ceil(Math.random() * this.difficulty / 2);
            count = Math.min(count, maxEnemies - this.enemies.length);
            let timeBetween = 0.5 / Math.max(1, count - 1);
            for (let k = 0; k < count; k++) {
                let x = Math.random() * bounds[2];
                let y = Math.random() * bounds[3];
                this.enemies.push(new Wanderer(this, [x, y]));
                await PH.delay(timeBetween);
                if(this.timeLevelWon !== null) break;
            }
        }
        else if(spawnType == 1) {
            // Generate a line of fliers.
            let count = Math.min(this.difficulty, maxEnemies - this.enemies.length);
            let vertical = randInt(2);
            let startx, endx, starty, endy: number;
            startx = (randInt(2) * 0.8 + 0.1) * bounds[2];
            starty = (randInt(2) * 0.8 + 0.1) * bounds[3];
            if(vertical > 0) {
                endx = startx;
                endy = bounds[3] - starty;
            }
            else {
                endy = starty;
                endx = bounds[2] - startx;
            }
            startx += bounds[0];
            endx += bounds[0];
            starty += bounds[1];
            endy += bounds[1];
            let timeBetween = 0.5 / Math.max(1, count - 1);
            for(let k = 0; k < count; k++) {
                let lam = k / (count - 1);
                let pos: [number, number] = [lerp(lam, startx, endx), lerp(lam, starty, endy)];
                this.enemies.push(new Flyer(this, pos));
                await PH.delay(timeBetween);
                if(this.timeLevelWon !== null) break;
            }
        }
    }

    winLevel() {
        this.ball.invisibleAfter = this.time;
        let gridtl = this.gridTLOffset();
        let expPos: [number, number] = [gridtl[0] + this.ball.nextPos[0] + 0.5, gridtl[1] + this.ball.nextPos[1] + 0.5];
        this.makeParticleExplosion(expPos, this.ball.rad, 20, 'rgb(200, 0, 0)');
        this.timeLevelWon = this.time;
        this.levelsComplete++;
        this.destroyAllEnemies();
        this.showHideTiles(false, 0);
        this.game.soundPlayer.playSound(this.game.data["ld47win"], false);
    }

    async destroyAllEnemies() {
        let maxTime = 1.0;
        let gap = maxTime / Math.max(1, this.enemies.length);
        gap = Math.min(gap, 0.1);
        let c = this.time;
        for (let e of this.enemies) {
            e.destroyTime = c;
            c += gap;
        }
    }

    async showHideTiles(visible: boolean, preDelay: number) {
        let offset = preDelay;
        for (let row = 0; row < this.gh; row++) {
            for (let col = 0; col < this.gw; col++) {
                if (visible) {
                    this.tiles[col][row].visibleAfter = this.time + offset;
                }
                else {
                    this.tiles[col][row].invisibleAfter = this.time + offset;
                }
                offset += 0.02
            }
        }
    }

    canvasToArenaCoords(pos: [number, number]): [number, number] {
        let dims = this.game.getDims();
        let s = dims.scale;
        let bounds = this.bounds();
        let arenaLeft = dims.cx - s * bounds[2] / 2;
        let arenaTop = dims.cy - s * bounds[3] / 2;
        let result: [number, number] = [(pos[0] - arenaLeft) / s, (pos[1] - arenaTop) / s];
        return result;
    }

    isTileVisible(t: Tile) {
        return (t.visibleAfter < this.time && this.time < t.invisibleAfter);
    }

    isBallVisible() {
        return (this.ball.visibleAfter < this.time && this.time < this.ball.invisibleAfter);
    }

    changePlayerTile(tc: [number, number]) { // pass me a tile coordinates (a pair of integers)
        let tiles = this.tiles;
        let gw = this.gw;
        let gh = this.gh;
        if (tc[0] >= 0 && tc[0] < gw && tc[1] >= 0 && tc[1] < gh) {
            let t = this.tiles[tc[0]][tc[1]];
            if (t.modi && this.isTileVisible(t)) {
                rotateTileUntilConnecting([tc[0], tc[1]], tiles);
                t.lastModTime = this.time;
                this.game.soundPlayer.playSound(this.game.data["ld47switch"], false);
            }
        }
    }

    handleKeyChange(code: string, down: boolean) {
        for (let k = 0; k < this.moveKeys.length; k++) {
            if (this.moveKeys[k].indexOf(code) > -1) {
                this.moveDir[k] = down;
                return false;
            }
        }
        return true;
    }

    handleKeyDown(e: KeyboardEvent) {
        return this.handleKeyChange(e.code, true);
    }

    handleKeyUp(e: KeyboardEvent) {
        return this.handleKeyChange(e.code, false);
    }

    handleMouseDown() {
        this.player.firing = true;
        return false;
    }

    handleMouseUp() {
        this.player.firing = false;
        return false;
    }

    handleMouseMove(mousePos: PH.MousePosition) {
        this.mousePos = mousePos;
    }

}
