MineJSv0.5.1
physics/player.tstypescript
import { EntitiesManager } from "../world/entity";
import { Angle, Axis, BaseAxis, BaseVec3, Vec3 } from "./direction";
import { AABB, Shapes, VoxelShape } from "./aabb";
import { BlockGetter, BlockManager, BlockState } from "../world/block";
import { clamp, Epsilon, equal, lerp } from "../base/math";
import { ClientNotReady, NotImplemented } from "../base/error";
import { LevelHeightLimit } from "../client/static";
import { SharedState } from "../world/state";
import { TypedEmmiter } from "../base/event";
import { ClientEvents } from "../client/client";
import { EffectRegistry } from "../version/registry";

export enum Input {
    Forward = "W",
    Backward = "S",
    Left = "A",
    Right = "D",
    Crouching = "shift",
    Jump = "space",
    Sprint = "cttl"
}

const VectorMovement: Record<Input, Vec3> = {
    [Input.Forward]: new Vec3(0, 0, 1),
    [Input.Backward]: new Vec3(0, 0, -1),
    [Input.Left]: new Vec3(1, 0, 0),
    [Input.Right]: new Vec3(-1, 0, 0),
    [Input.Crouching]: Vec3.Zero,
    [Input.Jump]: Vec3.Zero,
    [Input.Sprint]: Vec3.Zero,
};

/**
 * Not used for now
 */
export enum MoverType {
    SELF,
    PLAYER,
    PISTON,
    SHULKER_BOX,
    SHULKER
}

/**
 * Represent a player for calculating physics
 */
export class Player {
    private fallDistance: number = 0;
    private deltaMovement: Vec3 = Vec3.Zero;
    private supportBlockPos: BaseVec3 | null = null;

    // Moving state
    // TODO: need is sprinting
    private heldInputs: Set<Input> = new Set();
    private lastPos: BaseVec3 | null = null;
    private lastAngle: Angle | null = null;
    private positionReminder: number = 0;
    private noJumpDelay: number = 0;

    // Collision
    private horizontalCollision: boolean = false;
    private verticalCollision: boolean = false; // For debuging now
    private onGround: boolean = true;
    private lastHorizontalCollision: boolean = false;
    private lastOnGround: boolean = true;

    constructor(
        private state: SharedState,
        private on: TypedEmmiter<ClientEvents>["on"],
        private entities: EntitiesManager,
        private blocks: BlockManager,
    ) {
        this.on("playerPosition", () => {
            this.onGround = true;
            this.horizontalCollision = false;
            this.fallDistance = 0;
            this.deltaMovement = new Vec3(this.state.player!.velocity);
            this.setInitialVal();
        });
    }


    public sendPlayerPos: (
        position: BaseVec3,
        onGround: boolean,
        horizontalCollision: boolean
    ) => void = () => { throw new NotImplemented(); };

    public sendPlayerPosRot: (
        position: BaseVec3,
        angle: Angle,
        onGround: boolean,
        horizontalCollision: boolean
    ) => void = () => { throw new NotImplemented(); };

    public sendPlayerRot: (
        angle: Angle,
        onGround: boolean,
        horizontalCollision: boolean
    ) => void = () => { throw new NotImplemented(); };

    public sendPlayerStatus: (
        onGround: boolean,
        horizontalCollision: boolean
    ) => void = () => { throw new NotImplemented(); };

    /**
     * Set initial value
     */
    public setInitialVal() {
        this.lastPos = this.getPos();
        this.lastAngle = this.getAngle();
    }
    /**
     * Prune all value
     */
    public pruneInitialVal() {
        this.fallDistance = 0;
        this.deltaMovement = Vec3.Zero;
        this.supportBlockPos = null;
        this.heldInputs = new Set();
        this.lastPos = null;
        this.lastAngle = null;
        this.positionReminder = 0;
        this.horizontalCollision = false;
        this.verticalCollision = false;
        this.onGround = true;
        this.lastHorizontalCollision = false;
        this.lastOnGround = true;
    }

    // Inputting, to control current plat
    /**
     * Press and hold a key
     */
    public input(input: Input) { this.heldInputs.add(input); }
    /**
     * Release a key
     */
    public releaseInput(input: Input) { this.heldInputs.delete(input); }
    /**
     * Release all key
     */
    public releaseAllInputs() { this.heldInputs.clear(); }

    private get isCrouncing() { return this.heldInputs.has(Input.Crouching); };
    private get isJumping() { return this.heldInputs.has(Input.Jump); };
    private get isSprinting() { return this.heldInputs.has(Input.Sprint); }

    private getInputVector(): Vec3 {
        let vec = Vec3.Zero;
        for (const held of this.heldInputs)
            vec = vec.add(VectorMovement[held]);
        return vec;
    }

    /* Getting helper, from TCP client */
    private getPos() {
        this.state.checkReady();
        return new Vec3(this.state.player!.position);
    }
    private setPos(pos: Vec3) {
        this.state.checkReady();
        this.state.enqueueMutation((state) => state.player!.position = pos.copyBase());
    }
    private getAngle() {
        this.state.checkReady();
        return this.state.player!.angle;
    }
    public setAngle(yaw: number, pitch: number) {
        this.state.checkReady();
        this.state.enqueueMutation((state) => state.player!.angle = {
            yaw: yaw % 360,
            pitch: pitch % 360
        });
    }
    private getDimension() {
        this.state.checkReady();
        return this.state.player!.dimension;
    }
    private hasEffect(effectName: string) {
        this.state.checkReady();
        const id = EffectRegistry.effects[effectName];
        if (!id) return false;
        if (!(id in this.state.player!.effects)) return false;
        const effect = this.state.player!.effects[id]!;
        if (effect.duration < 0) return true;
        if (effect.date + (effect.duration / 20) * 1000 < Date.now()) return true;
        return false;
    }

    private setDeltaMovement(x: number, y: number, z: number): void;
    private setDeltaMovement(vec: BaseVec3): void;
    private setDeltaMovement(a: BaseVec3 | number, b?: number, c?: number): void {
        const vec3 = new Vec3(Vec3.loadArgs(a, b, c));
        if (vec3.isFinite())
            this.deltaMovement = vec3;
    }

    private getOnPos(offset: number): BaseVec3 {
        if (this.supportBlockPos) {
            if (offset > 1e-5) {
                const { x, z } = this.supportBlockPos;
                const belowState = this.blocks.at(this.supportBlockPos)!;
                // TODO: Add missing FenceGateBlock case
                if (offset < 0.5 && (belowState.owner.definitions['type'] === "minecraft:fence" || belowState.owner.definitions['type'] === "minecraft:wall")) {
                    return this.supportBlockPos;
                } else return {
                    x, y: this.getPos().y - offset, z
                };
            } else return this.supportBlockPos;
        }
        const { x, y, z } = this.getPos();
        return {
            x, y: y - offset, z
        };
    }

    private getLevelMinY() {
        const dimension = this.getDimension();
        return LevelHeightLimit[dimension]![0];
    }

    private getInBlockState() {
        const { x, y, z } = this.getPos();
        const blockPos = {
            x: Math.floor(x),
            y: Math.floor(y),
            z: Math.floor(z),
        };
        return this.blocks.at(blockPos)!;
    }

    private onClimbable() {
        const state = this.getInBlockState();
        if (state.owner.getClimbable())
            return true;
        if (state.owner.getType() === "minecraft:trapdoor") {
            const { x, y, z } = this.getPos();
            const posBelow = {
                x: Math.floor(x),
                y: Math.floor(y) - 1,
                z: Math.floor(z),
            };
            const stateBelow = this.blocks.at(posBelow)!;
            if (
                stateBelow.owner.getType() === "minecraft:ladder" && // if below is ladder
                (state.getFacing() && stateBelow.getFacing()) && // if both have facing props
                state.getFacing() === stateBelow.getFacing() // if both are the same
            ) return true;
        }
        return false;
    }

    /* Logics */

    /**
     * Ticking
     */
    public tick() {
        if (!this.state.isReady()) return;
        if (!this.lastPos || !this.lastAngle) throw new ClientNotReady();

        // console.log("tick");
        // console.log(this.getPos());

        this.aiStep();
        const { x, y, z } = this.getPos(),
            { x: xLast, y: yLast, z: zLast } = this.lastPos,
            { yaw, yaw: xRot, pitch, pitch: yRot } = this.getAngle(),
            { yaw: xRotLast, pitch: yRotLast } = this.lastAngle;
        const deltaX = x - xLast,
            deltaY = y - yLast,
            deltaZ = z - zLast,
            deltaYRot = yRot - yRotLast,
            deltaXRot = xRot - xRotLast;
        this.positionReminder++;
        const move = new Vec3(deltaX, deltaY, deltaZ).lengthSqr() > (2e-4 * 2e-4) || this.positionReminder > 20,
            rot = deltaXRot !== 0 || deltaYRot !== 0;
        if (move && rot)
            this.sendPlayerPosRot(this.getPos(), this.getAngle(), this.onGround, this.horizontalCollision);
        else if (move)
            this.sendPlayerPos(this.getPos(), this.onGround, this.horizontalCollision);
        else if (rot)
            this.sendPlayerRot(this.getAngle(), this.onGround, this.horizontalCollision);
        else if (
            this.onGround !== this.lastOnGround ||
            this.horizontalCollision !== this.lastHorizontalCollision
        )
            this.sendPlayerStatus(this.onGround, this.horizontalCollision);

        if (move) {
            this.lastPos = this.getPos();
            this.positionReminder = 0;
        }
        if (rot)
            this.lastAngle = this.getAngle();

        this.lastOnGround = this.onGround;
        this.lastHorizontalCollision = this.horizontalCollision;
    }

    private aiStep() {
        if (this.noJumpDelay > 0) {
            this.noJumpDelay--;
        }

        // eslint-disable-next-line prefer-const
        let { x, y, z } = this.deltaMovement;
        if (this.deltaMovement.horizontalDistanceSqr() < 9e-6) {
            x = 0;
            z = 0;
        }
        this.setDeltaMovement(x, y, z);

        if (this.isJumping && this.noJumpDelay === 0) {
            const jumpPower = this.getJumpFactor();

            if (jumpPower > 1e-5) {
                const { x: dx, y: dy, z: dz } = this.deltaMovement;
                this.setDeltaMovement(dx, Math.max(dy, jumpPower), dz);
                if (this.isSprinting) {
                    const yawInRad = this.getAngle().yaw * (Math.PI / 180.0);
                    this.setDeltaMovement(
                        this.deltaMovement.add(
                            new Vec3(-Math.sin(yawInRad) * 0.2, 0.0, Math.cos(yawInRad) * 0.2)
                        )
                    );
                }
            }

            this.noJumpDelay = 15; // Original value to 10
        }
        // TODO: Handle in fluid

        // Handle potion effect: Slow falling and Levitation
        if (
            this.hasEffect("minecraft:levitation") ||
            this.hasEffect("minecraft:slow_falling")
        )
            this.fallDistance = 0;

        // // TODO: Push entities
        // console.log({
        //     type: "BEFORE-TRAVEL",
        //     pos: this.getPos(),
        //     vel: this.deltaMovement,
        //     onGround: this.onGround,
        //     horizontalCollision: this.horizontalCollision,
        //     verticalCollision: this.verticalCollision
        // });
        this.travel(this.getInputVector());
    }

    private getJumpFactor() {
        const { x, y, z } = this.getPos();
        const inBlock = this.blocks.at(x, y, z)!;
        const belowBlock = this.blocks.at(x, y - 0.5, z)!;
        // 0.42F is the default jump strength, which will apply for player here
        return (inBlock.owner.getJumpFactor() === 1 ? belowBlock.owner.getJumpFactor() : inBlock.owner.getJumpFactor()) * 0.42;
    }

    /*
     * Invoking flow
     * 
     *         |-> travelInAir ---|
     * travel -|-> travelInFluid -+--> move -> colision
     *         |-> flying? -------|
     */

    /* 
     * Travel related methods
     */
    private travel(input: Vec3) {
        // TODO: Handle other case: flying, water, lava
        return this.travelInAir(input);
    }

    private travelInAir(input: Vec3) {
        const posBelow = this.getOnPos(0.50001);
        if (!this.blocks.hasChunkAt(posBelow))
            return; // console.log(`no chunk at ${Math.floor(posBelow.x / 16)} ${Math.floor(posBelow.y / 16)} ${Math.floor(posBelow.z / 16)}`);
        // console.log('posBelow', posBelow, 'block', this.blocks.at(posBelow)?.owner.getType());
        const blockFriction = this.onGround ? this.blocks.at(posBelow)!.owner.getFriction() : 1;
        const movement = this.handleFrictionAndCalculateMovement(input, blockFriction);
        let movementY = movement.y;
        // TODO: handle levitation effect
        if (this.blocks.hasChunkAt(posBelow)) {
            movementY -= 0.08;
        } else if (this.getPos().y > this.getLevelMinY()) {
            movementY = -0.1;
        } else {
            movementY = 0;
        }
        const airDrag = 0.91; // the orignal code invoke some function 
        // TODO: fully implement missing method
        const friction = blockFriction * airDrag;
        this.setDeltaMovement(new Vec3(movement.x * friction, movementY * 0.98, movement.z * friction));
    }

    private handleFrictionAndCalculateMovement(input: Vec3, friction: number) {
        this.moveRelative(input, this.getFrictionInfluencedSpeed(friction));
        this.setDeltaMovement(this.handleOnClimbable(this.deltaMovement));
        this.move(MoverType.SELF, this.deltaMovement);
        let movement = this.deltaMovement;
        if (
            (this.horizontalCollision || this.isJumping) && this.onClimbable()
            // TODO: Handle case stuck in power snow
        )
            movement = movement.with(BaseAxis.Y, 0.2);
        return movement;
    }

    private getFrictionInfluencedSpeed(blockFriction: number): number {
        if (!this.onGround) return 0.02;
        if (blockFriction > 0.6)
            return this.getSpeed() * (0.21600002 / (blockFriction * blockFriction * blockFriction));
        return this.getSpeed();
    }

    private handleOnClimbable(delta: Vec3) {
        if (this.onClimbable()) {
            this.fallDistance = 0;
            const xd = clamp(delta.x, -0.15, 0.15);
            const zd = clamp(delta.z, -0.15, 0.15);
            let yd = Math.max(delta.y, -0.15);
            if (yd < 0.0 && this.getInBlockState().id !== "minecraft:scaffolding" && this.isCrouncing)
                yd = 0.0;
            delta = new Vec3(xd, yd, zd);
        }
        return delta;
    }

    private getSpeed() {
        // return the base for now
        // TODO: Handle other cases: speed-enchanted gear and potion
        return this.isSprinting ? 0.13 : 0.1;
    }

    private moveRelative(input: Vec3, speed: number) {
        let delta = Vec3.Zero;
        if (input.lengthSqr() >= Epsilon) {
            const movement = (input.lengthSqr() > 1 ? input.normalize() : input).scale(speed);
            const yRotation = this.getAngle().yaw,
                yRotInRad = yRotation * Math.PI / 180;
            const sin = Math.sin(yRotInRad),
                cos = Math.cos(yRotInRad);
            delta = new Vec3((movement.x * cos) - (movement.z * sin), movement.y, (movement.z * cos) + (movement.x * sin));
        }
        this.setDeltaMovement(this.deltaMovement.add(delta));
    }

    /*
     * Move (collision) related methods  
     */
    private move(
        type: MoverType, // Not used for now 
        delta: Vec3
    ) {
        // console.log({
        //     type: "BEGIN-MOVE",
        //     pos: this.getPos(),
        //     velocity: this.deltaMovement,
        //     onGround: this.onGround,
        //     delta,
        // });
        const movement = this.collide(delta);
        // console.log({
        //     type: "AFTER-COLLIDE",
        //     movement
        // });
        if (movement.length() > Epsilon || delta.lengthSqr() - movement.lengthSqr() < Epsilon) {
            if (this.fallDistance != 0 && movement.length() >= 1) {
                const checkDistance = Math.min(movement.length(), 8);
                const checkTo = this.getPos().add(movement.normalize().scale(checkDistance));
                const hitResult = BlockGetter.clip(this.getPos(), checkTo, {
                    from: this.getPos(),
                    to: checkTo,
                    getBlockState: (position) => this.blocks.at(position)!.shape
                });
                if (hitResult?.miss !== false)
                    this.fallDistance = 0;
            }

            const pos = this.getPos();
            const newPosition = pos.add(movement);
            this.setPos(newPosition);
        }

        this.checkFallDamage(movement.y);

        const movedVertically = Math.abs(delta.y) > 0,
            verticalCollision = !equal(delta.y, movement.y), // or yCollision
            verticalCollisionBelow = verticalCollision && delta.y < 0;
        const xCollision = !equal(delta.x, movement.x),
            zCollision = !equal(delta.z, movement.z),
            horizontalCollision = xCollision || zCollision;
        const effectPos = this.getOnPos(0.2),
            effectState = this.blocks.at(effectPos)!;
        this.horizontalCollision = horizontalCollision;
        this.verticalCollision = verticalCollision;
        if ((movedVertically && verticalCollision) || horizontalCollision)
            this.restituteMovementAfterCollisions(effectState, xCollision, zCollision, verticalCollision, verticalCollisionBelow, movement);

        this.onGround = verticalCollisionBelow;
        const speedFactor = effectState.owner.getSpeedFactor();
        this.setDeltaMovement(this.deltaMovement.multiply(speedFactor, 1, speedFactor));
        // console.log({
        //     type: "DONE-MOVE",
        //     pos: this.getPos(),
        //     vel: this.deltaMovement,
        //     onGround: this.onGround,
        //     horizontalCollision: this.horizontalCollision,
        //     verticalCollision: this.verticalCollision,
        //     originalMovement: delta,
        //     resolvedMovement: movement
        // });
    }

    private checkFallDamage(ya: number) {
        if (
            // !isInWater() &&
            ya < 0
        )
            this.fallDistance -= ya;
        if (this.onGround)
            this.fallDistance = 0;
    }

    private restituteMovementAfterCollisions(
        state: BlockState,
        xCollision: boolean,
        zCollision: boolean,
        verticalCollision: boolean,
        verticalCollisionBelow: boolean,
        movement: Vec3
    ) {
        const currentMovement = this.deltaMovement;
        let restitution = 0,
            gravityCompensation = 0,
            effectiveDrag = 1;
        let movementAfterBounced = currentMovement.copy();
        if (xCollision) movementAfterBounced = movementAfterBounced.with(BaseAxis.X, 0);
        if (zCollision) movementAfterBounced = movementAfterBounced.with(BaseAxis.Z, 0);
        if (verticalCollision) {
            if (verticalCollisionBelow)
                restitution = (
                    -currentMovement.y < 0.08 ||
                    this.isCrouncing ||
                    state.owner.getBounciness() === 0
                ) ? 0 : state.owner.getBounciness();

            if (restitution > 0) {
                const portionWithMovement = movement.y / currentMovement.y;
                gravityCompensation = portionWithMovement * 0.08;
                effectiveDrag = lerp(portionWithMovement, 1, 0.98);
            }
            movementAfterBounced = movementAfterBounced.with(BaseAxis.Y, (gravityCompensation - currentMovement.y) * effectiveDrag * restitution);
        }
        this.setDeltaMovement(movementAfterBounced);
    }

    private collide(movement: Vec3): Vec3 {
        // Get player's bounding box
        const { x: playerX, y: playerY, z: playerZ } = this.state.player!.position;
        const playerBB = AABB.fromEntityType("minecraft:player")
            .move(playerX, playerY, playerZ);
        const expandedBB = playerBB.expandTowards(movement);
        // console.log({
        //     type: "PLAYER-AABB",
        //     pos: this.getPos(),
        //     playerBB: playerBB.copyBase(),
        //     expandedBB: expandedBB.copyBase(),
        // });

        // Collect all colliders
        const colliders = this.collectColliders(expandedBB, [this.state.player!.entityId]);
        // // console.log('nonEmptyColliders', colliders.filter(c => !c.isEmpty()).length);

        // Get the farest distance can move
        let movementStep: Vec3;
        if (colliders.length != 0 && movement.lengthSqr() !== 0)
            movementStep = this.collideWithShapes(movement, playerBB, colliders);
        else movementStep = movement;

        // Check if I can step up WITHOUT jumping
        const xCollision = movement.x != movementStep.x;
        const yCollision = movement.y != movementStep.y;
        const zCollision = movement.z != movementStep.z;
        const onGroundAfterCollision = yCollision && movement.y < 0.0;
        if ((onGroundAfterCollision || this.onGround) && (xCollision || zCollision)) {
            const groundedAABB = onGroundAfterCollision ? playerBB.move(0, movementStep.y, 0) : playerBB;
            let stepUpAABB = groundedAABB.expandTowards(movement.x, 0.6, movement.z);
            if (!onGroundAfterCollision)
                stepUpAABB = stepUpAABB.expandTowards(0, -1e-5, 0);

            const colliders = this.collectColliders(stepUpAABB, [this.state.player!.entityId]);
            const stepHeightToSkip = movementStep.y;
            const stepUpCandidateSet: Set<number> = new Set();
            for (const collider of colliders) {
                const coords = collider.getCoords(BaseAxis.Y);
                for (const coord of coords) {
                    const relativeCoord = coord - groundedAABB.minY;
                    if (relativeCoord < 0 || relativeCoord == stepHeightToSkip) continue;
                    if (relativeCoord > 0.6) break;
                    stepUpCandidateSet.add(relativeCoord);
                }
            }
            const stepUpCandidates = Array.from(stepUpCandidateSet.values());
            stepUpCandidates.sort((a, b) => a - b);

            // console.log({
            //     type: "STEP-UP",
            //     playerY: playerY,
            //     groundedAABBminY: groundedAABB.minY,
            //     movementY: movement.y,
            //     movementStepY: movementStep.y,
            //     stepHeightToSkip,
            //     candidates: stepUpCandidates,
            // });


            for (const candidateHeight of stepUpCandidates) {
                const stepFromGround = this.collideWithShapes(new Vec3(movement.x, candidateHeight, movement.z), groundedAABB, colliders);
                // console.log({
                //     type: "STEP-CANDIDATE",
                //     candidateHeight,
                //     requested: movement,
                //     movementStep,
                //     stepFromGround,
                //     accepted:
                //         stepFromGround.horizontalDistanceSqr() >
                //         movementStep.horizontalDistanceSqr(),
                // });
                if (stepFromGround.horizontalDistanceSqr() > movementStep.horizontalDistanceSqr()) {
                    const distanceFromGround = playerBB.minY - groundedAABB.minY;
                    // console.log({
                    //     type: "ACCEPTED-STEP-CANDIDATE",
                    //     after: stepFromGround.subtract(0, distanceFromGround, 0),
                    // });
                    return stepFromGround.subtract(0, distanceFromGround, 0);
                }
            }
        }

        // console.log('expandedBB', expandedBB, 'colliderCount', colliders.length);
        // console.log('movement.y', movement.y, 'movementStep.y', movementStep.y);

        return movementStep;
    }

    private collectColliders(aabb: AABB, ignoreEntity: number[] = []) {
        const entities = this.entities.queryAABB(aabb, ignoreEntity).map((box) => VoxelShape.fromBox(box));
        const blocks = this.blocks.queryAABB(aabb);
        return [...entities, ...blocks];
    }


    public collideWithShapes(movement: Vec3, aabb: AABB, colliders: VoxelShape[]) {
        let resolvedMovement = Vec3.Zero;
        for (const axis of Axis.stepOrder(movement.x, movement.z)) {
            const axisMovement = movement.get(axis);
            if (Math.abs(axisMovement) < Epsilon) continue;
            const distance = Shapes.collide(
                axis,
                aabb.move(resolvedMovement),
                colliders,
                axisMovement
            );
            resolvedMovement = resolvedMovement.with(axis, distance);
            // console.log({ type: "AXIS-STEP", axis, axisMovement, distanceResolved: distance, aabbUsed: aabb.move(resolvedMovement).copyBase() });
        }
        return resolvedMovement;
    }
}