let CORNER_SHRINK = 0.5; // Reduce the collision box by this much, so we don't get stopped by the corner of a box
let MAX_PARTICLES = 100;
let MAX_GHOST_PARTICLES = 10;
let GHOST_GEN_PERIOD = 0.05;
let PLAYER_PARTICLE_STYLE = '#60abfd';
let MAX_DISCO_SQUARES = 200;
let DISCO_SQUARE_AGE = 0.5;

// Timing for the ending sequence
let START_SHAKE_TIME = 1.5;
let START_DISSOLVE_TIME = 3.0;
let START_FADE_PLAYER_TIME = 4.0;
let FADE_DURATION = 3.0;
let START_FADE_SCREEN_TIME = 6.0;
let END_TIME = START_FADE_SCREEN_TIME + FADE_DURATION + 2;


function lerp(x: number, x0: number, x1: number, y0: number, y1: number) {
    let lam = (x - x0) / (x1 - x0);
    if (lam < 0) lam = 0;
    if (lam > 1) lam = 1;
    return y0 + lam * (y1 - y0);
}

class PlayerState {
    pos: [number, number];
    rad: [number, number];
    vel: [number, number] = [0, 0];
    grounded = true;
    startedFallingTime = 0;
    conduitGrid: boolean[][];
    sectionConduitStack: { [key: string]: [number, number][] };
    sectionConnected: { [key: string]: boolean };
    alive = true;
    seenSection: { [key: string]: boolean } = {};
    treasureGot: boolean[];

    constructor(pos: [number, number], rad: [number, number], map: GameMap) {
        this.pos = pos;
        this.rad = rad;

        this.conduitGrid = [];
        for (let j = 0; j < map.h; j++) {
            this.conduitGrid.push([]);
            for (let i = 0; i < map.w; i++) {
                this.conduitGrid[j].push(false);
            }
        }
        this.sectionConduitStack = {};
        this.sectionConnected = {};
        Object.keys(map.sectionSource).forEach(s => {
            let scoord = map.sectionSource[s];
            this.sectionConduitStack[s] = [scoord];
            this.conduitGrid[scoord[1]][scoord[0]] = true;
            this.sectionConnected[s] = false;
        });
        this.treasureGot = [];
        for (let k = 0; k < map.treasures.length; k++) {
            this.treasureGot.push(false);
        }
    }
}

class TileNeighborhood {
    minI: number;
    maxI: number;
    minJ: number;
    maxJ: number;
    constructor(map: GameMap, ts: number, x: number, y: number, rx: number, ry: number) {
        // Get a range of tiles within a rectangular region, given the pixel coordinates
        this.minI = Math.max(0, Math.floor((x - rx) / ts))
        this.maxI = Math.min(map.w, Math.ceil((x + rx) / ts));
        this.minJ = Math.max(0, Math.floor((y - ry) / ts));
        this.maxJ = Math.min(map.h, Math.ceil((y + ry) / ts));
    }
}

class Particle {
    pos: [number, number];
    vel: [number, number];
    style: string;
    active: boolean;
    age: number;

    constructor() {
        this.pos = [0, 0];
        this.vel = [0, 0];
        this.style = "";
        this.active = false;
        this.age = 0;
    }
}

class GhostParticle {
    pos: [number, number];
    vel: [number, number];
    active: boolean;
    age: number;
    sx: number;

    constructor() {
        this.pos = [0, 0];
        this.vel = [0, 0];
        this.sx = 0;
        this.active = false;
        this.age = 0;
    }
}

class DiscoSquare {
    pos: [number, number];
    rgb: [number, number, number]
    active: boolean;
    age: number;

    constructor() {
        this.pos = [0, 0],
            this.rgb = [0, 0, 0];
        this.active = false;
        this.age = 0;
    }
}

class GameLayer extends PH.Layer {

    game: Game;
    gameTime: number = 0;
    map: GameMap;
    playerState: PlayerState;

    cx = 160;
    cy = 100;
    ts = 8; // tile size
    maxRunSpeed = 60;
    maxFallSpeed = 180;
    runAccel = 1000;
    airAccel = 400;
    jumpSpeed = 120;
    gravity = 420;
    pushingLeft = false;
    pushingRight = false;
    pushingJump = false;
    previouslyPushingJump = false;
    pushJumpTime: number | null = null;
    pushJumpMaxTime = 0.1;
    jumpGroundedLeniencyTime = 0.15;
    panelMode = false;
    particles: Particle[];
    nextParticleIdx = 0;
    ghostParticles: GhostParticle[];
    nextGhostParticleIdx = 0;
    lastGhostGenTime = 0;
    panelSection: string = '';
    camY: number;
    wonTime: number | null = null;
    lastMoveDir: number = 1;
    gmp: GameMusicPlayer;
    playedWonSound = false;
    lastDiscoSquareTime = 3;
    discoSquares: DiscoSquare[];
    nextDiscoSquareIdx = 0;
    textSeenTime: (number | null)[];

    constructor(game: Game) {
        super();
        this.game = game;
        this.map = <GameMap>this.game.data['map'];
        let pRad: [number, number] = [2, 3];
        let pPos: [number, number] = [(this.map.playerStartPos[0] + 0.5) * this.ts, (this.map.playerStartPos[1] + 1) * this.ts - pRad[1]]
        this.playerState = new PlayerState(pPos, pRad, this.map);
        this.particles = [];
        for (let i = 0; i < MAX_PARTICLES; i++) {
            this.particles.push(new Particle());
        }
        this.ghostParticles = [];
        for (let i = 0; i < MAX_GHOST_PARTICLES; i++) {
            this.ghostParticles.push(new GhostParticle());
        }
        this.discoSquares = [];
        for (let i = 0; i < MAX_DISCO_SQUARES; i++) {
            this.discoSquares.push(new DiscoSquare());
        }

        this.textSeenTime = [];
        for(let k = 0; k < this.map.textPoints.length; k++) {
            this.textSeenTime.push(null);
        }

        this.camY = this.getTargetCameraY();

        this.gmp = new GameMusicPlayer(this.game);
    }

    tileBlocksPlayer(i: number, j: number): boolean {
        // Return true if the tile blocks the player's passage.
        let gt = this.map.grid[j][i];
        let t = gt.t;
        if (t === TileType.Wall) return true;
        if (t === TileType.Barrier) {
            return !this.playerState.sectionConnected[gt.section];
        }
        if (this.playerState.conduitGrid[j][i]) return true;
        return false;
    }

    tileOverlapsPlayer(i: number, j: number): boolean {
        // Determine whether a tile overlaps the player, for the purpose of extending conduit.
        let tl = i * this.ts;
        let tr = (i + 1) * this.ts;
        let tt = j * this.ts;
        let tb = (j + 1) * this.ts;
        let pos = this.playerState.pos;
        let rad = this.playerState.rad;
        let overlapHorizRed = (tl < pos[0] + rad[0] - CORNER_SHRINK && tr > pos[0] - rad[0] + CORNER_SHRINK);
        let overlapVertRed = (tt < pos[1] + rad[1] - CORNER_SHRINK && tb > pos[1] - rad[1] + CORNER_SHRINK);

        return overlapHorizRed && overlapVertRed;
    }

    tileBlocksConduit(i: number, j: number) {
        // Determine whether a tile prevents conduit from being built there

        // The following things prevent conduit
        // (i) some kinds of map tiles,
        // (ii) the player in the way,
        // (iii) a piece of conduit.
        let t = this.map.grid[j][i].t;
        if (t == TileType.Wall || t == TileType.Spikes || t == TileType.Barrier) return true;
        if (this.tileOverlapsPlayer(i, j)) return true;
        if (this.playerState.conduitGrid[j][i]) return true;

        return false;
    }

    isPlayerOverTileType(t: TileType): [number, number] | null {
        // Compute whether the player is touching a tile of specified type. If it is,
        // return the coordinates of an arbitrary such tile.

        let pos = this.playerState.pos;
        let ne = new TileNeighborhood(this.map, this.ts, pos[0], pos[1], this.playerState.rad[0], this.playerState.rad[1]);
        for (let i = ne.minI; i < ne.maxI; i++) {
            for (let j = ne.minJ; j < ne.maxJ; j++) {
                if (this.map.grid[j][i].t === t && this.tileOverlapsPlayer(i, j)) {
                    return [i, j];
                }
            }
        }
        return null;
    }

    updatePlayerPos(deltat: number) {
        let bottomShrink = 1.1; // Shrink the bottom by more, for smoother landings
        let bottomMargin = 0.01; // Extend the bottom of the player's collision box by this much, for searching for ground below the player

        // Update the player's position and velocity based on the intended velocity, and collisions with the map.
        let oldPos = this.playerState.pos;
        let vel = this.playerState.vel;
        // Apply falling acceleration
        if (!this.playerState.grounded) {
            this.playerState.vel[1] += this.gravity * deltat;
        }
        if (vel[1] > this.maxFallSpeed) vel[1] = this.maxFallSpeed;
        let newPos: [number, number] = [oldPos[0] + vel[0] * deltat, oldPos[1] + vel[1] * deltat];
        let rad = this.playerState.rad;

        // Compute the neighbourhood of tiles where we need to consider collision.
        let ne = new TileNeighborhood(this.map, this.ts, newPos[0], newPos[1], this.playerState.rad[0], this.playerState.rad[1] + bottomMargin);

        let hitLeftX = null; // X-coordinate of a tile's left edge, if we hit one
        let hitRightX = null; // X-coordinate of a tile's right edge, if we hit one
        let hitTopY = null; // Y-coordinate of a tile's top edge, if we hit one
        let hitBottomY = null; // Y-coordinate of a tile's bottom edge, if we hit one


        for (let i = ne.minI; i < ne.maxI; i++) {
            for (let j = ne.minJ; j < ne.maxJ; j++) {
                if (this.tileBlocksPlayer(i, j)) {
                    // Tile blocks player and is near the player. Check the intersection carefully.
                    let tl = i * this.ts;
                    let tr = (i + 1) * this.ts;
                    let tt = j * this.ts;
                    let tb = (j + 1) * this.ts;
                    let overlapHorizRed = (tl < newPos[0] + rad[0] - CORNER_SHRINK && tr > newPos[0] - rad[0] + CORNER_SHRINK);
                    let overlapHorizExt = (tl < newPos[0] + rad[0] && tr > newPos[0] - rad[0]);
                    let overlapVertRed = (tt < newPos[1] + rad[1] - bottomShrink && tb > newPos[1] - rad[1] + CORNER_SHRINK);
                    let overlapVertExt = (tt < newPos[1] + rad[1] + bottomMargin && tb > newPos[1] - rad[1]);
                    if (overlapHorizExt && overlapVertExt) {
                        if (tl > newPos[0] - rad[0] && tl < newPos[0] + rad[0] && overlapVertRed) hitLeftX = (hitLeftX === null ? tl : Math.min(tl, hitLeftX));
                        if (tr > newPos[0] - rad[0] && tr < newPos[0] + rad[0] && overlapVertRed) hitRightX = (hitRightX === null ? tr : Math.max(tr, hitRightX));
                        if (tt > newPos[1] - rad[1] && tt < newPos[1] + rad[1] + bottomMargin && overlapHorizRed) hitTopY = (hitTopY === null ? tt : Math.min(tt, hitTopY));
                        if (tb > newPos[1] - rad[1] && tb < newPos[1] + rad[1] + bottomMargin && overlapHorizRed) hitBottomY = (hitBottomY === null ? tb : Math.max(tb, hitBottomY));
                    }
                }
            }
        }

        // Edges of the map are walls
        let gameW = this.map.w * this.ts;
        if (newPos[0] - rad[0] <= 0) hitRightX = (hitRightX === null ? 0 : Math.max(0, hitRightX));
        if (newPos[0] + rad[0] >= gameW) hitLeftX = (hitLeftX === null ? gameW : Math.min(gameW, hitLeftX));
        if (newPos[1] - rad[1] <= 0) hitBottomY = (hitBottomY === null ? 0 : Math.max(0, hitBottomY));

        // Right edge and moving left? Stop moving left
        if (hitLeftX !== null && vel[0] > 0) {
            vel[0] = 0;
            newPos[0] = hitLeftX - rad[0];
        }
        // Left edge and moving right? Stop moving right
        if (hitRightX !== null && vel[0] < 0) {
            vel[0] = 0;
            newPos[0] = hitRightX + rad[0];
        }
        // Top edge and moving down? Stop moving down and mark as grounded.
        if (hitTopY !== null && vel[1] > 0) {
            vel[1] = 0;
            newPos[1] = hitTopY - rad[1];
            this.playerState.grounded = true;
        }
        // Bottom edge and moving up? Stop moving up.
        if (hitBottomY !== null && vel[1] < 0) {
            vel[1] = 0;
            newPos[1] = hitBottomY + rad[1];
        }

        // Finally update the player's position.
        this.playerState.pos = newPos;

        // check if we stepped off the ground
        if (this.playerState.grounded && hitTopY === null) {
            this.playerState.grounded = false;
            this.playerState.startedFallingTime = this.gameTime;
        }
    }

    microUpdate(deltat: number) {
        // Update step that can happen more than once a frame, for predictability purposes.
        this.gameTime += deltat;

        // update player velocity based on input
        if (this.playerState.alive && !this.wonTime) {
            let acc = this.playerState.grounded ? this.runAccel : this.airAccel;
            let vel = this.playerState.vel;

            if (this.pushingLeft && !this.pushingRight) {
                vel[0] -= acc * deltat;
            }
            else if (this.pushingRight && !this.pushingLeft) {
                vel[0] += acc * deltat;
            }
            else {
                // slow down
                if (vel[0] < 0) vel[0] = Math.min(0, vel[0] + acc * deltat);
                if (vel[0] > 0) vel[0] = Math.max(0, vel[0] - acc * deltat);
            }
            if (vel[0] < -this.maxRunSpeed) vel[0] = -this.maxRunSpeed;
            if (vel[0] > this.maxRunSpeed) vel[0] = this.maxRunSpeed;

            if (this.pushJumpTime !== null && this.pushJumpTime < this.gameTime - this.pushJumpMaxTime) {
                this.pushJumpTime = null;
            }

            let wantToJump = (this.pushingJump && this.pushJumpTime !== null);
            let canJump = this.playerState.alive && (this.playerState.grounded || (this.playerState.startedFallingTime + this.jumpGroundedLeniencyTime > this.gameTime));
            if (wantToJump && canJump) {
                this.playerState.grounded = false;
                this.playerState.vel[1] = -this.jumpSpeed;
                this.pushingJump = false;
                this.pushJumpTime = null;
                this.game.soundPlayer.playSound(this.game.data['Jump'], false);
            }

            this.updatePlayerPos(deltat);

            // Check if the player popped on spikes
            if (this.isPlayerOverTileType(TileType.Spikes) !== null) {
                this.playerState.alive = false;
                this.popPlayer();
            }
        }

        if (this.wonTime !== null && this.gameTime > this.wonTime + START_SHAKE_TIME) {
            this.playerState.pos[1] -= 5 * deltat;
        }

    }

    update(deltat: number): boolean {
        let microTimeStep = 1 / 180; // Game loop time step
        let targetTime = this.gameTime + deltat;
        while (this.gameTime < targetTime) {
            this.microUpdate(microTimeStep);
        }
        if (this.panelMode && !this.isPlayerOverTileType(TileType.Panel)) {
            this.tryTogglePanelMode();
        }

        // Check for collecting treasure
        for (let k = 0; k < this.map.treasures.length; k++) {
            let tPos = this.map.treasures[k].pos;
            if (!this.playerState.treasureGot[k] && this.tileOverlapsPlayer(tPos[0], tPos[1])) {
                this.playerState.treasureGot[k] = true;
                this.game.soundPlayer.playSound(this.game.data['Treasure'], false);
            }
        }

        // Check if the game is won
        if (this.wonTime === null && this.isPlayerOverTileType(TileType.Ending)) {
            this.wonTime = this.gameTime;
        }
        // updates for if the game is won
        if (this.wonTime !== null) {
            if (this.gameTime > this.wonTime + START_DISSOLVE_TIME) {
                let nps = deltat * 100;
                let pPos = this.playerState.pos;
                let pRad = this.playerState.rad;
                for (let k = 0; k < nps; k++) {
                    this.createParticle(pPos[0] + (Math.random() * 2 - 1) * pRad[0], pPos[1] + (Math.random() * 2 - 1) * pRad[1], 0, -Math.random() * 30, PLAYER_PARTICLE_STYLE);
                }
                if (!this.playedWonSound) {
                    this.playedWonSound = true;
                    this.game.soundPlayer.playSound(this.game.data['Win'], false);
                }
            }
            if (this.gameTime > this.wonTime + END_TIME) {
                // Calculate how many treasures we got, and then end the game.
                let numTreasure = this.playerState.treasureGot.length;
                let numColl = 0;
                for (let k = 0; k < numTreasure; k++) {
                    if (this.playerState.treasureGot[k]) numColl++;
                }

                this.game.endGame(numColl, numTreasure);
            }
        }

        // Check if we need to save
        let s = this.getPlayerSection();
        let needToSave = !this.playerState.seenSection[s] && this.map.savePointSections[s];
        this.playerState.seenSection[s] = true;
        if (needToSave) {
            this.saveState();
        }

        // update particles
        for (let k = 0; k < MAX_PARTICLES; k++) {
            let p = this.particles[k];
            p.pos[0] += p.vel[0] * deltat;
            p.pos[1] += p.vel[1] * deltat;
            p.age += deltat;
            if (p.age > 0.5) {
                p.active = false;
            }
        }

        for (let k = 0; k < MAX_GHOST_PARTICLES; k++) {
            let gp = this.ghostParticles[k];
            gp.pos[0] += gp.vel[0] * deltat;
            gp.pos[1] += gp.vel[1] * deltat;
            gp.age += deltat;
            if (gp.age > 0.5) {
                gp.active = false;
            }
        }

        // Update disco squares
        for (let k = 0; k < MAX_DISCO_SQUARES; k++) {
            let ds = this.discoSquares[k];
            ds.age += deltat;
            if (ds.age > DISCO_SQUARE_AGE) {
                ds.active = false;
            }
        }

        if (this.playerState.alive && this.gameTime > this.lastGhostGenTime + GHOST_GEN_PERIOD) {
            let r = Math.random() * 10;
            let a = Math.random() * 2 * Math.PI;
            let dx = r * Math.cos(a);
            let dy = r * Math.sin(a);
            this.generateGhost([dx, dy]);
            this.lastGhostGenTime = this.gameTime;
        }

        // Update music track volumes
        if (this.gameTime > 3) {
            let completion = (this.playerState.pos[1] / this.ts - 20) / (this.map.h - 60);
            if (completion < 0) completion = 0;
            if (completion > 1) completion = 1;
            let aGain: number;
            let bGain: number;
            let drumGain: number;
            let gKnots = [0, 0.3, 0.5, 0.7, 0.9, 1];
            if (this.wonTime !== null) {
                aGain = 0;
                bGain = 0;
                drumGain = 0;
            }
            else if (completion < gKnots[1]) {
                aGain = 0;
                bGain = 1;
                drumGain = 0;
            }
            else if (completion < gKnots[2]) {
                aGain = 0;
                bGain = 1;
                drumGain = lerp(completion, gKnots[1], gKnots[2], 0, 0.5);
            }
            else if (completion < gKnots[3]) {
                aGain = lerp(completion, gKnots[2], gKnots[3], 0, 0.3);
                bGain = lerp(completion, gKnots[2], gKnots[3], 1, 0);
                drumGain = 1;
            }
            else if (completion < gKnots[4]) {
                aGain = lerp(completion, gKnots[3], gKnots[4], 0.3, 1);
                bGain = 0;
                drumGain = lerp(completion, gKnots[3], gKnots[4], 1, 0);
            }
            else {
                aGain = 1;
                bGain = 0;
                drumGain = 0;
            }
            this.gmp.setTargetGain(aGain, bGain, drumGain);

            // Decide whether to add a disco square
            if (completion > 0.3) {
                let dp = Math.pow(lerp(completion, 0.3, 1, 1, 0.25), 4);
                while (this.gameTime - this.lastDiscoSquareTime > dp) {
                    this.createDiscoSquare();
                    this.lastDiscoSquareTime += dp
                }
            }
            else {
                // Do this to prevent a sudden surge of disco squares when they start happening.
                this.lastDiscoSquareTime = this.gameTime;
            }
        }

        // Move the camera towards the target camera position
        let targetY = this.getTargetCameraY();
        this.camY = targetY + (this.camY - targetY) * Math.pow(0.1, deltat);

        return true;
    }

    getTargetCameraY(): number {
        let camMargin = 4;
        let playerSection = this.getPlayerSection();
        let targetY: number;
        if (playerSection == '') {
            targetY = this.playerState.pos[1];
        }
        else {
            let lower = (this.map.sectionHeightRange[playerSection][0] - camMargin) * this.ts + this.cy;
            let upper = (this.map.sectionHeightRange[playerSection][1] + camMargin) * this.ts - this.cy;
            if (lower > upper) {
                targetY = (lower + upper) / 2;
            }
            else {
                targetY = this.playerState.pos[1];
                if (targetY < lower) targetY = lower;
                if (targetY > upper) targetY = upper;
            }
        }

        // bound at top and bottom
        //targetY = Math.max(this.cy, Math.min(this.map.h * this.ts - this.cy, targetY));

        return targetY;
    }

    saveState() {
        PH.localStorageSave('save', JSON.stringify(this.playerState));
    }

    loadState() {
        if (PH.localStorageIsSaved('save')) {
            this.playerState = JSON.parse(PH.localStorageLoad('save'));

            // The panel mode isn't saved, but we should probably just turn it off.
            if (this.panelMode) {
                this.tryTogglePanelMode();
            }

            // Reset the text displays
            for(let k = 0; k < this.textSeenTime.length; k++) {
                this.textSeenTime[k] = null;
            }
        }
    }

    drawTile(x: number, y: number, i: number, j: number, img: number) {
        // Draw an 8-by-8 tile as an image

    }

    drawBdry(x: number, y: number, i: number, j: number, src: CanvasImageSource) {
        let map = this.map;
        let ctx = this.game.ctx;
        let thisType = map.grid[j][i].t;
        function doBdry(t: TileType) {
            return (thisType === TileType.Wall && t !== TileType.Wall ||
                thisType == TileType.Space && t === TileType.Sky);
        }
        if (i > 0 && doBdry(map.grid[j][i - 1].t)) {
            ctx.drawImage(src, 0, 0, 8, 8, x, y, 8, 8);
        }
        if (i < map.w - 1 && doBdry(map.grid[j][i + 1].t)) {
            ctx.drawImage(src, 8, 0, 8, 8, x, y, 8, 8);
        }
        if (j > 0 && doBdry(map.grid[j - 1][i].t)) {
            ctx.drawImage(src, 16, 0, 8, 8, x, y, 8, 8);
        }
        if (j < map.h - 1 && doBdry(map.grid[j + 1][i].t)) {
            ctx.drawImage(src, 24, 0, 8, 8, x, y, 8, 8);
        }
    }

    draw() {
        let ctx = this.game.ctx;
        let data = this.game.data;

        let map = this.map;

        let camX = this.ts * this.map.w / 2;
        let camY = this.camY;

        // Draw the sky
        ctx.drawImage(data['sky'], 0, 0);

        // Apply shake
        ctx.resetTransform();
        if (this.wonTime !== null) {
            let tShake = this.gameTime - (this.wonTime + START_SHAKE_TIME);
            if (tShake > 0) {
                let offX = Math.floor(Math.random() * 3 - 1);
                let offY = Math.floor(Math.random() * 3 - 1);
                ctx.translate(offX, offY);
            }
        }


        // Work out the tile coords that need to be drawn
        let drawNeighb = new TileNeighborhood(map, this.ts, camX, camY, this.cx, this.cy);

        // Draw background stuff
        let drawEndPos: [number, number] | null = null;
        let barrierAlpha = 0.75 + 0.1 * Math.cos(this.gameTime * 5);
        for (let i = drawNeighb.minI; i < drawNeighb.maxI; i++) {
            for (let j = drawNeighb.minJ; j < drawNeighb.maxJ; j++) {
                let x = Math.floor(this.cx + i * this.ts - camX);
                let y = Math.floor(this.cy + j * this.ts - camY);
                let t = map.grid[j][i].t;
                if (t == TileType.Wall) {
                    ctx.fillStyle = '#000';
                    ctx.fillRect(x, y, this.ts, this.ts);
                    this.drawBdry(x, y, i, j, data['wall']);
                }
                else if (t != TileType.Sky) { // not sky - draw the background wall
                    ctx.fillStyle = '#040404';
                    ctx.fillRect(x, y, this.ts, this.ts);
                    this.drawBdry(x, y, i, j, data['space']);
                }

                if (t == TileType.Barrier) {
                    let s = map.grid[j][i].section;
                    if (this.playerState.sectionConnected[s]) {
                        ctx.fillStyle = 'rgb(0, 255, 255, ' + barrierAlpha + ')';
                    }
                    else {
                        ctx.fillStyle = 'rgb(255, 0, 0, ' + barrierAlpha + ')';
                    }
                    ctx.fillRect(x, y, this.ts, this.ts);
                }
                else if (t == TileType.Panel) {
                    if (((this.gameTime * 1) % 2) < 1) {
                        ctx.drawImage(data['panel'], 0, 0, 8, 8, x, y, 8, 8);
                    }
                    else {
                        ctx.drawImage(data['panel'], 8, 0, 8, 8, x, y, 8, 8);
                    }
                }
                else if (t == TileType.Sink) {
                    ctx.drawImage(data['sink'], x, y);
                }
                else if (t == TileType.Spikes) {
                    ctx.drawImage(data['spikes'], x, y);
                }
                else if (t == TileType.Ending) {
                    drawEndPos = [x, y];
                }
            }
        }

        // Draw disco squares
        for (let k = 0; k < MAX_DISCO_SQUARES; k++) {
            let ds = this.discoSquares[k];
            if (ds.active) {
                let i = ds.pos[0];
                let j = ds.pos[1];
                let x = Math.floor(this.cx + i * this.ts - camX);
                let y = Math.floor(this.cy + j * this.ts - camY);
                let alpha = lerp(ds.age, 0, 1, 0.25, 0);
                ctx.fillStyle = 'rgba(' + ds.rgb[0] + ',' + ds.rgb[1] + ',' + ds.rgb[2] + ',' + alpha + ')';
                ctx.fillRect(x, y, this.ts, this.ts);
            }
        }

        if (drawEndPos !== null) {
            let offset = Math.round(Math.cos(this.gameTime) * 3);
            let sx = 16 * Math.floor((this.gameTime / 1.6234) % 2);
            ctx.drawImage(data['endthing'], sx, 0, 16, 16, drawEndPos[0] - 4, drawEndPos[1] - 9 - offset, 16, 16);
        }
        // Draw treasure
        for (let k = 0; k < this.map.treasures.length; k++) {
            if (!this.playerState.treasureGot[k]) {
                let pi = this.map.treasures[k].pos[0];
                let pj = this.map.treasures[k].pos[1];
                if (pi >= drawNeighb.minI && pi < drawNeighb.maxI && pj >= drawNeighb.minJ && pj < drawNeighb.maxJ) {
                    let x = Math.floor(this.cx + pi * this.ts - camX);
                    let y = Math.floor(this.cy + pj * this.ts - camY);
                    let sx = 8 * Math.floor((this.gameTime / 1.6234) % 2);
                    ctx.drawImage(data['treasure'], sx, 0, 8, 8, x, y, 8, 8);
                }
            }
        }

        // Draw relevant text
        for (let k = 0; k < this.map.textPoints.length; k++) {
            let tp = this.map.textPoints[k];
            if (tp.pos[1] >= drawNeighb.minJ && tp.pos[1] < drawNeighb.maxJ) {
                let x = Math.floor(this.cx + tp.pos[0] * this.ts - camX);
                let y = Math.floor(this.cy + tp.pos[1] * this.ts - camY);
                let doDraw = true;
                if (tp.whisper) {
                    for(let k = 0; k < map.treasures.length; k++) {
                        if(map.treasures[k].name === tp.name) {
                            doDraw = this.playerState.treasureGot[k];
                        }
                    }
                }
                if (doDraw) {
                    if(this.textSeenTime[k] === null) {
                        this.textSeenTime[k] = this.gameTime;
                    }
                    let a = lerp(this.gameTime, this.textSeenTime[k]!, this.textSeenTime[k]! + 3, 0, 1);
                    if(tp.whisper) {
                        a = a * (0.3 + 0.1 * Math.sin(this.gameTime - tp.pos[1]));
                    }
                    ctx.globalAlpha = a;
                    this.game.mainFont.drawText(ctx, tp.text, x, y);
                }
                ctx.globalAlpha = 1;
            }
        }

        // Draw particles
        for (let k = 0; k < MAX_PARTICLES; k++) {
            let p = this.particles[k];
            if (p.active) {
                ctx.fillStyle = p.style;
                ctx.fillRect(p.pos[0] + this.cx - camX - 0.5, p.pos[1] + this.cy - camY - 0.5, 1, 1);
            }
        }

        // Draw ghost particles
        let ghostAlpha = 0.3
        let oldAlpha = ctx.globalAlpha;
        ctx.globalAlpha *= ghostAlpha;
        for (let k = 0; k < MAX_GHOST_PARTICLES; k++) {
            let gp = this.ghostParticles[k];
            if (gp.active) {
                let x = Math.floor(gp.pos[0] + this.cx - camX - 4);
                let y = Math.floor(gp.pos[1] + this.cy - camY + this.playerState.rad[1] - 8);
                ctx.drawImage(data['char'], gp.sx, 0, 8, 8, x, y, 8, 8)
            }
        }
        ctx.globalAlpha = oldAlpha;
        // Draw the player
        if (this.playerState.alive) {
            ctx.globalAlpha = ctx.globalAlpha * 0.6;
            if (this.wonTime !== null) {
                let t = this.gameTime - (this.wonTime + START_FADE_PLAYER_TIME);
                if (t > 0) {
                    ctx.globalAlpha = ctx.globalAlpha * Math.max(0, 1 - t / FADE_DURATION);
                }
            }
            let x = Math.floor(this.playerState.pos[0] + this.cx - camX - 4);
            let y = Math.floor(this.playerState.pos[1] + this.cy - camY + this.playerState.rad[1] - 8);
            let sx = this.getCharSX();
            if (this.lastMoveDir < 0) sx += 16;
            ctx.drawImage(data['char'], sx, 0, 8, 8, x, y, 8, 8);
        }
        ctx.globalAlpha = oldAlpha;

        // If in panel mode, darken everything, to highlight the conduit
        if (this.panelMode) {
            ctx.globalAlpha = 0.4;
            PH.fillCanvas(ctx, '#222');
            ctx.globalAlpha = oldAlpha;
        }

        // Draw conduit
        for (let i = drawNeighb.minI; i < drawNeighb.maxI; i++) {
            for (let j = drawNeighb.minJ; j < drawNeighb.maxJ; j++) {
                if (this.playerState.conduitGrid[j][i]) {
                    let x = Math.floor(this.cx + i * this.ts - camX);
                    let y = Math.floor(this.cy + j * this.ts - camY);
                    ctx.drawImage(data['conduit'], x, y);
                }
            }
        }

        // If in panel mode, draw the newest piece of conduit for the section
        if (this.panelMode) {
            ctx.strokeStyle = 'rgb(90,90,255)';
            ctx.lineWidth = 1;
            let stack = this.playerState.sectionConduitStack[this.getPlayerSection()];
            let i = stack[stack.length - 1][0];
            let j = stack[stack.length - 1][1];
            let x = Math.floor(this.cx + i * this.ts - camX);
            let y = Math.floor(this.cy + j * this.ts - camY);
            ctx.strokeRect(x - 0.5, y - 0.5, this.ts + 1, this.ts + 1);
        }

        // Draw rectangles showing the conduit route
        Object.keys(this.playerState.sectionConduitStack).forEach(s => {
            let stack = this.playerState.sectionConduitStack[s];
            let conn = this.playerState.sectionConnected[s];
            let o = 0;
            if (conn) {
                o = 20 + 20 * (1 + Math.sin(this.gameTime * 9));
            }
            let r = 90 + o;
            let g = 90 + o;
            let b = 255;
            ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')';
            for (let k = 0; k < stack.length - 1; k++) {
                let firstpos = stack[k];
                let secondpos = stack[k + 1];
                if (firstpos[0] >= drawNeighb.minI && firstpos[0] < drawNeighb.maxI && firstpos[1] >= drawNeighb.minJ && firstpos[1] < drawNeighb.maxJ ||
                    secondpos[0] >= drawNeighb.minI && secondpos[0] < drawNeighb.maxI && secondpos[1] >= drawNeighb.minJ && secondpos[1] < drawNeighb.maxJ) {
                    let lowI = Math.min(firstpos[0], secondpos[0]);
                    let highI = Math.max(firstpos[0], secondpos[0]);
                    let lowJ = Math.min(firstpos[1], secondpos[1]);
                    let highJ = Math.max(firstpos[1], secondpos[1]);

                    let lowX = lowI * this.ts + this.cx - camX + 3;
                    let highX = highI * this.ts + this.cx - camX + 5;
                    let lowY = lowJ * this.ts + this.cy - camY + 3;
                    let highY = highJ * this.ts + this.cy - camY + 5;
                    ctx.fillRect(lowX, lowY, highX - lowX, highY - lowY);
                }
            }
        });


        ctx.resetTransform();

        // Draw fade-out
        if (this.wonTime !== null) {
            let t = this.gameTime - this.wonTime - START_FADE_SCREEN_TIME;
            if (t > 0) {
                ctx.globalAlpha = t / FADE_DURATION;
                PH.fillCanvas(ctx, '#111');
                ctx.globalAlpha = 1;
            }
        }
    }

    getCharSX() {
        // Get the source x coordinate of the appropriate character sprite
        let sx: number;
        if (this.pushingLeft || this.pushingRight) {
            sx = 8 * Math.floor((this.gameTime * 5) % 2);
        }
        else {
            sx = 0;
        }
        return sx
    }

    createParticle(px: number, py: number, vx: number, vy: number, style: string) {
        let p = this.particles[this.nextParticleIdx];
        p.pos = [px, py];
        p.vel = [vx, vy];
        p.style = style;
        p.active = true;
        p.age = Math.random() * 0.2;
        this.nextParticleIdx = (this.nextParticleIdx + 1) % MAX_PARTICLES;
    }

    popPlayer() {
        for (let k = 0; k < 100; k++) {
            let r = Math.random() * 4;
            let a = Math.random() * 2 * Math.PI;
            let dx = Math.cos(a) * r;
            let dy = Math.sin(a) * r;
            let px = this.playerState.pos[0] + dx;
            let py = this.playerState.pos[1] + dy;
            this.createParticle(px, py, dx * 30, dy * 30, PLAYER_PARTICLE_STYLE);
        }
        this.game.soundPlayer.playSound(this.game.data['PlayerPop'], false);
    }

    generateGhost(vel: [number, number]) {
        let gp = this.ghostParticles[this.nextGhostParticleIdx];
        gp.pos = [this.playerState.pos[0], this.playerState.pos[1]];
        gp.vel = vel;
        gp.sx = this.getCharSX();
        gp.active = true;
        gp.age = Math.random() * 0.2;
        this.nextGhostParticleIdx = (this.nextGhostParticleIdx + 1) % MAX_GHOST_PARTICLES;
    }

    createDiscoSquare() {
        let ds = this.discoSquares[this.nextDiscoSquareIdx];
        let x = Math.floor(Math.random() * this.map.w);
        let y = Math.floor(this.playerState.pos[1] / this.ts + Math.random() * 60 - 30);
        ds.pos = [x, y];
        let r = Math.floor(Math.random() * 255);
        let g = Math.floor(Math.random() * 255);
        let b = Math.floor(Math.random() * 255);
        ds.rgb = [r, g, b];
        ds.age = 0;
        ds.active = true;
        this.nextDiscoSquareIdx = (this.nextDiscoSquareIdx + 1) % MAX_DISCO_SQUARES;
    }

    tryTogglePanelMode() {
        // Player wants to use/unuse the panel
        if (this.panelMode) {
            // If player is already using the panel, go ahead and unuse it
            this.panelMode = false;
            this.panelSection = '';
            this.game.soundPlayer.playSound(this.game.data['PanelOff'], false);
        }
        else {
            // Check that we are actually near a panel
            let panelCoords = this.isPlayerOverTileType(TileType.Panel);
            if (panelCoords !== null && this.playerState.grounded) {
                this.panelMode = true;
                this.panelSection = this.map.grid[panelCoords[1]][panelCoords[0]].section;
                this.game.soundPlayer.playSound(this.game.data['PanelOn'], false);
            }
        }
    }

    getPlayerSection(): string {
        // Get the name of "the" (actually "a") section that the player is in.
        let pos = this.playerState.pos;
        let rad = this.playerState.rad;
        let ne = new TileNeighborhood(this.map, this.ts, pos[0], pos[1], rad[0], rad[1]);
        for (let i = ne.minI; i < ne.maxI; i++) {
            for (let j = ne.minJ; j < ne.maxJ; j++) {
                let gt = this.map.grid[j][i];
                if (gt.section !== '') return gt.section;
            }
        }
        return '';
    }

    checkConnection(section: string) {
        let stack = this.playerState.sectionConduitStack[this.panelSection];
        let cur = stack[stack.length - 1]
        let t = this.map.grid[cur[1]][cur[0]].t;
        if (t === TileType.Sink) {
            if (!this.playerState.sectionConnected[this.panelSection]) {
                this.game.soundPlayer.playSound(this.game.data['Connected'], false);
            }
            this.playerState.sectionConnected[this.panelSection] = true;

        }
        if (t !== TileType.Sink) {
            if (this.playerState.sectionConnected[this.panelSection]) {
                this.game.soundPlayer.playSound(this.game.data['Disconnected'], false);
            }
            this.playerState.sectionConnected[this.panelSection] = false;
        }
    }

    tryMoveConduit(xOff: number, yOff: number) {
        if (!this.playerState.alive) return;
        let stack = this.playerState.sectionConduitStack[this.panelSection];
        let cur = stack[stack.length - 1];
        let next: [number, number] = [cur[0] + xOff, cur[1] + yOff];

        if (stack.length > 1) {
            // Check whether we're trying to undo
            let prev = stack[stack.length - 2];
            if (next[0] === prev[0] && next[1] === prev[1]) {
                this.playerState.conduitGrid[cur[1]][cur[0]] = false;
                stack.splice(stack.length - 1, 1);
                this.checkConnection(this.panelSection);
                this.game.soundPlayer.playSound(this.game.data['MoveConduit'], false);
                return;
            }
        }

        // Try to extend
        let failed = false;
        if (next[0] < 0 || next[0] >= this.map.w || next[1] < 0 || next[1] >= this.map.h) {
            failed = true;
        }
        else {
            let typeCur = this.map.grid[cur[1]][cur[0]].t;
            if (typeCur !== TileType.Sink && !this.tileBlocksConduit(next[0], next[1])) {
                stack.push(next);
                this.playerState.conduitGrid[next[1]][next[0]] = true;
                this.checkConnection(this.panelSection);
                this.game.soundPlayer.playSound(this.game.data['MoveConduit'], false);
            }
            else {
                failed = true;
            }
        }
        if (failed) {
            this.game.soundPlayer.playSound(this.game.data['InvalidMove'], false);
        }
    }

    handleKeyDown(e: KeyboardEvent): boolean {
        if (this.wonTime !== null) return true;
        if (e.key.toLowerCase() == 'z') {
            this.tryTogglePanelMode();
            return false;
        }
        else if (e.key.toLowerCase() == 'r') {
            this.loadState();
            return false;
        }

        if (this.panelMode) {
            if (e.key == 'ArrowLeft') {
                this.tryMoveConduit(-1, 0);
                return false;
            }
            else if (e.key == 'ArrowRight') {
                this.tryMoveConduit(1, 0);
                return false;
            }
            else if (e.key == 'ArrowUp') {
                this.tryMoveConduit(0, -1);
                return false;
            }
            else if (e.key == 'ArrowDown') {
                this.tryMoveConduit(0, 1);
                return false;
            }
        }
        else {
            if (e.key == 'ArrowLeft') {
                this.pushingLeft = true;
                this.lastMoveDir = -1;
                return false;
            }
            else if (e.key == 'ArrowRight') {
                this.pushingRight = true;
                this.lastMoveDir = 1;
                return false;
            }
            else if (e.key == 'ArrowUp') {
                if (!this.previouslyPushingJump) {
                    this.pushingJump = true;
                    this.previouslyPushingJump = true;
                    this.pushJumpTime = this.gameTime;
                }
                return false;
            }
        }

        return true;
    }

    handleKeyUp(e: KeyboardEvent): boolean {
        if (this.wonTime !== null) return true;
        if (this.panelMode) {

        }
        else {
            if (e.key == 'ArrowLeft') {
                this.pushingLeft = false;
                return false;
            }
            else if (e.key == 'ArrowRight') {
                this.pushingRight = false;
                return false;
            }
            else if (e.key == 'ArrowUp') {
                this.pushingJump = false;
                this.previouslyPushingJump = false;
                return false;
            }
        }
        return true;
    }

}
