class PhysicsEngine {
    constructor() {
    }

    /**
     * Adds sprite to the physics
     * @param sprite
     */
    addSprite(sprite: Sprite) { }

    removeSprite(sprite: Sprite) { }

    /** move a single sprite **/
    moveSprite(s: Sprite, dx: Fx8, dy: Fx8) { }

    draw() { }

    /** Apply physics and collisions to all sprites **/
    move(dt: number) { }

    setMaxSpeed(speed: number) { }

    overlaps(sprite: Sprite): Sprite[] { return []; }
}

const MAX_TIME_STEP = 100; // milliseconds
const MIN_MOVE_GAP = Fx8(0.1);

const SPRITE_NO_TILE_OVERLAPS = SpriteFlag.GhostThroughTiles | sprites.Flag.Destroyed | SpriteFlag.RelativeToCamera;
const SPRITE_NO_WALL_COLLISION = SpriteFlag.GhostThroughWalls | sprites.Flag.IsClipping | sprites.Flag.Destroyed | SpriteFlag.RelativeToCamera;
const SPRITE_NO_SPRITE_OVERLAPS = SpriteFlag.GhostThroughSprites | sprites.Flag.Destroyed | SpriteFlag.RelativeToCamera;

class MovingSprite {
    constructor(
        public sprite: Sprite,
        // vx and vy when last updated
        public cachedVx: Fx8,
        public cachedVy: Fx8,
        // remaining x
        public dx: Fx8,
        public dy: Fx8,
        // how much to move per step
        public xStep: Fx8,
        public yStep: Fx8
    ) { }
}


/**
 * A physics engine that does simple AABB bounding box check
 */
class ArcadePhysicsEngine extends PhysicsEngine {
    protected sprites: Sprite[];
    protected map: sprites.SpriteMap;
    protected maxVelocity: Fx8;
    protected maxNegativeVelocity: Fx8;
    protected minSingleStep: Fx8;
    protected maxSingleStep: Fx8;

    constructor(maxVelocity = 500, minSingleStep = 2, maxSingleStep = 4) {
        super();
        this.sprites = [];
        this.map = new sprites.SpriteMap();
        this.maxSpeed = maxVelocity;
        this.maxStep = maxSingleStep;
        this.minStep = minSingleStep;
    }

    get maxSpeed(): number {
        return Fx.toInt(this.maxVelocity);
    }

    set maxSpeed(v: number) {
        this.maxVelocity = Fx8(v);
        this.maxNegativeVelocity = Fx.neg(this.maxVelocity);
    }

    get minStep(): number {
        return Fx.toInt(this.minSingleStep);
    }

    set minStep(v: number) {
        this.minSingleStep = Fx8(v);
    }

    get maxStep(): number {
        return Fx.toInt(this.maxSingleStep);
    }

    set maxStep(v: number) {
        this.maxSingleStep = Fx8(v);
    }

    setMaxSpeed(v: number) {
        this.maxSpeed = v;
    }

    addSprite(sprite: Sprite) {
        this.sprites.push(sprite);
        const tm = game.currentScene().tileMap;
        if (tm && tm.isOnWall(sprite)) {
            sprite.flags |= sprites.Flag.IsClipping;
        }
    }

    removeSprite(sprite: Sprite) {
        this.sprites.removeElement(sprite);
    }

    draw() {
        this.map.draw();
    }

    move(dt: number) {
        // Sprite movement logic is done in milliseconds to avoid rounding errors with Fx8 numbers
        const dtMs = Math.min(MAX_TIME_STEP, dt * 1000);
        const dt2 = Math.idiv(dtMs, 2);

        const scene = game.currentScene();

        const tileMap = scene.tileMap;
        const movingSprites = this.sprites
            .map(sprite => this.createMovingSprite(sprite, dtMs, dt2));

        // clear obstacles if moving on that axis
        this.sprites.forEach(s => {
            if (s.vx || s.vy) s.clearObstacles();
        });

        this.map.clear();
        this.map.resizeBuckets(this.sprites);

        const MAX_STEP_COUNT = Fx.toInt(
            Fx.idiv(
                Fx.imul(
                    Fx.div(
                        this.maxVelocity,
                        this.minSingleStep
                    ),
                    dtMs
                ),
                1000
            )
        );
        const overlapHandlers = scene.overlapHandlers.slice();

        // buffers store the moving sprites on each step; switch back and forth between the two
        let selected = 0;
        let buffers = [movingSprites, []];
        for (let count = 0; count < MAX_STEP_COUNT && buffers[selected].length !== 0; ++count) {
            const currMovers = buffers[selected];
            selected ^= 1;
            const remainingMovers = buffers[selected];

            for (let ms of currMovers) {
                const s = ms.sprite;
                // if still moving and speed has changed from a collision or overlap;
                // reverse direction if speed has reversed
                if (ms.cachedVx !== s._vx) {
                    if (s._vx == Fx.zeroFx8) {
                        ms.dx = Fx.zeroFx8;
                    } else if (s._vx < Fx.zeroFx8 && ms.cachedVx > Fx.zeroFx8
                        || s._vx > Fx.zeroFx8 && ms.cachedVx < Fx.zeroFx8) {
                        ms.dx = Fx.neg(ms.dx);
                        ms.xStep = Fx.neg(ms.xStep);
                    }

                    ms.cachedVx = s._vx;
                }
                if (ms.cachedVy !== s._vy) {
                    if (s._vy == Fx.zeroFx8) {
                        ms.dy = Fx.zeroFx8;
                    } else if (s._vy < Fx.zeroFx8 && ms.cachedVy > Fx.zeroFx8
                        || s._vy > Fx.zeroFx8 && ms.cachedVy < Fx.zeroFx8) {
                        ms.dy = Fx.neg(ms.dy);
                        ms.yStep = Fx.neg(ms.yStep);
                    }

                    ms.cachedVy = s._vy;
                }

                // identify how much to move in this step
                const stepX = Fx.abs(ms.xStep) > Fx.abs(ms.dx) ? ms.dx : ms.xStep;
                const stepY = Fx.abs(ms.yStep) > Fx.abs(ms.dy) ? ms.dy : ms.yStep;
                ms.dx = Fx.sub(ms.dx, stepX);
                ms.dy = Fx.sub(ms.dy, stepY);

                s._lastX = s._x;
                s._lastY = s._y;
                s._x = Fx.add(s._x, stepX);
                s._y = Fx.add(s._y, stepY);

                if (!(s.flags & SPRITE_NO_SPRITE_OVERLAPS) && s._kindsOverlappedWith.length) {
                    this.map.insertAABB(s);
                }
                if (tileMap && tileMap.enabled) {
                    this.tilemapCollisions(ms, tileMap);
                }

                // check for screen edge collisions
                const bounce = s.flags & sprites.Flag.BounceOnWall;
                if (s.flags & sprites.Flag.StayInScreen || (bounce && !tileMap)) {
                    this.screenEdgeCollisions(ms, bounce, scene.camera);
                }

                // if sprite still needs to move, add it to the next step of movements
                if (Fx.abs(ms.dx) > MIN_MOVE_GAP || Fx.abs(ms.dy) > MIN_MOVE_GAP) {
                    remainingMovers.push(ms);
                }
            }

            // this step is done; check collisions between sprites
            this.spriteCollisions(currMovers, overlapHandlers);
            // clear moving sprites buffer for next step
            while (currMovers.length) currMovers.pop();
        }
    }

    protected createMovingSprite(sprite: Sprite, dtMs: number, dt2: number): MovingSprite {
        const ovx = this.constrain(sprite._vx);
        const ovy = this.constrain(sprite._vy);
        sprite._lastX = sprite._x;
        sprite._lastY = sprite._y;

        if (sprite._ax) {
            sprite._vx = Fx.add(
                sprite._vx,
                Fx.idiv(
                    Fx.imul(
                        sprite._ax,
                        dtMs
                    ),
                    1000
                )
            );
        } else if (sprite._fx) {
            const fx = Fx.idiv(
                Fx.imul(
                    sprite._fx,
                    dtMs
                ),
                1000
            );
            const c = Fx.compare(sprite._vx, fx);
            if (c < 0) // v < f, v += f
                sprite._vx = Fx.min(Fx.zeroFx8, Fx.add(sprite._vx, fx));
            else if (c > 0) // v > f, v -= f
                sprite._vx = Fx.max(Fx.zeroFx8, Fx.sub(sprite._vx, fx));
            else
                sprite._vx = Fx.zeroFx8
        }

        if (sprite._ay) {
            sprite._vy = Fx.add(
                sprite._vy,
                Fx.idiv(
                    Fx.imul(
                        sprite._ay,
                        dtMs
                    ),
                    1000
                )
            );
        } else if (sprite._fy) {
            const fy = Fx.idiv(
                Fx.imul(
                    sprite._fy,
                    dtMs
                ),
                1000
            );
            const c = Fx.compare(sprite._vy, fy);
            if (c < 0) // v < f, v += f
                sprite._vy = Fx.min(Fx.zeroFx8, Fx.add(sprite._vy, fy));
            else if (c > 0) // v > f, v -= f
                sprite._vy = Fx.max(Fx.zeroFx8, Fx.sub(sprite._vy, fy));
            else
                sprite._vy = Fx.zeroFx8;
        }

        sprite._vx = this.constrain(sprite._vx);
        sprite._vy = this.constrain(sprite._vy);

        const dx = Fx8(Fx.toFloat(Fx.add(sprite._vx, ovx)) * dt2 / 1000);
        const dy = Fx8(Fx.toFloat(Fx.add(sprite._vy, ovy)) * dt2 / 1000);

        let xStep = dx;
        let yStep = dy;

        // make step increments smaller until under max step size
        while (Fx.abs(xStep) > this.maxSingleStep || Fx.abs(yStep) > this.maxSingleStep) {
            if (Fx.abs(xStep) > this.minSingleStep) {
                xStep = Fx.idiv(xStep, 2);
            }
            if (Fx.abs(yStep) > this.minSingleStep) {
                yStep = Fx.idiv(yStep, 2);
            }
        }

        return new MovingSprite(
            sprite,
            sprite._vx,
            sprite._vy,
            dx,
            dy,
            xStep,
            yStep
        );
    }

    protected spriteCollisions(movedSprites: MovingSprite[], handlers: scene.OverlapHandler[]) {
        control.enablePerfCounter("phys_collisions");
        if (!handlers.length) return;

        // clear the overlap lists on all sprites
        for (const sprite of this.sprites) {
            sprite._alreadyChecked = undefined;
        }

        for (const bucket of this.map.filledBuckets) {
            if (bucket.length === 1) continue;

            for (const sprite of bucket) {
                if (sprite.flags & SPRITE_NO_SPRITE_OVERLAPS) continue;

                for (const overlapper of bucket) {
                    if (overlapper === sprite) continue;
                    const thisKind = sprite.kind();
                    const otherKind = overlapper.kind();

                    // the sprite with the higher id maintains the overlap lists
                    const higher = sprite.id > overlapper.id ? sprite : overlapper;
                    const lower = higher === sprite ? overlapper : sprite;

                    if (!higher._alreadyChecked) {
                        higher._alreadyChecked = [];
                    }

                    // skip if we already compared these two
                    if (higher._alreadyChecked.indexOf(lower.id) !== -1) continue;

                    higher._alreadyChecked.push(lower.id);

                    // skip if already overlapping
                    if (higher._overlappers.indexOf(lower.id) !== -1) continue;

                    // skip if there is no overlap event between these two kinds of sprites
                    if (sprite._kindsOverlappedWith.indexOf(otherKind) === -1) continue;

                    // perform the actual overlap check
                    if (!higher.overlapsWith(lower)) continue;

                    // invoke all matching overlap event handlers
                    for (const h of handlers) {
                        if ((h.kind === thisKind && h.otherKind === otherKind)
                            || (h.kind === otherKind && h.otherKind === thisKind)) {
                            higher._overlappers.push(lower.id);
                            control.runInParallel(() => {
                                if (!((sprite.flags | overlapper.flags) & SPRITE_NO_SPRITE_OVERLAPS)) {
                                    if (thisKind === h.kind) {
                                        h.handler(sprite, overlapper)
                                    }
                                    else {
                                        h.handler(overlapper, sprite)
                                    }
                                }
                                higher._overlappers.removeElement(lower.id);
                            });
                        }
                    }
                }
            }
        }
    }

    protected screenEdgeCollisions(movingSprite: MovingSprite, bounce: number, camera: scene.Camera) {
        let s = movingSprite.sprite;
        if (!s.isStatic()) s.setHitbox();
        if (!camera.isUpdated()) camera.update();

        let offset = Fx.toFloat(s._hitbox.left) - camera.offsetX;
        if (offset < 0) {
            s.left -= offset;
            if (bounce) s.vx = -s.vx;
        }
        else if ((offset = Fx.toFloat(s._hitbox.right) - camera.offsetX - screen.width) > 0) {
            s.right -= offset;
            if (bounce) s.vx = -s.vx;
        }
        if ((offset = Fx.toFloat(s._hitbox.top) - camera.offsetY) < 0) {
            s.top -= offset;
            if (bounce) s.vy = -s.vy;
        }
        else if ((offset = Fx.toFloat(s._hitbox.bottom) - camera.offsetY - screen.height) > 0) {
            s.bottom -= offset;
            if (bounce) s.vy = -s.vy;
        }
    }

    protected tilemapCollisions(movingSprite: MovingSprite, tm: tiles.TileMap) {
        const s = movingSprite.sprite;
        // if the sprite is already clipping into a wall,
        // allow free movement rather than randomly 'fixing' it
        if (s.flags & sprites.Flag.IsClipping) {
            if (!tm.isOnWall(s)) {
                s.flags &= ~sprites.Flag.IsClipping;
            }
        }
        if (!s.isStatic()) s.setHitbox();
        const hbox = s._hitbox;
        const tileScale = tm.scale;
        const tileSize = 1 << tileScale;

        const xDiff = Fx.sub(
            s._x,
            s._lastX
        );

        const yDiff = Fx.sub(
            s._y,
            s._lastY
        );

        if (!(s.flags & SPRITE_NO_WALL_COLLISION)) {
            if (xDiff !== Fx.zeroFx8) {
                const right = xDiff > Fx.zeroFx8;
                const x0 = Fx.toIntShifted(
                    Fx.add(
                        right ?
                            Fx.add(hbox.right, Fx.oneFx8)
                            :
                            Fx.sub(hbox.left, Fx.oneFx8),
                        Fx.oneHalfFx8
                    ),
                    tileScale
                );

                const collidedTiles: sprites.StaticObstacle[] = [];

                // check collisions with tiles sprite is moving towards horizontally
                for (
                    let y = Fx.sub(hbox.top, yDiff);
                    y < Fx.iadd(tileSize, Fx.sub(hbox.bottom, yDiff));
                    y = Fx.iadd(tileSize, y)
                ) {
                    const y0 = Fx.toIntShifted(
                        Fx.add(
                            Fx.min(
                                y,
                                Fx.sub(
                                    hbox.bottom,
                                    yDiff
                                )
                            ),
                            Fx.oneHalfFx8
                        ),
                        tileScale
                    );

                    if (tm.isObstacle(x0, y0)) {
                        const obstacle = tm.getObstacle(x0, y0);
                        if (!collidedTiles.some(o => o.tileIndex === obstacle.tileIndex)) {
                            collidedTiles.push(obstacle);
                        }
                    }
                }

                if (collidedTiles.length) {
                    const collisionDirection = right ? CollisionDirection.Right : CollisionDirection.Left;
                    s._x = Fx.sub(
                        right ?
                            Fx.sub(
                                Fx8(x0 << tileScale),
                                hbox.width
                            )
                            :
                            Fx8((x0 + 1) << tileScale),
                        hbox.ox
                    );

                    for (const tile of collidedTiles) {
                        if(!(s.flags & SPRITE_NO_WALL_COLLISION)) {
                            s.registerObstacle(collisionDirection, tile, tm);
                        }
                    }

                    if (s.flags & sprites.Flag.DestroyOnWall) {
                        s.destroy();
                    } else if (s._vx === movingSprite.cachedVx && !(s.flags & SPRITE_NO_WALL_COLLISION)) {
                        // sprite collision event didn't change velocity in this direction;
                        // apply normal updates
                        if (s.flags & sprites.Flag.BounceOnWall) {
                            if ((!right && s.vx < 0) || (right && s.vx > 0)) {
                                s._vx = Fx.neg(s._vx);
                                movingSprite.xStep = Fx.neg(movingSprite.xStep);
                                movingSprite.dx = Fx.neg(movingSprite.dx);
                            }
                        } else {
                            movingSprite.dx = Fx.zeroFx8;
                            s._vx = Fx.zeroFx8;
                        }
                    } else if (Math.sign(Fx.toInt(s._vx)) === Math.sign(Fx.toInt(movingSprite.cachedVx))) {
                        // sprite collision event changed velocity,
                        // but still facing same direction; prevent further movement this update.
                        movingSprite.dx = Fx.zeroFx8;
                    }
                }
            }

            if (yDiff !== Fx.zeroFx8) {
                const down = yDiff > Fx.zeroFx8;
                const y0 = Fx.toIntShifted(
                    Fx.add(
                        down ?
                            Fx.add(hbox.bottom, Fx.oneFx8)
                            :
                            Fx.sub(hbox.top, Fx.oneFx8),
                        Fx.oneHalfFx8
                    ),
                    tileScale
                );
                const collidedTiles: sprites.StaticObstacle[] = [];

                // check collisions with tiles sprite is moving towards vertically
                for (
                    let x = hbox.left;
                    x < Fx.iadd(tileSize, hbox.right);
                    x = Fx.iadd(tileSize, x)
                ) {
                    const x0 = Fx.toIntShifted(
                        Fx.add(
                            Fx.min(
                                x,
                                hbox.right
                            ),
                            Fx.oneHalfFx8
                        ),
                        tileScale
                    );

                    if (tm.isObstacle(x0, y0)) {
                        const obstacle = tm.getObstacle(x0, y0);
                        if (!collidedTiles.some(o => o.tileIndex === obstacle.tileIndex)) {
                            collidedTiles.push(obstacle);
                        }
                    }
                }

                if (collidedTiles.length) {
                    const collisionDirection = down ? CollisionDirection.Bottom : CollisionDirection.Top;
                    s._y = Fx.sub(
                        down ?
                            Fx.sub(
                                Fx8(y0 << tileScale),
                                hbox.height
                            )
                            :
                            Fx8((y0 + 1) << tileScale),
                        hbox.oy
                    );

                    for (const tile of collidedTiles) {
                        if(!(s.flags & SPRITE_NO_WALL_COLLISION)) {
                            s.registerObstacle(collisionDirection, tile, tm);
                        }
                    }

                    if (s.flags & sprites.Flag.DestroyOnWall) {
                        s.destroy();
                    } else if (s._vy === movingSprite.cachedVy && !(s.flags & SPRITE_NO_WALL_COLLISION)) {
                        // sprite collision event didn't change velocity in this direction;
                        // apply normal updates
                        if (s.flags & sprites.Flag.BounceOnWall) {
                            if ((!down && s.vy < 0) || (down && s.vy > 0)) {
                                s._vy = Fx.neg(s._vy);
                                movingSprite.yStep = Fx.neg(movingSprite.yStep);
                                movingSprite.dy = Fx.neg(movingSprite.dy);
                            }
                        } else {
                            movingSprite.dy = Fx.zeroFx8;
                            s._vy = Fx.zeroFx8;
                        }
                    } else if (Math.sign(Fx.toInt(s._vy)) === Math.sign(Fx.toInt(movingSprite.cachedVy))) {
                        // sprite collision event changed velocity,
                        // but still facing same direction; prevent further movement this update.
                        movingSprite.dy = Fx.zeroFx8;
                    }
                }
            }
        }


        if (!(s.flags & SPRITE_NO_TILE_OVERLAPS)) {
            // Now that we've moved, check all of the tiles underneath the current position
            // for overlaps
            const overlappedTiles: tiles.Location[] = [];
            for (
                let x = hbox.left;
                x < Fx.iadd(tileSize, hbox.right);
                x = Fx.iadd(tileSize, x)
            ) {
                const x0 = Fx.toIntShifted(
                    Fx.add(
                        Fx.min(
                            x,
                            hbox.right
                        ),
                        Fx.oneHalfFx8
                    ),
                    tileScale
                );
                for (
                    let y = hbox.top;
                    y < Fx.iadd(tileSize, hbox.bottom);
                    y = Fx.iadd(tileSize, y)
                ) {
                    const y0 = Fx.toIntShifted(
                        Fx.add(
                            Fx.min(
                                y,
                                hbox.bottom
                            ),
                            Fx.oneHalfFx8
                        ),
                        tileScale
                    );

                    // if the sprite can move through walls, it can overlap the underlying tile.
                    if (!tm.isObstacle(x0, y0) || !!(s.flags & sprites.Flag.GhostThroughWalls)) {
                        const location = tm.getTile(x0, y0);
                        overlappedTiles.push(location);
                    }
                }
            }

            if (overlappedTiles.length) {
                this.tilemapOverlaps(s, overlappedTiles);
            }
        }
    }

    /**
     * Given a sprite and a list of overlapped tiles, checks the overlap handlers and calls
     * the ones appropriate to the sprite and tile kind.
     * @param sprite the sprite
     * @param overlappedTiles the list of tiles the sprite is overlapping
     */
    protected tilemapOverlaps(sprite: Sprite, overlappedTiles: tiles.Location[]) {
        const alreadyHandled: tiles.Location[] = [];

        for (const tile of overlappedTiles) {
            if (alreadyHandled.some(l => l.column === tile.column && l.row === tile.row)) {
                continue;
            }
            alreadyHandled.push(tile);

            const tileOverlapHandlers = game.currentScene().tileOverlapHandlers;
            if (tileOverlapHandlers) {
                tileOverlapHandlers
                    .filter(h => h.spriteKind == sprite.kind() && h.tileKind.equals(tile._getOriginalImage()))
                    .forEach(h => h.handler(sprite, tile));
            }
        }
    }

    /**
     * Returns sprites that overlap with the given sprite. If type is non-zero, also filter by type.
     * @param sprite
     * @param layer
     */
    overlaps(sprite: Sprite): Sprite[] {
        return this.map.overlaps(sprite);
    }

    /** moves a sprite explicitly outside of the normal velocity changes **/
    public moveSprite(s: Sprite, dx: Fx8, dy: Fx8) {
        s._lastX = s._x;
        s._lastY = s._y;
        s._x = Fx.add(s._x, dx);
        s._y = Fx.add(s._y, dy);

        // if the sprite can collide with things, check tile map
        const tm = game.currentScene().tileMap;
        if (tm && tm.enabled) {
            const maxDist = Fx.toInt(this.maxSingleStep);
            // only check tile map if moving within a single step
            if (Math.abs(Fx.toInt(dx)) <= maxDist && Math.abs(Fx.toInt(dy)) <= maxDist) {
                const ms = new MovingSprite(
                    s,
                    s._vx,
                    s._vy,
                    dx,
                    dy,
                    dx,
                    dy
                );
                this.tilemapCollisions(ms, tm);
                // otherwise, accept movement...
            } else if (tm.isOnWall(s) && !this.canResolveClipping(s, tm)) {
                // if no luck, flag as clipping into a wall
                s.flags |= sprites.Flag.IsClipping;
            } else {
                // or clear clipping if no longer clipping
                s.flags &= ~sprites.Flag.IsClipping;
            }
        }
    }

    // Attempt to resolve clipping by moving the sprite slightly up / down / left / right
    protected canResolveClipping(s: Sprite, tm: tiles.TileMap) {
        if (s.flags & sprites.Flag.GhostThroughWalls) return false;
        if (!s.isStatic()) s.setHitbox();
        const hbox = s._hitbox;
        const sz = 1 << tm.scale;
        const maxMove = this.maxStep;
        const origY = s._y;
        const origX = s._x;
        const l = Fx.toInt(hbox.left);
        const r = Fx.toInt(hbox.right);
        const t = Fx.toInt(hbox.top);
        const b = Fx.toInt(hbox.bottom);

        {   // bump up and test;
            const offset = (b + 1) % sz;
            if (offset <= maxMove) {
                s._y = Fx.sub(
                    s._y,
                    Fx8(offset)
                );
                if (!tm.isOnWall(s)) {
                    return true;
                } else {
                    s._y = origY;
                }
            }
        }
        {   // bump down and test;
            const offset = (Math.floor(t / sz) + 1) * sz - t;
            if (offset <= maxMove) {
                s._y = Fx.add(
                    s._y,
                    Fx8(offset)
                );
                if (!tm.isOnWall(s)) {
                    return true;
                } else {
                    s._y = origY;
                }
            }
        }
        {   // bump left and test;
            const offset = (r + 1) % sz;
            if (offset <= maxMove) {
                s._x = Fx.sub(
                    s._x,
                    Fx8(offset)
                );
                if (!tm.isOnWall(s)) {
                    return true;
                } else {
                    s._x = origX;
                }
            }
        }
        {   // bump right and test;
            const offset = (Math.floor(l / sz) + 1) * sz - l;
            if (offset <= maxMove) {
                s._x = Fx.add(
                    s._x,
                    Fx8(offset)
                );
                if (!tm.isOnWall(s)) {
                    return true;
                } else {
                    s._x = origX;
                }
            }
        }

        // no trivial adjustment worked; it's going to clip for now
        return false;
    }

    protected constrain(v: Fx8) {
        return Fx.max(
            Fx.min(
                this.maxVelocity,
                v
            ),
            this.maxNegativeVelocity
        );
    }
}
