ca5faeea创建于 9 天前历史提交
/**
 * Renderer.js
 *
 * Renders the world (tile map + placed objects + cursor preview) to the
 * main game canvas using painter's algorithm depth sorting.
 *
 * Layered architecture:
 *
 *   [SCREEN-SPACE STATIC CACHE]
 *     1. Soft warm sky + bloom + parchment dots backdrop.
 *     2. Multi-layer blurred drop shadow under the floating platform.
 *     3. Cream platform slab + back-edge highlight.
 *     4. Soft outer vignette.
 *     Rebuilt only on resize / grid resize.
 *
 *   [WORLD-SPACE TERRAIN CACHE]
 *     Every terrain tile composed once into an offscreen world-space
 *     canvas. Per-frame we just stamp it via the camera transform.
 *     Rebuilt only when `tileMap.terrainVersion` changes.
 *
 *   [WORLD-SPACE STATIC-OBJECTS CACHE]
 *     Every non-animating object's cast shadow + sprite, depth-sorted,
 *     composed into one world-space canvas. Rebuilt only when
 *     `tileMap.objectsVersion` changes (or when an animation completes
 *     and the object joins the static set).
 *
 *   [LIVE OVERLAY]
 *     Hover tile highlight, ghost preview & its shadow, plus any objects
 *     and tiles whose placement animation is still playing.
 *
 * Plus a dirty-flag pattern: `draw()` is skipped entirely when nothing
 * changed and no animations are running, so an idle scene with 200
 * placements costs ~0% CPU.
 */

import { CONFIG } from '../config.js';
import { cellToScreen } from '../grid/IsoGrid.js';
import { getAsset } from '../assets/assetLoader.js';
import { ASSET_INDEX } from '../assets/assetManifest.js';

const TW = CONFIG.tile.w;
const TH = CONFIG.tile.h;

// World-space padding around the platform when allocating cache canvases:
// objects can extend well above the platform top (windmill vanes, chapel
// tower) and slightly below it (side walls, drop shadows).
const WORLD_PAD_TOP    = 800;
const WORLD_PAD_BOTTOM = 240;
const WORLD_PAD_X      = 320;

/**
 * High-DPI scale for the world-space terrain & objects caches.
 *
 * The asset displayCanvases are pre-rendered at ~6× their reference
 * display size (DISPLAY_SUPERSAMPLE in the asset loader), so the only
 * quality bottleneck left is whatever resolution we store inside the
 * cached layers. At cache_scale = 1 the cache holds tiles at their
 * reference width (e.g. 64 px), and at default zoom on a retina screen
 * the camera then upscales that ~2.8× before painting — visibly soft.
 *
 * We raise the cache scale roughly to `defaultZoom × devicePixelRatio`
 * (≈ 3 on retina, 2 elsewhere) so the cached pixels themselves are at
 * or near final on-screen resolution at default zoom. Memory cost is
 * modest: ~80 MB per cache on retina, gone the moment the page closes.
 */
const _DPR = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
// Capped at 3 so we stay safely within canvas size limits on every
// browser even with our generous world-bounds padding (e.g. 1536 × 1488
// world × 3 = 4608 × 4464 cache, comfortably below the typical 8192/16384
// limits) and so memory stays bounded on very-high-DPI devices.
const CACHE_SCALE = Math.min(3, Math.max(2, Math.ceil(_DPR * 1.5)));

// Shadow tuning. Pre-blurring happens once at asset-load time; the
// renderer just transforms + alphas the silhouettes per frame.
const SHADOW_ALPHA       = 0.32;
const BACK_DRIFT_X       = 0.16;
const BACK_DRIFT_Y       = 0.48;

export class Renderer {
    constructor(canvas, camera, tileMap) {
        this.canvas = canvas;
        this.ctx = canvas.getContext('2d', { alpha: true });
        this.camera = camera;
        this.tileMap = tileMap;

        // Visibility toggles
        this.showGrid = false;
        this.ambientOcclusion = true;
        this.showBorders = true;

        // Hover state set by the input manager
        this.hoverCell = null;       // { gx, gy }
        this.previewAssetId = null;  // null when not in place mode
        this.previewValid = true;
        this.eraseMode = false;
        // Flip flags applied to the ghost preview (set by Game).
        this.previewFlipH = false;
        this.previewFlipV = false;

        // Per-frame snapshot of currently-running placement animations.
        // Keyed by 'obj-<id>' for placed objects and 't-<gx>,<gy>' for
        // terrain tiles. Values are normalised progress in [0, 1).
        this._anims = new Map();
        this._frameAnims = new Map();
        this._animObjectIds = new Set();   // numeric obj ids currently animating
        this._animTerrainKeys = new Set(); // 'gx,gy' strings currently animating

        // Cached layers + the version stamps that produced them.
        // Chrome = backdrop + vignette in screen space (depends only on
        // resize). Platform / terrain / objects all live in a single
        // world-space coordinate frame and are stamped via the camera
        // transform, so pan & zoom never invalidate them.
        this._chromeCanvas   = null;
        this._chromeDirty    = true;
        this._platformCanvas = null;
        this._platformGridW  = -1;
        this._platformGridH  = -1;
        this._terrainCanvas  = null;
        this._terrainVersion = -1;
        this._objectsCanvas  = null;
        this._objectsVersion = -1;
        this._objectsAnimCount = 0;

        // World-space bounds (stored at first build, derived from grid).
        this._worldBounds = null;

        // Dirty flag for the render loop. We always draw at least once
        // after construction; otherwise the loop early-exits unless an
        // animation is running or `markDirty()` was called.
        this._dirty = true;

        this.resize();
        window.addEventListener('resize', () => this.resize());
    }

    /** Mark the next frame as needing a redraw. */
    markDirty() { this._dirty = true; }

    /**
     * Trigger a one-shot elastic placement animation for the given key.
     * The cell rect is stored alongside the timer so the preview ghost can
     * step out of the way of any cell currently running an animation.
     *
     * `startAt` (default = now) lets callers schedule the animation to
     * begin in the future — used by the starter-scene reveal to ripple
     * the placements in back-to-front instead of all at once. Animations
     * with `startAt` in the future stay invisible until their start time
     * arrives, but they're already excluded from the static caches so
     * they don't pop in twice.
     */
    spawnAnim(key, cell = null, duration = 460, startAt = performance.now()) {
        this._anims.set(key, { start: startAt, duration, cell });
        if (key.startsWith('obj-')) {
            const id = +key.slice(4);
            if (!Number.isNaN(id)) this._animObjectIds.add(id);
            // The animating object is no longer part of the static cache.
            this._objectsVersion = -1;
        } else if (key.startsWith('t-')) {
            // 't-<gx>,<gy>' — stash the cell key for the terrain cache to
            // skip while the elastic effect plays, otherwise the baked
            // tile shows underneath the scaled overlay and the animation
            // looks like a faint ghost rather than a real pop.
            this._animTerrainKeys.add(key.slice(2));
            this._terrainVersion = -1;
        }
        this._dirty = true;
    }

    _snapshotAnims() {
        const now = performance.now();
        this._frameAnims.clear();
        let removedObj = false;
        let removedTerrain = false;
        for (const [key, a] of this._anims) {
            const t = (now - a.start) / a.duration;
            if (t >= 1) {
                this._anims.delete(key);
                if (key.startsWith('obj-')) {
                    const id = +key.slice(4);
                    if (!Number.isNaN(id)) this._animObjectIds.delete(id);
                    removedObj = true;
                } else if (key.startsWith('t-')) {
                    this._animTerrainKeys.delete(key.slice(2));
                    removedTerrain = true;
                }
                continue;
            }
            // Skip animations whose scheduled start time hasn't arrived
            // yet (used by the staggered starter-scene reveal). They're
            // still tracked in `_anims` so subsequent frames will pick
            // them up once their start window opens.
            if (t < 0) continue;
            this._frameAnims.set(key, { t, cell: a.cell });
        }
        // When an animation finishes we need to rebuild the corresponding
        // static cache so the freshly-settled tile / object joins the
        // baked layer. The dirty flag also has to flip on, otherwise the
        // next frame would early-exit and the just-settled cell would
        // briefly disappear.
        if (removedObj)     { this._objectsVersion = -1; this._dirty = true; }
        if (removedTerrain) { this._terrainVersion = -1; this._dirty = true; }
    }

    _animT(key) {
        const entry = this._frameAnims.get(key);
        return entry == null ? undefined : entry.t;
    }

    _isAnimAtCell(gx, gy) {
        for (const { cell } of this._frameAnims.values()) {
            if (!cell) continue;
            if (gx >= cell.gx && gx < cell.gx + (cell.w ?? 1)
                && gy >= cell.gy && gy < cell.gy + (cell.d ?? 1)) {
                return true;
            }
        }
        return false;
    }

    _easeOutElastic(t) {
        if (t <= 0) return 0;
        if (t >= 1) return 1;
        const c4 = (2 * Math.PI) / 3;
        return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
    }

    resize() {
        const dpr = window.devicePixelRatio || 1;
        const w = window.innerWidth;
        const h = window.innerHeight;
        this.canvas.width  = w * dpr;
        this.canvas.height = h * dpr;
        this.canvas.style.width  = w + 'px';
        this.canvas.style.height = h + 'px';
        this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        // The per-frame composite is just a handful of large drawImage
        // calls (chrome + platform + terrain + objects + a few overlays),
        // so 'high' is affordable and keeps assets crisp at zoom.
        this.ctx.imageSmoothingEnabled = true;
        this.ctx.imageSmoothingQuality = 'high';
        this._chromeDirty = true;
        this._dirty = true;
    }

    /** Canvas size in CSS pixels. */
    cssSize() {
        return { w: window.innerWidth, h: window.innerHeight };
    }

    /** Draw the entire frame, but only when something has actually changed. */
    draw() {
        this._snapshotAnims();
        // Any pending anim — even one whose start time is still in the
        // future — must keep the loop alive so we eventually reach its
        // start window. Otherwise the dirty flag would settle to false
        // and the staggered reveal would freeze before it began.
        const animsPending = this._anims.size > 0;
        if (!this._dirty && !animsPending) return;
        this._dirty = false;

        const ctx = this.ctx;
        const { w, h } = this.cssSize();
        ctx.clearRect(0, 0, w, h);

        this._ensureChromeCache(w, h);
        this._ensurePlatformCache();
        this._ensureTerrainCache();
        this._ensureObjectsCache();

        // 1. Static screen-space chrome (backdrop dots + bloom + sky).
        ctx.drawImage(this._chromeCanvas.bottom, 0, 0, w, h);

        // 2. World layers via the camera transform — none of these depend
        //    on the camera state, so pan/zoom is just a transform change
        //    and four stamped images.
        ctx.save();
        this._applyCamera();
        const wb = this._worldBounds;

        if (this._platformCanvas) ctx.drawImage(this._platformCanvas, wb.x, wb.y);
        // Terrain + objects caches are stored at CACHE_SCALE world DPR for
        // crisp pixels at zoom; we explicitly size the stamp to world
        // units so the browser does the high-quality downsample as part
        // of the same hardware-resampled draw.
        if (this._terrainCanvas)  ctx.drawImage(this._terrainCanvas,  wb.x, wb.y, wb.w, wb.h);
        if (this.showGrid)        this._drawGrid();
        if (this._objectsCanvas)  ctx.drawImage(this._objectsCanvas,  wb.x, wb.y, wb.w, wb.h);

        // 3. Live overlays: actively-animating objects/tiles + hover +
        //    preview ghost. Sorted together so depth is sane.
        this._drawLiveOverlay();

        ctx.restore();

        // 4. Top-of-frame vignette (applied in screen space).
        ctx.drawImage(this._chromeCanvas.top, 0, 0, w, h);
    }

    _applyCamera() {
        const ctx = this.ctx;
        ctx.translate(this.camera.offsetX, this.camera.offsetY);
        ctx.scale(this.camera.zoom, this.camera.zoom);
    }

    /* ── World bounds ─────────────────────────────────────────── */

    _computeWorldBounds() {
        const W = this.tileMap.width, H = this.tileMap.height;
        const corners = [
            cellToScreen(0, 0),
            cellToScreen(W, 0),
            cellToScreen(W, H),
            cellToScreen(0, H),
        ];
        let minX =  Infinity, maxX = -Infinity;
        let minY =  Infinity, maxY = -Infinity;
        for (const c of corners) {
            if (c.x < minX) minX = c.x;
            if (c.x > maxX) maxX = c.x;
            if (c.y < minY) minY = c.y;
            if (c.y > maxY) maxY = c.y;
        }
        const x = Math.floor(minX - WORLD_PAD_X);
        const y = Math.floor(minY - WORLD_PAD_TOP);
        const w = Math.ceil(maxX - minX + WORLD_PAD_X * 2);
        const h = Math.ceil(maxY - minY + WORLD_PAD_TOP + WORLD_PAD_BOTTOM);
        return { x, y, w, h };
    }

    /* ── Cache builders ───────────────────────────────────────── */

    _ensureChromeCache(w, h) {
        const dpr = window.devicePixelRatio || 1;
        const dw = Math.round(w * dpr);
        const dh = Math.round(h * dpr);
        if (!this._chromeDirty
            && this._chromeCanvas
            && this._chromeCanvas.bottom.width  === dw
            && this._chromeCanvas.bottom.height === dh) {
            return;
        }
        this._chromeDirty = false;
        const bottom = document.createElement('canvas');
        bottom.width  = dw;
        bottom.height = dh;
        const top = document.createElement('canvas');
        top.width  = dw;
        top.height = dh;
        // Build at device-pixel resolution so the parchment 1px dots stay
        // crisp on retina, then draw at CSS size with the same dpr scale
        // applied via the live ctx transform.
        const bctx = bottom.getContext('2d');
        const tctx = top.getContext('2d');
        bctx.scale(dpr, dpr);
        tctx.scale(dpr, dpr);
        this._paintBackdrop(bctx, w, h);
        this._paintVignette(tctx, w, h);
        this._chromeCanvas = { bottom, top };
    }

    _ensurePlatformCache() {
        const W = this.tileMap.width, H = this.tileMap.height;
        if (this._platformCanvas
            && this._platformGridW === W
            && this._platformGridH === H) {
            return;
        }
        // Grid size changed (or first build): invalidate every world cache
        // since they all share the same world-coordinate frame.
        this._worldBounds = this._computeWorldBounds();
        this._terrainCanvas = null;
        this._objectsCanvas = null;
        const wb = this._worldBounds;
        const c = document.createElement('canvas');
        c.width  = wb.w;
        c.height = wb.h;
        const ctx = c.getContext('2d');
        ctx.translate(-wb.x, -wb.y);
        this._paintPlatform(ctx);
        this._platformCanvas = c;
        this._platformGridW = W;
        this._platformGridH = H;
    }

    _paintBackdrop(ctx, w, h) {
        // Soft warm sky, brighter to the upper-back-left (where the iso sun
        // sits), fading toward the lower-front for atmosphere.
        const sky = ctx.createLinearGradient(0, 0, 0, h);
        sky.addColorStop(0, 'rgba(255, 247, 224, 0.55)');
        sky.addColorStop(0.55, 'rgba(247, 235, 208, 0.0)');
        sky.addColorStop(1, 'rgba(214, 192, 158, 0.18)');
        ctx.fillStyle = sky;
        ctx.fillRect(0, 0, w, h);

        // Sun bloom: warm radial highlight from the upper-back area.
        const bloom = ctx.createRadialGradient(
            w * 0.70, h * 0.18, 0,
            w * 0.70, h * 0.18, Math.max(w, h) * 0.85,
        );
        bloom.addColorStop(0, 'rgba(255, 232, 188, 0.55)');
        bloom.addColorStop(0.45, 'rgba(255, 232, 188, 0.10)');
        bloom.addColorStop(1, 'rgba(255, 232, 188, 0)');
        ctx.fillStyle = bloom;
        ctx.fillRect(0, 0, w, h);

        // Subtle dotted parchment texture, fades toward the edges.
        // This used to be ~3,600 fillRect calls per frame; now it's done
        // once per resize.
        const cellSize = 24;
        const cx = w / 2, cy = h / 2;
        const maxR = Math.hypot(cx, cy);
        for (let y = 0; y < h; y += cellSize)
        for (let x = 0; x < w; x += cellSize) {
            const r = Math.hypot(x - cx, y - cy) / maxR;
            const a = 0.05 * (1 - r * 0.85);
            if (a <= 0) continue;
            ctx.fillStyle = `rgba(60, 50, 30, ${a.toFixed(3)})`;
            ctx.fillRect(x, y, 1, 1);
        }
    }

    /**
     * Paint the platform (drop shadows + cream slab + back-edge highlight)
     * into a context already aligned to world coordinates. Cached once per
     * grid size; the camera transform applied at draw time scales / pans
     * the result naturally, so pan & zoom never re-trigger this work.
     */
    _paintPlatform(ctx) {
        const gw = this.tileMap.width, gh = this.tileMap.height;
        const corners = [
            cellToScreen(0, 0),
            cellToScreen(gw, 0),
            cellToScreen(gw, gh),
            cellToScreen(0, gh),
        ];

        const tracePlatform = () => {
            ctx.beginPath();
            ctx.moveTo(corners[0].x, corners[0].y);
            for (let i = 1; i < 4; i++) ctx.lineTo(corners[i].x, corners[i].y);
            ctx.closePath();
        };

        // Soft outer glow – multiple progressively darker offsets fake a
        // proper blurred drop shadow even when ctx.filter is unsupported.
        // Blur values are in world pixels here; the camera scales them
        // visually at draw time, which gives a free zoom-correct shadow.
        const passes = [
            { dx:  0, dy: 36, blur: 28, alpha: 0.10 },
            { dx:  4, dy: 24, blur: 14, alpha: 0.12 },
            { dx:  2, dy: 12, blur:  6, alpha: 0.14 },
        ];
        const supportsFilter = typeof ctx.filter === 'string';
        for (const p of passes) {
            ctx.save();
            if (supportsFilter) ctx.filter = `blur(${p.blur}px)`;
            ctx.translate(p.dx, p.dy);
            tracePlatform();
            ctx.fillStyle = `rgba(40, 28, 10, ${p.alpha})`;
            ctx.fill();
            ctx.restore();
        }

        tracePlatform();
        const base = ctx.createLinearGradient(
            corners[0].x, corners[0].y,
            corners[2].x, corners[2].y,
        );
        base.addColorStop(0, 'rgba(252, 245, 226, 0.85)');
        base.addColorStop(1, 'rgba(231, 217, 188, 0.85)');
        ctx.fillStyle = base;
        ctx.fill();

        ctx.beginPath();
        ctx.moveTo(corners[3].x, corners[3].y);
        ctx.lineTo(corners[0].x, corners[0].y);
        ctx.lineTo(corners[1].x, corners[1].y);
        ctx.lineWidth = 1.5;
        ctx.strokeStyle = 'rgba(255, 248, 226, 0.55)';
        ctx.stroke();
    }

    _paintVignette(ctx, w, h) {
        const grad = ctx.createRadialGradient(
            w / 2, h * 0.55, Math.min(w, h) * 0.35,
            w / 2, h * 0.55, Math.max(w, h) * 0.85,
        );
        grad.addColorStop(0, 'rgba(0, 0, 0, 0)');
        grad.addColorStop(0.7, 'rgba(40, 28, 10, 0.05)');
        grad.addColorStop(1, 'rgba(40, 28, 10, 0.20)');
        ctx.fillStyle = grad;
        ctx.fillRect(0, 0, w, h);
    }

    /**
     * Force the screen-space chrome (backdrop + vignette) to be repainted
     * on the next frame. Called by `resize()` automatically; exposed for
     * any future caller that needs to invalidate it explicitly.
     */
    markChromeDirty() {
        this._chromeDirty = true;
        this.markDirty();
    }

    /* ── Terrain cache ────────────────────────────────────────── */

    _ensureTerrainCache() {
        if (this._terrainCanvas && this._terrainVersion === this.tileMap.terrainVersion) {
            return;
        }
        if (!this._worldBounds) this._worldBounds = this._computeWorldBounds();
        const wb = this._worldBounds;
        const cw = wb.w * CACHE_SCALE;
        const ch = wb.h * CACHE_SCALE;
        if (!this._terrainCanvas
            || this._terrainCanvas.width  !== cw
            || this._terrainCanvas.height !== ch) {
            const c = document.createElement('canvas');
            c.width  = cw;
            c.height = ch;
            this._terrainCanvas = c;
        }
        const ctx = this._terrainCanvas.getContext('2d');
        // Cache builds run only on actual content changes (placement /
        // erase / load), so we pay the 'high' smoothing cost once and
        // bank crisp pixels for every subsequent frame.
        ctx.imageSmoothingEnabled = true;
        ctx.imageSmoothingQuality = 'high';
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, cw, ch);
        // Pre-scale to CACHE_SCALE so the rest of the build can use plain
        // world coordinates; the stored pixels end up at high-DPI density.
        ctx.scale(CACHE_SCALE, CACHE_SCALE);
        ctx.translate(-wb.x, -wb.y);

        for (let gy = 0; gy < this.tileMap.height; gy++)
        for (let gx = 0; gx < this.tileMap.width; gx++) {
            const id = this.tileMap.getTerrain(gx, gy);
            if (!id) continue;
            // Skip cells that are mid-animation — the live overlay draws
            // the elastic-scaled version in their place. Without this
            // skip the baked tile shows underneath the overlay and the
            // pop animation looks like a faint ghost.
            if (this._animTerrainKeys.has(`${gx},${gy}`)) continue;
            const asset = getAsset(id);
            if (!asset) continue;
            const { x, y } = cellToScreen(gx, gy);
            const dx = x - asset.anchorX;
            const dy = y - asset.anchorY;
            const src = asset.displayCanvas || asset.canvas;
            ctx.drawImage(src, dx, dy, asset.width, asset.height);
        }

        ctx.setTransform(1, 0, 0, 1, 0, 0);
        this._terrainVersion = this.tileMap.terrainVersion;
    }

    /* ── Static-objects cache (objects + their cast shadows) ──── */

    _ensureObjectsCache() {
        const tm = this.tileMap;
        if (this._objectsCanvas
            && this._objectsVersion === tm.objectsVersion
            && this._objectsAnimCount === this._animObjectIds.size) {
            return;
        }
        if (!this._worldBounds) this._worldBounds = this._computeWorldBounds();
        const wb = this._worldBounds;
        const cw = wb.w * CACHE_SCALE;
        const ch = wb.h * CACHE_SCALE;
        if (!this._objectsCanvas
            || this._objectsCanvas.width  !== cw
            || this._objectsCanvas.height !== ch) {
            const c = document.createElement('canvas');
            c.width  = cw;
            c.height = ch;
            this._objectsCanvas = c;
        }
        const ctx = this._objectsCanvas.getContext('2d');
        // Same as the terrain cache — built only on object add/remove,
        // so we use 'high' for permanently crisp pixels in the cache.
        ctx.imageSmoothingEnabled = true;
        ctx.imageSmoothingQuality = 'high';
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, cw, ch);
        // Pre-scale to CACHE_SCALE — see _ensureTerrainCache for why.
        ctx.scale(CACHE_SCALE, CACHE_SCALE);
        ctx.translate(-wb.x, -wb.y);

        // Pass 1: shadows for every static (non-animating) object that
        // casts one.
        ctx.save();
        ctx.globalAlpha = SHADOW_ALPHA;
        for (const obj of tm.objects) {
            if (this._animObjectIds.has(obj.id)) continue;
            const asset = getAsset(obj.assetId);
            if (!this._castsShadow(asset)) continue;
            this._drawShadowFor(ctx, asset, obj.gx, obj.gy, obj.footprint, {
                flipH: obj.flipH,
                flipV: obj.flipV,
            });
        }
        ctx.restore();

        // Pass 2: objects depth-sorted via painter's algorithm.
        const drawables = [];
        for (const obj of tm.objects) {
            if (this._animObjectIds.has(obj.id)) continue;
            drawables.push(obj);
        }
        drawables.sort((a, b) => a.sortKey() - b.sortKey());
        for (const obj of drawables) {
            this._drawStaticObject(ctx, obj);
        }

        ctx.setTransform(1, 0, 0, 1, 0, 0);
        this._objectsVersion = tm.objectsVersion;
        this._objectsAnimCount = this._animObjectIds.size;
    }

    _drawStaticObject(ctx, obj) {
        const asset = getAsset(obj.assetId);
        if (!asset) return;
        const { x, y } = cellToScreen(obj.gx, obj.gy);
        const dx = x - asset.anchorX;
        const dy = y - asset.anchorY;
        this._drawAssetImage(ctx, asset, dx, dy, obj.gx, obj.gy, obj.footprint, {
            flipH: obj.flipH,
            flipV: obj.flipV,
        });
    }

    /* ── Live overlay (animations + hover + preview) ──────────── */

    _drawLiveOverlay() {
        const ctx = this.ctx;
        const items = [];

        // Currently-animating objects + their shadows.
        for (const obj of this.tileMap.objects) {
            if (!this._animObjectIds.has(obj.id)) continue;
            const t = this._animT(`obj-${obj.id}`);
            if (t == null) continue;
            const asset = getAsset(obj.assetId);
            if (!asset) continue;
            // Shadow first (sits below). Drawn at the same depth band.
            if (this._castsShadow(asset)) {
                items.push({
                    key: obj.sortKey() - 0.5,
                    draw: () => {
                        const prev = ctx.globalAlpha;
                        ctx.globalAlpha = prev * SHADOW_ALPHA * Math.min(1, Math.max(0, t * 1.4 - 0.1));
                        this._drawShadowFor(ctx, asset, obj.gx, obj.gy, obj.footprint, {
                            flipH: obj.flipH,
                            flipV: obj.flipV,
                        });
                        ctx.globalAlpha = prev;
                    },
                });
            }
            items.push({
                key: obj.sortKey(),
                draw: () => this._drawAnimatingObject(obj, t),
            });
        }

        // Currently-animating terrain tiles.
        for (const [key, entry] of this._frameAnims) {
            if (!key.startsWith('t-')) continue;
            const cell = entry.cell;
            if (!cell) continue;
            const id = this.tileMap.getTerrain(cell.gx, cell.gy);
            if (!id) continue;
            items.push({
                key: cell.gx + cell.gy - 0.0005,
                draw: () => this._drawAnimatingTile(id, cell.gx, cell.gy, entry.t),
            });
        }

        // Hover highlight + preview.
        if (this.hoverCell) {
            const { gx, gy } = this.hoverCell;
            const previewAsset = this.previewAssetId
                ? ASSET_INDEX[this.previewAssetId]
                : null;
            const fp = previewAsset?.footprint ?? { w: 1, d: 1 };
            items.push({
                key: gx + gy - 0.001,
                draw: () => this._drawHoverTiles(gx, gy, fp),
            });
            const ghostBlocked = this._isAnimAtCell(gx, gy);
            if (previewAsset && previewAsset.kind === 'object' && !this.eraseMode && !ghostBlocked) {
                if (this._castsShadow(getAsset(previewAsset.id))) {
                    items.push({
                        key: (gx + fp.w - 1) + (gy + fp.d - 1) - 0.5,
                        draw: () => this._drawPreviewShadow(previewAsset, gx, gy),
                    });
                }
                items.push({
                    key: (gx + fp.w - 1) + (gy + fp.d - 1) + 0.001,
                    draw: () => this._drawPreviewObject(previewAsset, gx, gy),
                });
            }
            if (previewAsset && previewAsset.kind === 'terrain' && !this.eraseMode && !ghostBlocked) {
                items.push({
                    key: gx + gy + 0.0005,
                    draw: () => this._drawPreviewTerrain(previewAsset, gx, gy),
                });
            }
        }

        if (items.length > 1) items.sort((a, b) => a.key - b.key);
        for (const item of items) item.draw();
    }

    _drawAnimatingObject(obj, t) {
        const ctx = this.ctx;
        const asset = getAsset(obj.assetId);
        if (!asset) return;
        const { x, y } = cellToScreen(obj.gx, obj.gy);
        const dx = x - asset.anchorX;
        const dy = y - asset.anchorY;
        const s = this._easeOutElastic(t);
        const pivot = cellToScreen(obj.gx + obj.footprint.w / 2, obj.gy + obj.footprint.d / 2);
        if (asset.flatBase) {
            pivot.y += (obj.footprint.w + obj.footprint.d) * TH / 4;
        }
        ctx.save();
        ctx.globalAlpha *= Math.min(1, t * 1.6);
        ctx.translate(pivot.x, pivot.y);
        ctx.scale(s, s);
        ctx.translate(-pivot.x, -pivot.y);
        this._drawAssetImage(ctx, asset, dx, dy, obj.gx, obj.gy, obj.footprint, {
            flipH: obj.flipH,
            flipV: obj.flipV,
        });
        ctx.restore();
    }

    _drawAnimatingTile(assetId, gx, gy, t) {
        const ctx = this.ctx;
        const asset = getAsset(assetId);
        if (!asset) return;
        const { x, y } = cellToScreen(gx, gy);
        const dx = x - asset.anchorX;
        const dy = y - asset.anchorY;
        const s = this._easeOutElastic(t);
        const pivot = cellToScreen(gx + 0.5, gy + 0.5);
        ctx.save();
        ctx.globalAlpha *= Math.min(1, t * 1.6);
        ctx.translate(pivot.x, pivot.y);
        ctx.scale(s, s);
        ctx.translate(-pivot.x, -pivot.y);
        const src = asset.displayCanvas || asset.canvas;
        ctx.drawImage(src, dx, dy, asset.width, asset.height);
        ctx.restore();
    }

    _drawPreviewShadow(previewAsset, gx, gy) {
        const ctx = this.ctx;
        const asset = getAsset(previewAsset.id);
        if (!asset) return;
        const prev = ctx.globalAlpha;
        ctx.globalAlpha = prev * SHADOW_ALPHA * (this.previewValid ? 1 : 0.5);
        this._drawShadowFor(ctx, asset, gx, gy, previewAsset.footprint, {
            flipH: this.previewFlipH,
            flipV: this.previewFlipV,
        });
        ctx.globalAlpha = prev;
    }

    /* ── Shadow drawing (uses pre-blurred silhouettes) ────────── */

    _castsShadow(asset) {
        return !!asset
            && !asset.tileLike
            && !asset.noShadow
            && asset.kind !== 'terrain'
            && (asset.shadowStyle === 'contact' || !!asset.shadowCanvas);
    }

    _drawShadowFor(ctx, asset, gx, gy, footprint, flip) {
        if (asset.shadowStyle === 'contact') {
            this._drawContactShadowFor(ctx, asset, gx, gy, footprint, flip);
            return;
        }
        this._drawCastShadowFor(ctx, asset, gx, gy, footprint, flip);
    }

    /**
     * Low props like railings look wrong with a long projected silhouette:
     * it reads as if they are floating. Give them a tight grounding shadow
     * directly below their feet instead.
     */
    _drawContactShadowFor(ctx, asset, gx, gy, footprint, flip = {}) {
        const back = cellToScreen(gx, gy);
        const dx = back.x - asset.anchorX;
        const dy = back.y - asset.anchorY;
        const padW = Math.max(7, asset.width * 0.18);
        const padH = Math.max(4, TH * 0.14);
        const points = asset.contactPoints?.length >= 2
            ? asset.contactPoints
            : [
                { x: asset.width * 0.28, y: asset.height },
                { x: asset.width * 0.72, y: asset.height },
            ];
        const posts = points.map(point => ({
            x: dx + (flip.flipH ? asset.width - point.x : point.x),
            y: dy + (flip.flipV ? asset.height - point.y : point.y),
        }));
        const center = {
            x: (posts[0].x + posts[1].x) / 2,
            y: (posts[0].y + posts[1].y) / 2,
        };
        const bridgeW = Math.hypot(posts[1].x - posts[0].x, posts[1].y - posts[0].y) + padW;
        const bridgeAngle = Math.atan2(posts[1].y - posts[0].y, posts[1].x - posts[0].x);

        ctx.save();
        ctx.fillStyle = 'rgba(35, 25, 10, 1)';
        ctx.save();
        ctx.globalAlpha *= 0.18;
        ctx.beginPath();
        ctx.ellipse(center.x, center.y, bridgeW / 2, padH * 0.45, bridgeAngle, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
        ctx.globalAlpha *= 0.82;
        for (const post of posts) {
            ctx.beginPath();
            ctx.ellipse(post.x, post.y, padW / 2, padH / 2, 0, 0, Math.PI * 2);
            ctx.fill();
        }
        ctx.restore();
    }

    /**
     * Project an asset's silhouette onto the ground plane using a 2D
     * affine transform. Taller pixels project toward the back of the map
     * (up on screen), with only a slight left drift.
     *
     * The silhouette is pre-blurred at asset-load time, so the per-frame
     * cost is one transformed `drawImage` — no `ctx.filter` blur in the
     * render loop at all.
     */
    _drawCastShadowFor(ctx, asset, gx, gy, footprint, flip) {
        const ground = cellToScreen(gx + footprint.w / 2, gy + footprint.d / 2);
        if (asset.flatBase) {
            const halfCellH = (footprint.w + footprint.d) * TH / 4;
            ground.y += halfCellH;
        }
        const ax = asset.width / 2;
        const ay = asset.height;
        const a = 1, b = 0, c = BACK_DRIFT_X, d = BACK_DRIFT_Y;
        const e = ground.x - ax - ay * BACK_DRIFT_X;
        const f = ground.y      - ay * BACK_DRIFT_Y;
        ctx.save();
        ctx.transform(a, b, c, d, e, f);
        if (flip?.flipH || flip?.flipV) {
            ctx.translate(ax, ay);
            ctx.scale(flip.flipH ? -1 : 1, flip.flipV ? -1 : 1);
            ctx.translate(-ax, -ay);
        }
        const pad = asset.shadowPadding || 0;
        ctx.drawImage(
            asset.shadowCanvas,
            -pad, -pad,
            asset.width + pad * 2,
            asset.height + pad * 2,
        );
        ctx.restore();
    }

    /* ── Preview drawing ──────────────────────────────────────── */

    _drawPreviewObject(previewAsset, gx, gy) {
        const ctx = this.ctx;
        const asset = getAsset(previewAsset.id);
        if (!asset) return;
        const { x, y } = cellToScreen(gx, gy);
        const dx = x - asset.anchorX;
        const dy = y - asset.anchorY;
        ctx.save();
        ctx.globalAlpha = this.previewValid ? 0.32 : 0.22;
        this._drawAssetImage(ctx, asset, dx, dy, gx, gy, previewAsset.footprint, {
            flipH: this.previewFlipH,
            flipV: this.previewFlipV,
        });
        ctx.restore();
    }

    _drawPreviewTerrain(previewAsset, gx, gy) {
        const ctx = this.ctx;
        const asset = getAsset(previewAsset.id);
        if (!asset) return;
        const { x, y } = cellToScreen(gx, gy);
        const dx = x - asset.anchorX;
        const dy = y - asset.anchorY;
        const src = asset.displayCanvas || asset.canvas;
        ctx.save();
        ctx.globalAlpha = 0.38;
        ctx.drawImage(src, dx, dy, asset.width, asset.height);
        ctx.restore();
    }

    /**
     * Draw an asset image into `ctx`, optionally mirrored horizontally /
     * vertically around the screen centre of its footprint diamond. Used
     * by both the static-objects cache builder and the live overlay so
     * the same flip logic applies in both passes.
     */
    _drawAssetImage(ctx, asset, dx, dy, gx, gy, footprint = { w: 1, d: 1 }, flip = {}) {
        const flipH = flip.flipH === true;
        const flipV = flip.flipV === true;
        const src = asset.displayCanvas || asset.canvas;
        if (!flipH && !flipV) {
            ctx.drawImage(src, dx, dy, asset.width, asset.height);
            return;
        }
        const pivot = cellToScreen(gx + footprint.w / 2, gy + footprint.d / 2);
        ctx.save();
        ctx.translate(pivot.x, pivot.y);
        ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
        ctx.translate(-pivot.x, -pivot.y);
        ctx.drawImage(src, dx, dy, asset.width, asset.height);
        ctx.restore();
    }

    /* ── Grid + hover ─────────────────────────────────────────── */

    _drawGrid() {
        const ctx = this.ctx;
        ctx.save();
        ctx.lineWidth = 1 / this.camera.zoom;
        ctx.strokeStyle = 'rgba(60, 50, 30, 0.18)';
        ctx.beginPath();
        for (let g = 0; g <= this.tileMap.width; g++) {
            const a = cellToScreen(g, 0);
            const b = cellToScreen(g, this.tileMap.height);
            ctx.moveTo(a.x, a.y);
            ctx.lineTo(b.x, b.y);
        }
        for (let g = 0; g <= this.tileMap.height; g++) {
            const a = cellToScreen(0, g);
            const b = cellToScreen(this.tileMap.width, g);
            ctx.moveTo(a.x, a.y);
            ctx.lineTo(b.x, b.y);
        }
        ctx.stroke();
        ctx.restore();
    }

    /** Draw highlighted footprint cells under the cursor. */
    _drawHoverTiles(gx, gy, footprint) {
        const ctx = this.ctx;
        ctx.save();
        const valid = this.previewValid;
        const stroke = this.eraseMode
            ? 'rgba(216, 91, 142, 1)'
            : (valid ? 'rgba(27, 91, 168, 1)' : 'rgba(216, 91, 91, 1)');
        const fill = this.eraseMode
            ? 'rgba(216, 91, 142, 0.18)'
            : (valid ? 'rgba(27, 91, 168, 0.16)' : 'rgba(216, 91, 91, 0.16)');

        ctx.lineWidth = 2 / this.camera.zoom;
        ctx.strokeStyle = stroke;
        ctx.fillStyle = fill;

        for (let ix = 0; ix < footprint.w; ix++)
        for (let iy = 0; iy < footprint.d; iy++) {
            const cx = gx + ix;
            const cy = gy + iy;
            if (!this.tileMap.inBounds(cx, cy)) continue;
            const a = cellToScreen(cx, cy);
            const b = cellToScreen(cx + 1, cy);
            const c = cellToScreen(cx + 1, cy + 1);
            const d = cellToScreen(cx, cy + 1);
            ctx.beginPath();
            ctx.moveTo(a.x, a.y);
            ctx.lineTo(b.x, b.y);
            ctx.lineTo(c.x, c.y);
            ctx.lineTo(d.x, d.y);
            ctx.closePath();
            ctx.fill();
            ctx.stroke();
        }
        ctx.restore();
    }
}