ca5faeea创建于 9 天前历史提交
/**
 * InputManager.js
 *
 * Handles mouse, touch, and keyboard input on the game canvas, translating
 * it to game-level events (place, erase, hover, pan, zoom).
 *
 * Touch model (mirrors the desktop mouse/keyboard model as closely as a
 * fingers-only device allows):
 *
 *   • Single-finger tap            → primary click (place / erase, depending on tool)
 *   • Single-finger long-press     → secondary click (erase) — the "right click" stand-in
 *   • Single-finger drag (place)   → brush-place across cells
 *   • Single-finger drag (erase)   → brush-erase across cells
 *   • Single-finger drag (pan)     → pan camera
 *   • Two-finger pinch             → zoom in / out, anchored at the gesture midpoint
 *   • Two-finger drag              → pan (always works, regardless of active tool)
 */

import { CONFIG } from '../config.js';
import { screenToCell } from '../grid/IsoGrid.js';
import { playUiClick } from '../ui/Audio.js';

// How long a stationary single-finger touch must be held before we
// treat it as the "erase" gesture. Tuned to feel responsive but not
// trip while panning slowly.
const LONG_PRESS_MS = 420;
// Pixel distance the finger may drift before we cancel the long-press
// timer and reclassify the gesture as a drag.
const TOUCH_MOVE_THRESHOLD = 8;
// Max pixels a finger may drift and still register as a tap on release.
const TAP_SLOP = 10;
// Max ms a touch may stay down and still register as a tap on release.
const TAP_MAX_MS = 350;

export class InputManager {
    constructor(canvas, camera, game) {
        this.canvas = canvas;
        this.camera = camera;
        this.game = game;

        this._dragging = false;
        this._dragMoved = false;
        this._lastX = 0;
        this._lastY = 0;
        this._pressedButton = null;
        this._brushActive = false;
        this._lastBrushKey = null;

        // Touch state — kept entirely separate from the mouse path so the
        // two input modes don't fight each other on hybrid devices.
        this._touches = new Map(); // touch.identifier → { x, y, startX, startY, startTime }
        this._touchMode = null;    // null | 'single' | 'pinch'
        this._touchMoved = false;
        this._touchSecondaryFired = false;
        this._longPressTimer = null;
        this._lastBrushTouchKey = null;
        this._pinchLastDist = 0;
        this._pinchLastMid = { x: 0, y: 0 };
        this._lastTouchScreen = null; // last x/y of the active finger, for tap on release

        this._bind();
    }

    _bind() {
        const c = this.canvas;
        c.addEventListener('mousedown',   e => this._onMouseDown(e));
        window.addEventListener('mousemove', e => this._onMouseMove(e));
        window.addEventListener('mouseup',   e => this._onMouseUp(e));
        c.addEventListener('contextmenu', e => e.preventDefault());
        c.addEventListener('wheel',       e => this._onWheel(e), { passive: false });

        // Touch handlers — passive: false so we can preventDefault and
        // stop the browser from scrolling, pinch-zooming the page, or
        // firing synthetic mouse events that would double-trigger us.
        c.addEventListener('touchstart',  e => this._onTouchStart(e),  { passive: false });
        c.addEventListener('touchmove',   e => this._onTouchMove(e),   { passive: false });
        c.addEventListener('touchend',    e => this._onTouchEnd(e),    { passive: false });
        c.addEventListener('touchcancel', e => this._onTouchEnd(e),    { passive: false });

        window.addEventListener('keydown', e => this._onKeyDown(e));
    }

    _toCell(e) {
        const rect = this.canvas.getBoundingClientRect();
        const sx = e.clientX - rect.left;
        const sy = e.clientY - rect.top;
        const world = this.camera.screenToWorld(sx, sy);
        const c = screenToCell(world.x, world.y);
        return { gx: Math.floor(c.gx), gy: Math.floor(c.gy), sx, sy };
    }

    _onMouseDown(e) {
        const { gx, gy, sx, sy } = this._toCell(e);
        this._dragging = true;
        this._dragMoved = false;
        this._lastX = sx;
        this._lastY = sy;
        this._pressedButton = e.button;

        const canBrush = this.game.tool !== 'pan'
            && (e.button === 0 || e.button === 2)
            && !e.shiftKey;
        if (canBrush) {
            e.preventDefault();
            this._brushActive = true;
            this._lastBrushKey = null;
            this._brushCell(gx, gy);
        }
    }

    _onMouseMove(e) {
        const rect = this.canvas.getBoundingClientRect();
        const sx = e.clientX - rect.left;
        const sy = e.clientY - rect.top;
        const world = this.camera.screenToWorld(sx, sy);
        const c = screenToCell(world.x, world.y);
        const cell = { gx: Math.floor(c.gx), gy: Math.floor(c.gy) };
        this.game.onHover(cell);

        if (!this._dragging) return;
        const dx = sx - this._lastX;
        const dy = sy - this._lastY;
        if (Math.abs(dx) + Math.abs(dy) > 3) this._dragMoved = true;

        if (this._brushActive && !e.shiftKey) {
            this._brushCell(cell.gx, cell.gy);
            this._lastX = sx;
            this._lastY = sy;
            return;
        }

        // Pan with middle button always; with left only in pan mode.
        const panMode = this.game.tool === 'pan';
        if (this._pressedButton === 1 || panMode || (this._dragMoved && e.shiftKey)) {
            this.camera.pan(dx, dy);
        }
        this._lastX = sx;
        this._lastY = sy;
    }

    _onMouseUp(e) {
        if (!this._dragging) return;
        this._dragging = false;
        if (this._brushActive) {
            this._brushActive = false;
            this._lastBrushKey = null;
            this._pressedButton = null;
            return;
        }
        if (this._dragMoved) { this._pressedButton = null; return; }

        const { gx, gy } = this._toCell(e);

        if (e.button === 0) {
            this.game.onPrimaryClick(gx, gy);
        } else if (e.button === 2) {
            this.game.onSecondaryClick(gx, gy);
        }
        this._pressedButton = null;
    }

    _brushCell(gx, gy) {
        const key = `${gx},${gy}`;
        if (key === this._lastBrushKey) return;
        this._lastBrushKey = key;

        if (this._pressedButton === 0) {
            this.game.onPrimaryClick(gx, gy);
        } else if (this._pressedButton === 2) {
            this.game.onSecondaryClick(gx, gy);
        }
        this.game.onHover({ gx, gy });
    }

    _onWheel(e) {
        e.preventDefault();
        const rect = this.canvas.getBoundingClientRect();
        const sx = e.clientX - rect.left;
        const sy = e.clientY - rect.top;
        const factor = Math.exp(-e.deltaY * 0.0015);
        this.camera.zoomAt(sx, sy, factor);
    }

    /* ── Touch input ──────────────────────────────────────────── */

    _touchToCanvas(touch) {
        const rect = this.canvas.getBoundingClientRect();
        return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
    }

    _screenToCellXY(sx, sy) {
        const world = this.camera.screenToWorld(sx, sy);
        const c = screenToCell(world.x, world.y);
        return { gx: Math.floor(c.gx), gy: Math.floor(c.gy) };
    }

    _onTouchStart(e) {
        // Stop the browser from generating synthetic mouse events,
        // scrolling, or doing the iOS double-tap-zoom.
        e.preventDefault();

        for (const t of e.changedTouches) {
            const { x, y } = this._touchToCanvas(t);
            this._touches.set(t.identifier, {
                x, y,
                startX: x, startY: y,
                startTime: performance.now(),
            });
            this._lastTouchScreen = { x, y };
        }

        const n = this._touches.size;
        if (n === 1) {
            this._touchMode = 'single';
            this._touchMoved = false;
            this._touchSecondaryFired = false;
            this._lastBrushTouchKey = null;

            // Hover the cell under the finger right away so the placement
            // preview tracks where the user touched.
            const [tp] = this._touches.values();
            this.game.onHover(this._screenToCellXY(tp.x, tp.y));

            // Long-press = erase. We schedule it once at touch-down and
            // any drift / lift / second finger cancels it.
            this._clearLongPressTimer();
            this._longPressTimer = setTimeout(() => {
                this._longPressTimer = null;
                if (this._touches.size !== 1 || this._touchMoved) return;
                const cell = this._screenToCellXY(tp.x, tp.y);
                this._touchSecondaryFired = true;
                this.game.onSecondaryClick(cell.gx, cell.gy);
                if (navigator.vibrate) navigator.vibrate(18);
            }, LONG_PRESS_MS);
        } else if (n >= 2) {
            // Promote to pinch. Cancel any in-flight single-finger
            // intent so we don't accidentally place when the second
            // finger lands a few ms later than the first.
            this._clearLongPressTimer();
            this._touchMode = 'pinch';
            this._touchSecondaryFired = false;
            const [a, b] = Array.from(this._touches.values()).slice(0, 2);
            this._pinchLastDist = Math.max(1, this._distance(a, b));
            this._pinchLastMid = this._midpoint(a, b);
        }
    }

    _onTouchMove(e) {
        e.preventDefault();

        for (const t of e.changedTouches) {
            const tp = this._touches.get(t.identifier);
            if (!tp) continue;
            const { x, y } = this._touchToCanvas(t);
            // Track per-frame delta on the touch itself so a single
            // pan-tool finger can scroll the world without us having to
            // remember the last position globally.
            tp.lastX = tp.x; tp.lastY = tp.y;
            tp.x = x; tp.y = y;
            this._lastTouchScreen = { x, y };
        }

        if (this._touchMode === 'single') {
            const [tp] = this._touches.values();
            const dx = tp.x - tp.startX;
            const dy = tp.y - tp.startY;
            if (!this._touchMoved && (Math.abs(dx) + Math.abs(dy)) > TOUCH_MOVE_THRESHOLD) {
                this._touchMoved = true;
                this._clearLongPressTimer();
            }
            // Always keep the hover preview tracking the finger so the
            // valid/invalid state visualises correctly while dragging.
            const cell = this._screenToCellXY(tp.x, tp.y);
            this.game.onHover(cell);

            if (!this._touchMoved) return;

            const tool = this.game.tool;
            if (tool === 'pan') {
                const fdx = tp.x - (tp.lastX ?? tp.x);
                const fdy = tp.y - (tp.lastY ?? tp.y);
                if (fdx || fdy) this.camera.pan(fdx, fdy);
            } else if (tool === 'place' || tool === 'erase') {
                const key = `${cell.gx},${cell.gy}`;
                if (key !== this._lastBrushTouchKey) {
                    this._lastBrushTouchKey = key;
                    // Primary click respects the active tool — in erase
                    // mode it erases, in place mode it places. Same as
                    // the mouse brush path.
                    this.game.onPrimaryClick(cell.gx, cell.gy);
                }
            }
        } else if (this._touchMode === 'pinch') {
            const [a, b] = Array.from(this._touches.values()).slice(0, 2);
            if (!a || !b) return;
            const dist = Math.max(1, this._distance(a, b));
            const mid  = this._midpoint(a, b);

            // Frame-relative scale: the camera's zoom is already
            // accumulating, so we only multiply by the change since
            // last frame. This stays stable when fingers add or lift.
            const factor = dist / this._pinchLastDist;
            if (factor !== 1) this.camera.zoomAt(mid.x, mid.y, factor);

            // Two-finger pan: midpoint drift moves the camera too.
            const pdx = mid.x - this._pinchLastMid.x;
            const pdy = mid.y - this._pinchLastMid.y;
            if (pdx || pdy) this.camera.pan(pdx, pdy);

            this._pinchLastDist = dist;
            this._pinchLastMid = mid;
        }
    }

    _onTouchEnd(e) {
        e.preventDefault();

        // Snapshot the finger that's lifting so we can do tap-on-release
        // from its actual final position (the map entry is about to go).
        let lifted = null;
        for (const t of e.changedTouches) {
            lifted = this._touches.get(t.identifier) || lifted;
            this._touches.delete(t.identifier);
        }

        const wasSingle = this._touchMode === 'single';
        const remaining = this._touches.size;

        if (wasSingle && remaining === 0 && lifted) {
            this._clearLongPressTimer();
            const elapsed = performance.now() - lifted.startTime;
            const dx = lifted.x - lifted.startX;
            const dy = lifted.y - lifted.startY;
            const moved = (Math.abs(dx) + Math.abs(dy)) > TAP_SLOP;
            const tap = !moved && elapsed < TAP_MAX_MS && !this._touchSecondaryFired;
            if (tap) {
                const cell = this._screenToCellXY(lifted.x, lifted.y);
                this.game.onPrimaryClick(cell.gx, cell.gy);
            }
        }

        if (remaining === 0) {
            this._touchMode = null;
            this._touchMoved = false;
            this._touchSecondaryFired = false;
            this._lastBrushTouchKey = null;
            this._clearLongPressTimer();
        } else if (remaining === 1 && this._touchMode === 'pinch') {
            // Dropped from pinch back to single — restart the single
            // path with the surviving finger as a fresh "drag" so we
            // don't snap-pan from its old start position. We also
            // suppress tap detection (mark moved) since the user is
            // mid-gesture, not tapping.
            const [tp] = this._touches.values();
            tp.startX = tp.x;
            tp.startY = tp.y;
            tp.startTime = performance.now();
            tp.lastX = tp.x;
            tp.lastY = tp.y;
            this._touchMode = 'single';
            this._touchMoved = true;
            this._touchSecondaryFired = true; // belt + suspenders: also blocks tap
        }
    }

    _clearLongPressTimer() {
        if (this._longPressTimer != null) {
            clearTimeout(this._longPressTimer);
            this._longPressTimer = null;
        }
    }

    _distance(a, b) {
        const dx = a.x - b.x, dy = a.y - b.y;
        return Math.sqrt(dx * dx + dy * dy);
    }

    _midpoint(a, b) {
        return { x: (a.x + b.x) * 0.5, y: (a.y + b.y) * 0.5 };
    }

    _onKeyDown(e) {
        // Skip when user is typing in an input.
        if (e.target instanceof HTMLInputElement
            || e.target instanceof HTMLTextAreaElement) return;
        const k = e.key.toLowerCase();
        const map = {
            '1': () => this.game.setCategory('terrain'),
            '2': () => this.game.setCategory('nature'),
            '3': () => this.game.setCategory('props'),
            '4': () => this.game.setCategory('water'),
            '5': () => this.game.setCategory('buildings'),
            'e': () => this.game.setTool(this.game.tool === 'erase' ? 'place' : 'erase'),
            'g': () => this.game.toggleGrid(),
            's': () => this.game.save(),
            'r': () => this.game.reset(),
            'h': () => this.game.toggleFlipH(),
            'v': () => this.game.toggleFlipV(),
        };
        if (map[k]) {
            e.preventDefault();
            playUiClick();
            map[k]();
        }
    }
}