Source: game.js

// @ts-check

const PLAYER_WIDTH = 40;
const PLAYER_HEIGHT = 40;

const PLAYER_BORDER_WIDTH = 5;

const PLAYER_ASPEED_X = 1;
const PLAYER_STOP_ASPEED_X = 2;
const PLAYER_STOP_ASPEED_Y = 1.3;

const PLAYER_SPEED_CAP_X = 10;

const JUMP_SPEED = 20;

const BOUNCE_YSPEED = 25;
const BOUNCE_XSPEED = 10;
const BOUNCE_CAP = 3;

/**
 * @enum { number }
 * @readonly
 */
export const SurfaceType = {
    Floor: 0,
    Ceiling: 1,
    Wall: 2,
    Win: 3
}



/**
 * 
 * @param { number } a 
 * @param { number } b
 * @returns { number } 
 */
function mod(a, b) {
    return ((a % b) + b) % b;
}

export class Pool {
    /**
     * @type { any[] }
     */
    values = [];
    /**
     * 
     * @param { string } url
     * @returns { Promise<Pool> } 
     */
    static async load(url) {
        const raw = await (await fetch(url, {
            "mode": "no-cors"
        })).json();
        const result = new Pool();
        for (const value of raw.values) {
            result.values.push(await (await fetch(value, {
                "mode": "no-cors"
            })).json());
            result.values[result.values.length - 1].name = value;
        }
        return result;
    }

    /**
     * @returns { GameMap[] }
     */
    create() {
        /**
         * @type { any[] }
         */
        let repeatPool = [];
        /**
         * @type { GameMap[] }
         */
        let result = [];
        result.push(GameMap.loadFromJSON(this.values[0]));
        for (let i = 0; i < 5; i++) {
            if (repeatPool.length <= 0) repeatPool = this.values.slice();
            const index = Math.floor(Math.random() * repeatPool.length);
            const obj = repeatPool.splice(index, 1)[0]
            result.push(GameMap.loadFromJSON(obj));
            result[result.length - 1].name = obj.name;
        }
        // @ts-ignore
        result[0].surfaces.push(Surface.fromArray([result[0].leftX, result[0].bottomY, result[0].topY - result[0].bottomY, 0, false]));
        return result;
    }
}

export class Surface {
    /**
     * @type { number }
     */
    startX;
    /**
     * @type { number }
     */
    startY;
    /**
     * @type { number }
     */
    length;
    /**
     * @type { number } radian.
     */
    facing;
    /**
     * @type { boolean }
     */
    virtual;

    /**
     * recalculate box.
     */
    calculateBox() {
        this.endX = this.startX + Math.cos(this.facing - Math.PI / 2) * this.length;
        this.endY = this.startY + Math.sin(this.facing - Math.PI / 2) * this.length;
        if (this.endX > this.startX) {
            this.leftX = this.startX;
            this.rightX = this.endX;
        } else {
            this.leftX = this.endX;
            this.rightX = this.startX;
        }
        if (this.endY > this.startY) {
            this.bottomY = this.startY;
            this.topY = this.endY;
        } else {
            this.bottomY = this.endY;
            this.topY = this.startY;
        }
    }
    calculateType() {
        const facing = mod(this.facing, (Math.PI * 2));
        if (facing > 0 && facing < Math.PI) return SurfaceType.Floor;
        if (facing > Math.PI && facing < 2 * Math.PI) return SurfaceType.Ceiling;
        console.log(this, facing);
        return SurfaceType.Wall;
    }

    calculate() {
        this.type = this.calculateType();
        this.calculateBox();
    }

    /**
     * @type { SurfaceType }
     */
    type;
    /**
     * @type { number }
     */
    endX;
    /**
     * @type { number }
     */
    endY;
    /**
     * @type { number }
     */
    leftX;
    /**
     * @type { number }
     */
    bottomY;
    /**
     * @type { number }
     */
    rightX;
    /**
     * @type { number }
     */
    topY;

    /**
     * @param { [number, number, number, number, boolean] } array 
     * @returns { Surface }
     */
    static fromArray(array) {
        const [startX, startY, length, facing, virtual] = array;
        const result = new Surface();
        result.startX = startX;
        result.startY = startY;
        result.length = length;
        result.facing = facing;
        result.virtual = virtual;
        result.calculate();
        return result;
    }
}

export class GameObject {
    /**
     * @abstract
     * @type { number }
     */
    width;
    /**
     * @abstract
     * @type { number }
     */
    height;
    /**
     * @type { number }
     */
    x = 0;
    /**
     * @type { number }
     */
    y = 0;
    /**
     * @abstract
     * @param { Game } game 
     */
    tick(game) {

    }

    /**
     * @abstract
     * @param { Game } game 
     */
    collide(game) {

    }

    /**
     * @type { Map<string, typeof GameObject> }
     */
    static objectRegistry = new Map();
    /**
     * @param { string } name
     * @param { typeof GameObject } object 
     */
    static register(name, object) {
        this.objectRegistry.set(name, object);
    }

    /**
     * 
     * @param { string } name
     * @returns { typeof GameObject } 
     */
    static get(name) {
        // @ts-ignore
        return this.objectRegistry.get(name);
    }
}

export class GameMap {
    /**
     * @type { string }
     */
    name;
    /**
     * @type { Surface[] }
     */
    surfaces;
    /**
     * @type { string[] }
     */
    features;
    /**
     * @type { number }
     */
    spawnX;
    /**
     * @type { number }
     */
    spawnY;
    /**
     * @type { number }
     */
    endpointX;
    /**
     * @type { number }
     */
    endpointY;
    /**
     * @type { GameObject }
     */
    objects;

    /**
     * @param { string } url 
     * @returns { Promise<GameMap> }
     */
    static async load(url) {
        const obj = await (await fetch(url, {
            "mode": "no-cors"
        })).json();
        const result = this.loadFromJSON(obj);
        result.name = url;
        return result;
    }

    /**
     * @param { any } obj 
     * @returns { GameMap }
     */
    static loadFromJSON(obj) {
        const result = new GameMap();
        result.surfaces = obj.surfaces.map(Surface.fromArray);
        result.features = obj.features ?? [];
        result.calculateBox();
        return result;
    }

    /**
     * @type { number }
     */
    leftX = 0;
    /**
     * @type { number }
     */
    rightX = 0;
    /**
     * @type { number }
     */
    bottomY = 0;
    /**
     * @type { number }
     */
    topY = 0;
    calculateBox() {
        for (const surface of this.surfaces) {
            if (surface.virtual) continue;
            this.leftX = Math.min(this.leftX, surface.leftX);
            this.rightX = Math.max(this.rightX, surface.rightX);
            this.bottomY = Math.min(this.bottomY, surface.bottomY);
            this.topY = Math.max(this.topY, surface.topY);
        }
    }
}

export class Player {
    /**
     * @type { number }
     */
    x = 0;
    /**
     * @type { number }
     */
    y = 0;
    /**
     * @type { number }
     */
    speedX = 0;
    /**
     * @type { number }
     */
    speedY = 0;
    /**
     * @type { boolean }
     */
    onGround = false;
    /**
     * @type { number }
     */
    extraJump = 0;
    /**
     * @type { number }
     */
    bounceTime = 0;
    /**
     * @type { number }
     */
    maxExtraJump = 1;
}

export class Game {
    /**
     * @type { boolean }
     */
    active = true;
    /**
     * @type { HTMLCanvasElement }
     */
    canvas;
    /**
     * @type { CanvasRenderingContext2D }
     */
    context;
    /**
     * @type { number }
     */
    cameraX;
    /**
     * @type { number }
     */
    cameraY;
    /**
     * @type { GameMap[] }
     */
    currentMapSequence;
    /**
     * @type { number }
     */
    currentMapIndex;
    /**
     * @returns { GameMap }
     */
    get map() {
        return this.currentMapSequence[this.currentMapIndex];
    }
    /**
     * @type { boolean }
     */
    running = false;
    /**
     * @type { Player }
     */
    player;
    /**
     * @type { KeyEvents }
     */
    keyEvents;
    /**
     * @type { boolean }
     */
    DEBUG = true;

    /**
     * 
     * @param { HTMLCanvasElement } canvas 
     * @param { Player } player 
     */
    constructor(canvas, player) {
        this.canvas = canvas;
        // @ts-ignore
        this.context = canvas.getContext("2d");
        this.player = player;
        this.keyEvents = listenKeyboard();
    }

    /**
     * @returns { {floors: Surface[]; ceilings: Surface[]; walls: Surface[];} }
     */
    getRelevantSurfaces() {
        /**
         * @type { {floors: Surface[]; ceilings: Surface[]; walls: Surface[];} }
         */
        const result = {
            floors: [],
            ceilings: [],
            walls: []
        };
        for (const surface of this.map.surfaces.filter(surface => !surface.virtual)/*.filter(surface => {
            if (surface.type in [SurfaceType.Ceiling, SurfaceType.Floor]) {
                return surface.leftX < this.player.x && surface.rightX > this.player.x;
            }
            const realY = (this.player.y - this.player.speedY);
            return surface.bottomY < realY && realY < surface.topY;
        })*/) {
            if (surface.type === SurfaceType.Floor) {
                result.floors.push(surface);
            } else if (surface.type === SurfaceType.Ceiling) {
                result.ceilings.push(surface);
            } else {
                result.walls.push(surface);
            }
        }
        return result;
    }

    /**
     * if Date.now() is after, you can't wall jump.
     * @type { number }
     */
    wallJumpCountdown = 0;
    /**
     * @type { Surface | undefined }
     */
    surfaceCollided;

    movement() {
        const { floors, ceilings, walls } = this.getRelevantSurfaces();
        let nextX = this.player.x + this.player.speedX;
        let nextY = this.player.y + this.player.speedY;

        if (nextX <= this.map.leftX) nextX = this.map.leftX + 1;

        for (const surface of walls) {
            if (this.player.x > surface.rightX && nextX > surface.rightX) continue;
            if (this.player.x < surface.leftX && nextX < surface.leftX) continue;
            if (this.player.y > surface.topY && nextY + PLAYER_HEIGHT > surface.topY) continue;
            if (this.player.y < surface.bottomY && nextY + PLAYER_HEIGHT < surface.bottomY) continue;

            if (mod(surface.facing, (Math.PI * 2)) === 0 && this.player.x > surface.leftX && nextX <= surface.leftX) { // 不许往左
                nextX = surface.leftX + 1;
                this.wallJumpCountdown = Date.now() + 100;
                this.surfaceCollided = surface;
            }
            if (mod(surface.facing, (Math.PI * 2)) !== 0 && this.player.x < surface.leftX && nextX >= surface.leftX) { // 不许往右
                nextX = surface.leftX - 1;
                this.wallJumpCountdown = Date.now() + 100;
                this.surfaceCollided = surface;
            }
        }
        for (const surface of floors) {
            if (this.player.x > surface.rightX + PLAYER_WIDTH / 2 && nextX > surface.rightX  + PLAYER_WIDTH / 2) continue;
            if (this.player.x < surface.leftX - PLAYER_WIDTH / 2 && nextX < surface.leftX - PLAYER_WIDTH / 2) continue;
            if (this.player.y > surface.topY && nextY > surface.topY) continue;
            if (this.player.y < surface.bottomY && nextY < surface.bottomY) continue;

            const curTop = surface.startY + Math.tan(surface.facing - Math.PI / 2) * (this.player.x - surface.startX)
            const nextTop = surface.startY + Math.tan(surface.facing - Math.PI / 2) * (nextX - surface.startX);

            if (this.player.y > curTop && nextY <= nextTop) {
                nextY = nextTop + 1;
                this.player.onGround = true;
                this.player.extraJump = this.player.maxExtraJump;
                this.player.bounceTime = BOUNCE_CAP;
            }
        }
        for (const surface of ceilings) {
            if (this.player.x > surface.rightX + PLAYER_WIDTH / 2 && nextX > surface.rightX + PLAYER_WIDTH / 2) continue;
            if (this.player.x < surface.leftX - PLAYER_WIDTH / 2 && nextX < surface.leftX - PLAYER_WIDTH / 2) continue;
            if (this.player.y + PLAYER_HEIGHT > surface.topY && nextY + PLAYER_HEIGHT > surface.topY) continue;
            if (this.player.y + PLAYER_HEIGHT < surface.bottomY && nextY + PLAYER_HEIGHT < surface.bottomY) continue;

            const curBottom = surface.startY + Math.tan(surface.facing - Math.PI / 2) * (this.player.x - surface.startX)
            const nextBottom = surface.startY + Math.tan(surface.facing - Math.PI / 2) * (nextX - surface.startX);
            if (this.player.y + PLAYER_HEIGHT < curBottom && nextY + PLAYER_HEIGHT >= nextBottom) {
                nextY = nextBottom - 1 - PLAYER_HEIGHT;
            }
            else if (nextY + PLAYER_HEIGHT > curBottom && nextY <= nextBottom) {
                if (this.player.x >= surface.rightX) {
                    nextX = surface.rightX + 1;
                }
                else {
                    nextX = surface.leftX - 1;
                }
            }
        }
        this.player.x = nextX;
        this.player.y = nextY;
    }

    logic() {
        if (!this.active) return;

        if (this.currentMapIndex >= this.currentMapSequence.length) {
            alert("恭喜通关!");
            this.active = false;
            return;
        }

        if (this.player.y < this.map.bottomY - 40) this.spawn();
        if (this.player.x > this.map.rightX) {
            this.currentMapIndex++;
            this.next();
            return;
        }
        this.player.onGround = false;
        if (this.keyEvents.walkingLeft && !this.keyEvents.walkingRight) {
            if (this.player.speedX > -PLAYER_SPEED_CAP_X) {
                this.player.speedX = Math.max(-PLAYER_SPEED_CAP_X, this.player.speedX - PLAYER_ASPEED_X);
            }
        }
        else if (this.keyEvents.walkingRight && !this.keyEvents.walkingLeft) {
            if (this.player.speedX < PLAYER_SPEED_CAP_X) {
                this.player.speedX = Math.min(PLAYER_SPEED_CAP_X, this.player.speedX + PLAYER_ASPEED_X);
            }
        }
        else {
            if (this.player.speedX > 0) {
                this.player.speedX = Math.max(0, this.player.speedX - PLAYER_STOP_ASPEED_X);
            } else if (this.player.speedX < 0) {
                this.player.speedX = Math.min(0, this.player.speedX + PLAYER_STOP_ASPEED_X);
            }
        }
        this.movement();
        this.player.speedY = Math.max(-10, this.player.speedY - PLAYER_STOP_ASPEED_Y);
        if (this.keyEvents.jumping) {
            this.keyEvents.jumping = false;
            const canWallJump = this.wallJumpCountdown >= Date.now();
            if (this.player.onGround || this.player.extraJump > 0 || this.player.extraJump === -1 || canWallJump) {
                if (canWallJump && this.player.bounceTime > 0) {
                    this.player.extraJump = this.player.maxExtraJump;
                }


                if (this.surfaceCollided && this.player.bounceTime > 0) {
                    console.log(this.surfaceCollided.facing);
                    const multiplexer = 1 + Math.log(this.player.speedX ** 2 + this.player.speedY ** 2) / Math.log(BOUNCE_YSPEED ** 2 + PLAYER_SPEED_CAP_X ** 2) / 2;

                    this.player.speedX = Math.cos(this.surfaceCollided.facing) * BOUNCE_XSPEED * multiplexer;
                    this.player.speedY = BOUNCE_YSPEED;
                    this.player.bounceTime -= 1;
                }

                else if (this.player.onGround || this.player.extraJump > 0) {
                    this.player.speedY = JUMP_SPEED;
                    if (!this.player.onGround) this.player.extraJump -= 1;
                }

                this.surfaceCollided = undefined;
            }
        }
    }

    render() {
        if (!this.active) return;
        // rerender
        this.context.fillStyle = "white";
        this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
        [this.cameraX, this.cameraY] = [this.player.x, this.player.y];
        if (this.map.features.indexOf("fucking_camera") !== -1) {
            this.cameraY += 180;
        }
        if (this.map.rightX - this.map.leftX > this.canvas.width) {
            if (this.cameraX - this.canvas.width / 2 < this.map.leftX) {
                this.cameraX = this.map.leftX + this.canvas.width / 2;
            }

            if (this.cameraX + this.canvas.width / 2 > this.map.rightX) {
                this.cameraX = this.map.rightX - this.canvas.width / 2;
            }
        } else {
            this.cameraX = this.canvas.width / 2;
        }
        if (this.map.features.indexOf("no_y_border_align") === -1) {
            if (this.map.topY - this.map.bottomY > this.canvas.height) {
                if (this.cameraY - this.canvas.height / 2 < this.map.bottomY) {
                    this.cameraY = this.map.bottomY + this.canvas.height / 2;
                }

            } else {
                this.cameraY = this.canvas.height / 2;
            }
        }

        // render surfaces
        this.context.strokeStyle = "black";
        for (const surface of this.map.surfaces) {
            this.context.beginPath();
            this.context.moveTo(surface.startX - this.cameraX + this.canvas.width / 2, this.canvas.height - (surface.startY - this.cameraY) - this.canvas.height / 2);
            this.context.lineTo(surface.endX - this.cameraX + this.canvas.width / 2, this.canvas.height - (surface.endY - this.cameraY) - this.canvas.height / 2);
            this.context.stroke();
        }

                // render player
                const centerX = this.player.x - this.cameraX + this.canvas.width / 2;
                const centerY = (this.canvas.height - this.player.y) + this.cameraY - this.canvas.height / 2;
        
                // border
                this.context.fillStyle = "blue";
                this.context.fillRect(centerX - PLAYER_WIDTH / 2 - PLAYER_BORDER_WIDTH, centerY + PLAYER_BORDER_WIDTH, PLAYER_WIDTH + 2 * PLAYER_BORDER_WIDTH, -PLAYER_HEIGHT - 2 * PLAYER_BORDER_WIDTH)
        
                // base
                this.context.fillStyle = "black";
                this.context.fillRect(centerX - PLAYER_WIDTH / 2, centerY, PLAYER_WIDTH, -PLAYER_HEIGHT);
        
                // can jump
                this.context.fillStyle = this.player.onGround || this.player.extraJump > 0 ? "green" : "red";
                this.context.fillRect(centerX - PLAYER_WIDTH / 2, centerY - PLAYER_HEIGHT, PLAYER_WIDTH / 2, PLAYER_HEIGHT / 2);
        
                // wall tireness
                this.context.fillStyle = this.player.bounceTime > 0 ? "yellow" : "pink";
                this.context.fillRect(centerX, centerY - PLAYER_HEIGHT, PLAYER_WIDTH / 2, PLAYER_HEIGHT / 2);
        
                // render player ends

        // debug
        if (this.DEBUG) {
            this.renderDebug();
        }
    }

    /**
     * @type { number }
     */
    mouseX = 0;
    /**
     * @type { number }
     */
    mouseY = 0;
    /**
     * @type { boolean }
     */
    getMousePositionStarted = false;
    renderDebug() {
        if (!this.getMousePositionStarted) {
            document.addEventListener("mousemove", (ev) => {
                const { left, top } = this.canvas.getBoundingClientRect();
                this.mouseX = ev.clientX - left;
                this.mouseY = ev.clientY - top;
            });
            this.getMousePositionStarted = true;
        }

        this.context.fillStyle = "black";
        this.context.font = "20px Arial";
        this.context.textAlign = "center";
        this.context.textBaseline = "top";
        this.context.fillText(`mouse: ${(this.mouseX + this.cameraX - this.canvas.width / 2).toFixed(1)} ${(this.canvas.height - this.mouseY + this.cameraY - this.canvas.height / 2).toFixed(1)}`, this.canvas.width / 2, 0);
        this.context.fillText(`player: ${this.player.x.toFixed(1)} ${this.player.y.toFixed(1)}`, this.canvas.width / 2, 25);
        this.context.fillText(`camera: ${this.cameraX.toFixed(1)} ${this.cameraY.toFixed(1)}`, this.canvas.width / 2, 50);
        this.context.fillText(`speed: ${this.player.speedX.toFixed(1)} ${this.player.speedY.toFixed(1)}`, this.canvas.width / 2, 75);
        this.context.fillText(`level: ${this.currentMapIndex}`, this.canvas.width / 2, 100);
    }

    next() {
        this.spawn();
    }

    back() {
        this.player.x = this.map.rightX;
    }

    spawn() {
        console.log(this.map.name);
        this.player.x = this.map.leftX + 20;
        if (this.map.features.indexOf("spawn_offset_x+") !== -1) this.player.x += 100;
        this.player.y = 301;
    }
}

/**
 * @typedef { { walkingLeft: boolean; walkingRight: boolean; jumping: boolean; } } KeyEvents
 */

/**
 * 
 * @returns { KeyEvents }
 */
function listenKeyboard() {
    /**
     * @type { KeyEvents }
     */
    const keyEvents = {
        walkingLeft: false,
        walkingRight: false,
        jumping: false
    };
    document.addEventListener("keydown", (ev) => {
        if (ev.key === "a") keyEvents.walkingLeft = true;
        if (ev.key === "d") keyEvents.walkingRight = true;
        if (ev.key === "w") keyEvents.jumping = true;
    });
    document.addEventListener("keyup", (ev) => {
        if (ev.key === "a") keyEvents.walkingLeft = false;
        if (ev.key === "d") keyEvents.walkingRight = false;
        if (ev.key === "w") keyEvents.jumping = false;
    });
    return keyEvents;
}