5180 fichiers 38.5 Mo 0 téléchargements 479 vues mis à jour il y a 7 heures ui
SnowWarArenaView.tsx 132.2 Ko Télécharger le fichier
import { CSSProperties, FC, MouseEvent, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
    getCountdownFrame,
    GetConfigurationValue,
    getExplosionAssetName,
    getLoopFrame,
    getPingPongFrame,
    getScoreFlashFrame,
    GetSessionDataManager,
    LocalizeText,
    SNOWWAR_EXPLOSION_OFFSETS
} from '../../../../api';
import { SNOWWAR_AIR_ASSETS } from '../../../../assets/images/snowstorm/air';
import {
    findSnowWarOfficialBackdrop,
    INITIAL_SNOWBALL_COUNT,
    LONG_LOB_MAX_RANGE_WORLD,
    QUICK_THROW_MAX_RANGE_WORLD,
    SHORT_LOB_MAX_RANGE_WORLD,
    SNOWWAR_SNOWBALL_IMAGE,
    SNOWWAR_SNOWBALL_SHADOW,
    SNOWWAR_SPLASH_FRAMES,
    SNOWWAR_STATE_CREATING,
    SNOWWAR_STATE_INVINCIBLE,
    SNOWWAR_STATE_STUNNED,
    STUN_TIME,
    SUBTURN_MS,
    TILE_SIZE_WORLD,
    TRAJECTORY_DEFAULT,
    TRAJECTORY_LONG_LOB,
    TRAJECTORY_QUICK,
    TRAJECTORY_SHORT_LOB,
    layoutSnowWarOfficialBackdrop,
    tileToWorld,
} from '../../../../api/snowwar';
import snowballPileImage from '../../../../assets/images/snowstorm/original/snst_ballpile.png';
import { LayoutAvatarImageView, LayoutFurniImageView } from '../../../../common';
import { useNotification, useSnowWar } from '../../../../hooks';
import { FloorplanEditorView } from '../../../floorplan-editor/FloorplanEditorView';
import { SnowWarAvatarView } from './SnowWarAvatarView';
import { SnowWarStrokeText } from './SnowWarStrokeText';
import {
    canPlaceSnowWarEditorItem,
    getSnowWarEditorArea,
    isSnowWarFloorTile,
    SnowWarEditorTile,
} from './SnowWarEditorTools';
import { Checkbox } from '../../../../common/Checkbox';
import { BaseButton } from '../../../../common/BaseButton';
import { Hint } from '../../../../common/Tooltip';
import { isTextEntryTarget } from '../../../../hooks/rooms/useRoomKeyboardMovement';
import { NativeTextTree } from '../../../../common/native-text/NativeTextTree';

const localizeWithFallback = (key: string, fallback: string) =>
{
    const text = LocalizeText(key);
    return text && text !== key ? text : fallback;
};

// RoomDesktop creates every AIR game session at RoomEngine scale 32 (normal
// rooms use 64). Keep the DOM layer at 2x for crisp pixel rendering, but author
// every world coordinate and sprite at the matching half-size underneath it.
const AIR_GAME_CANVAS_SCALE = 32;
const ZOOM = 2;
const TILE_HALF_W = AIR_GAME_CANVAS_SCALE / (2 * ZOOM);
const TILE_HALF_H = TILE_HALF_W / 2;

// Furni images come from getFurnitureFloorImage at geometry scale 64, i.e.
// authored for a 64px-wide tile (half-width 32). The SnowWar tile is only
// TILE_HALF_W*2 wide, so this scale makes one furni tile exactly cover one
// arena tile - a 2x2 grid furni then lines up with the 2x2 floor tiles instead
// of overhanging them.
const SNOWWAR_FURNI_SCALE = TILE_HALF_W / 32;
// SnowWar's embedded game sprites are already authored for the 32px game
// canvas, unlike normal RoomEngine furniture and LARGE avatar renders (64px).
const SNOWWAR_GAME_SPRITE_SCALE = 1 / ZOOM;

// Duckie's water tiles are an additive editor feature. The original
// block_basic/block_ice suffixes describe progressively taller one-tile
// obstacles, not wider floor footprints.
const CUSTOM_FLOOR_SIZES: Record<string, { w: number; l: number }> = {
    block_water1: { w: 1, l: 1 },
    block_water2: { w: 2, l: 2 },
    block_water3: { w: 3, l: 3 },
};

// Draws one flat isometric floor tile (diamond) with its top vertex at (tsx,tsy).
const drawFloorDiamond = (context: CanvasRenderingContext2D, tsx: number, tsy: number, kind: 'stone' | 'ice' | 'water'): void =>
{
    const midY = tsy + TILE_HALF_H;
    const bottomY = tsy + (TILE_HALF_H * 2);

    context.beginPath();
    context.moveTo(tsx, tsy);
    context.lineTo(tsx + TILE_HALF_W, midY);
    context.lineTo(tsx, bottomY);
    context.lineTo(tsx - TILE_HALF_W, midY);
    context.closePath();

    if (kind === 'water')
    {
        // Deep-to-light vertical gradient so the tile reads as water with depth.
        const gradient = context.createLinearGradient(tsx, tsy, tsx, bottomY);
        gradient.addColorStop(0, '#7cc3ec');
        gradient.addColorStop(1, '#2f7cb8');
        context.fillStyle = gradient;
    }
    else
    {
        context.fillStyle = kind === 'ice' ? '#bfe6f7' : '#c7cfd6';
    }
    context.fill();
    context.strokeStyle = kind === 'ice' ? '#7fbfe0' : kind === 'water' ? '#276fa8' : '#9aa5b0';
    context.lineWidth = 1;
    context.stroke();

    if (kind === 'ice')
    {
        // Glossy highlight: a bright facet in the upper-left of the tile.
        context.fillStyle = 'rgba(255, 255, 255, 0.55)';
        context.beginPath();
        context.moveTo(tsx, tsy + 2);
        context.lineTo(tsx - TILE_HALF_W * 0.55, tsy + TILE_HALF_H * 0.7);
        context.lineTo(tsx, tsy + TILE_HALF_H);
        context.lineTo(tsx + TILE_HALF_W * 0.3, tsy + TILE_HALF_H * 0.55);
        context.closePath();
        context.fill();
    }
    else if (kind === 'water')
    {
        // Two short wavy ripples across the middle, kept well inside the diamond.
        context.strokeStyle = 'rgba(226, 246, 255, 0.75)';
        context.lineWidth = 1;
        for (const dy of [-2.5, 2])
        {
            context.beginPath();
            context.moveTo(tsx - 6, midY + dy);
            context.quadraticCurveTo(tsx - 3, midY + dy - 1.4, tsx, midY + dy);
            context.quadraticCurveTo(tsx + 3, midY + dy + 1.4, tsx + 6, midY + dy);
            context.stroke();
        }
    }
};

interface OfficialSnowWarSprite
{
    image: string;
    shadow?: string;
}

// Original hh_game_snowwar_room artwork. Comet itself resolves these classnames
// through RoomEngine; it never paints substitute trees or snowmen.
const OFFICIAL_SNOWWAR_SPRITES: Record<string, OfficialSnowWarSprite> = {
    sw_tree1: { image: 'sw_tree1.png', shadow: 'sw_tree1_shadow.png' },
    sw_tree2: { image: 'sw_tree2.png', shadow: 'sw_tree2_shadow.png' },
    sw_tree3: { image: 'sw_tree3.png', shadow: 'sw_tree3_shadow.png' },
    sw_tree4: { image: 'sw_tree4.png', shadow: 'sw_tree4_shadow.png' },
    obst_snowman: { image: 'obst_snowman.png' },
    obst_duck: { image: 'obst_duck.png' },
    block_basic: { image: 'block_basic.png' },
    block_basic2: { image: 'block_basic2.png' },
    block_basic3: { image: 'block_basic3.png' },
    block_ice: { image: 'block_ice.png' },
    block_ice2: { image: 'block_ice2.png' },
    block_small: { image: 'block_small.png' },
    // The a/b arch variants are distinct official pieces (different collision
    // heights server-side), not rotations of one classname.
    block_arch1: { image: 'block_arch1.png' },
    block_arch2: { image: 'block_arch2.png' },
    block_arch3: { image: 'block_arch3.png' },
    block_arch1b: { image: 'block_arch1b.png' },
    block_arch2b: { image: 'block_arch2b.png' },
    block_arch3b: { image: 'block_arch3b.png' },
};

const getArenaPropAssetUrl = (fileName: string): string =>
{
    const imageLibraryUrl = GetConfigurationValue<string>('image.library.url', '');
    const baseUrl = imageLibraryUrl && !imageLibraryUrl.endsWith('/') ? `${imageLibraryUrl}/` : imageLibraryUrl;
    return `${baseUrl}snowstorm_client/arena_props/${fileName}?v=official-2`;
};

const getOfficialSnowWarAssetUrl = (fileName: string): string =>
{
    const imageLibraryUrl = GetConfigurationValue<string>('image.library.url', '');
    const baseUrl = imageLibraryUrl && !imageLibraryUrl.endsWith('/') ? `${imageLibraryUrl}/` : imageLibraryUrl;
    return `${baseUrl}snowstorm_client/official/${fileName}?v=air-room-2`;
};

// HUD bitmaps are bundled (AIR habbo-games-com); the image library copy is
// only a fallback for a name that is not bundled.
const getHudAssetUrl = (name: string): string => SNOWWAR_AIR_ASSETS[name] ?? getOfficialSnowWarAssetUrl(`${ name }.png`);

const getOfficialSnowWarSprite = (name: string, rotation: number): OfficialSnowWarSprite | null =>
{
    if (name === 'sw_fence' || name === 'sw_fence2')
    {
        const alternate = (rotation === 2 || rotation === 6) !== (name === 'sw_fence2');
        return { image: alternate ? 'sw_fence_dir2.png' : 'sw_fence_dir4.png' };
    }
    return OFFICIAL_SNOWWAR_SPRITES[name] ?? null;
};

const loadSnowWarImage = (url: string): Promise<HTMLImageElement> => new Promise((resolve, reject) =>
{
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = () => reject(new Error(`Unable to load SnowStorm asset: ${url}`));
    image.src = url;
});

// Draws Duckie's additive water tiles and a neutral fallback for newer editor
// pieces that do not exist in the archived original asset library.
const drawCustomProp = (context: CanvasRenderingContext2D, name: string, sx: number, sy: number, _rotation = 0): void =>
{
    const centerY = sy + TILE_HALF_H;

    const size = CUSTOM_FLOOR_SIZES[name];
    if (size)
    {
        const kind = name.includes('ice') ? 'ice' : name.includes('water') ? 'water' : 'stone';
        for (let dx = 0; dx < size.w; dx++)
        {
            for (let dy = 0; dy < size.l; dy++)
            {
                const tsx = sx + ((dx - dy) * TILE_HALF_W);
                const tsy = sy + ((dx + dy) * TILE_HALF_H);
                drawFloorDiamond(context, tsx, tsy, kind);
            }
        }
        return;
    }

    // Any other block / fence / obstacle: raised cube.
    const height = name.includes('3') ? 26 : name.includes('2') ? 18 : 10;
    const isIce = name.includes('ice');
    context.beginPath();
    context.moveTo(sx, centerY - height - TILE_HALF_H);
    context.lineTo(sx + TILE_HALF_W, centerY - height);
    context.lineTo(sx + TILE_HALF_W, centerY);
    context.lineTo(sx, centerY + TILE_HALF_H);
    context.lineTo(sx - TILE_HALF_W, centerY);
    context.lineTo(sx - TILE_HALF_W, centerY - height);
    context.closePath();
    context.fillStyle = isIce ? '#bfe3f5' : '#cfd6dd';
    context.fill();
    context.strokeStyle = isIce ? '#8fc3de' : '#9aa5b0';
    context.stroke();
};

const isFlatFloorProp = (name: string) => !!CUSTOM_FLOOR_SIZES[name];

// v26 sprites use a ~30px isometric tile; the arena's authored tile is 24px.
const OFFICIAL_SNOWWAR_SPRITE_SCALE = (TILE_HALF_W * 2) / 30;
const PROP_BOX_W = 64;
const PROP_BOX_H = 112;
const PROP_ANCHOR_Y = 100;

// Paint the original bitmap and its original shadow into a per-tile canvas.
const ClassicPropSprite: FC<{ name: string; rotation: number; opacity?: number }> = ({ name, rotation, opacity }) =>
{
    const ref = useRef<HTMLCanvasElement>(null);
    useEffect(() =>
    {
        const context = ref.current?.getContext('2d');
        if (!context) return;

        let cancelled = false;
        const fallback = () =>
        {
            context.clearRect(0, 0, PROP_BOX_W, PROP_BOX_H);
            drawCustomProp(context, name, PROP_BOX_W / 2, PROP_ANCHOR_Y, rotation);
        };
        const sprite = getOfficialSnowWarSprite(name, rotation);
        if (!sprite)
        {
            fallback();
            return;
        }

        const render = async () =>
        {
            try
            {
                const [image, shadow] = await Promise.all([
                    loadSnowWarImage(getArenaPropAssetUrl(sprite.image)),
                    sprite.shadow ? loadSnowWarImage(getArenaPropAssetUrl(sprite.shadow)) : Promise.resolve(null),
                ]);
                if (cancelled) return;

                context.clearRect(0, 0, PROP_BOX_W, PROP_BOX_H);
                context.imageSmoothingEnabled = false;
                const baseY = PROP_ANCHOR_Y + TILE_HALF_H;
                if (shadow)
                {
                    const shadowWidth = Math.round(shadow.naturalWidth * OFFICIAL_SNOWWAR_SPRITE_SCALE);
                    const shadowHeight = Math.round(shadow.naturalHeight * OFFICIAL_SNOWWAR_SPRITE_SCALE);
                    context.drawImage(
                        shadow,
                        Math.round((PROP_BOX_W - shadowWidth) / 2),
                        Math.round(baseY + 4 - shadowHeight),
                        shadowWidth,
                        shadowHeight);
                }

                const width = Math.round(image.naturalWidth * OFFICIAL_SNOWWAR_SPRITE_SCALE);
                const height = Math.round(image.naturalHeight * OFFICIAL_SNOWWAR_SPRITE_SCALE);
                context.drawImage(
                    image,
                    Math.round((PROP_BOX_W - width) / 2),
                    Math.round(baseY - height),
                    width,
                    height);
            }
            catch
            {
                if (!cancelled) fallback();
            }
        };
        void render();
        return () =>
        {
            cancelled = true;
        };
    }, [name, rotation]);
    return <canvas ref={ref} width={PROP_BOX_W} height={PROP_BOX_H} className="snowwar-classic-prop-canvas" style={opacity != null ? { opacity } : undefined} />;
};

const TEAM_COLORS = ['#e64545', '#4577e6', '#3fb550', '#e6c245'];

// How long (ms) an avatar holds the SnowWarThrow arm-out pose after throwing.
const SNOWWAR_THROW_POSE_MS = 450;
// Official AIR snowball height rendering: the simulated z (world units) maps
// linearly to screen pixels exactly like room-engine z — roomZ = worldZ / 1600
// (Tile.TILE_HALFWIDTH), drawn at 16px per z unit on the 32px game canvas.
// That is worldZ / 100 screen px here. No per-trajectory baselines or caps: a
// resting ball sits at z=3000 (hand height), quick throws fly flat there and
// lobs follow the full parabola. The splash shares the mapping so flight and
// impact never drift apart.
const SNOWWAR_WORLD_Z_TO_PX = (TILE_HALF_W * ZOOM) / 1600;
const snowballRise = (height: number): number => height * SNOWWAR_WORLD_Z_TO_PX;

// Design base: the arena is authored for a 1920x1080 stage. Larger screens
// centre this stage; smaller screens cap the viewport to the screen and follow
// the player.
const DESIGN_W = 1920;
const DESIGN_H = 1080;

// The official room-ad backdrops (Arctic Island, Dragon Top, Fight Night) are
// fitted from SNOWWAR_OFFICIAL_BACKDROPS (api/snowwar/SnowWarBackdrops.ts): each
// is drawn at the size whose painted tiles are 32px and pinned to its own tile
// anchor. Their painted fields are off-centre in the artwork, so the stage is
// built around the artwork instead of centring a 1920x1080 stage on the floor -
// that left empty strips at the edges.

// Camera dead zone: the avatar roams the central (1 - 2*DEADZONE) of the
// viewport with the camera held still; only when it pushes into the outer
// DEADZONE band near an edge does the camera ease back to re-centre it. This
// keeps the background static most of the time (no per-step scroll, so jitter
// stays invisible) and only follows when the avatar is about to leave the view.
const CAMERA_DEADZONE = 0.2;
const CAMERA_EASE = 0.15;

interface EditItem { name: string; x: number; y: number; rotation: number; imageUrl: string; offsetZ: number; width?: number; length?: number; state: number; stateCount?: number; walkableHeight?: number }

const SNOWWAR_EDITOR_MAX_ITEMS = 2048;

interface EditorGesture
{
    mode: 'paint' | 'area';
    start: SnowWarEditorTile;
    end: SnowWarEditorTile;
}

// Editor preview walker: a purely client-side avatar you can stroll around the
// arena while editing (no server / game simulation involved), to test the
// layout. It steps one tile every SNOWWAR_EDITOR_STEP_MS.
const SNOWWAR_EDITOR_STEP_MS = 260;

interface EditorWalk { path: { x: number; y: number }[]; startMs: number; endDir: number }

// Tile delta -> Habbo 8-direction (0 N .. 7 NW; +x = E, +y = S on the grid).
const tileDirection = (dx: number, dy: number): number =>
{
    const sx = Math.sign(dx);
    const sy = Math.sign(dy);
    if (sx === 0 && sy > 0) return 4;
    if (sx > 0 && sy > 0) return 3;
    if (sx > 0 && sy === 0) return 2;
    if (sx > 0 && sy < 0) return 1;
    if (sx === 0 && sy < 0) return 0;
    if (sx < 0 && sy < 0) return 7;
    if (sx < 0 && sy === 0) return 6;
    if (sx < 0 && sy > 0) return 5;
    return 4;
};

// Breadth-first shortest walk over every non-void floor-plan height, 8-way but
// never cutting a diagonal through a void corner. Returns the tile path
// (including the start) or null when the target is void / unreachable.
const findEditorPath = (rows: string[], from: { x: number; y: number }, to: { x: number; y: number }, width: number, height: number, blocked?: Set<number>): { x: number; y: number }[] | null =>
{
    const walkable = (x: number, y: number) => x >= 0 && y >= 0 && x < width && y < height && isSnowWarFloorTile(rows[y]?.charAt(x)) && !(blocked?.has((y * width) + x));
    if (!walkable(from.x, from.y) || !walkable(to.x, to.y)) return null;
    if (from.x === to.x && from.y === to.y) return [from];

    const key = (x: number, y: number) => (y * width) + x;
    const prev = new Map<number, number>();
    const seen = new Set<number>([key(from.x, from.y)]);
    const queue: { x: number; y: number }[] = [from];
    const steps = [
        { dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 },
        { dx: 1, dy: 1 }, { dx: 1, dy: -1 }, { dx: -1, dy: 1 }, { dx: -1, dy: -1 },
    ];

    while (queue.length)
    {
        const cur = queue.shift();
        if (cur.x === to.x && cur.y === to.y)
        {
            const path: { x: number; y: number }[] = [];
            let cx = cur.x;
            let cy = cur.y;
            let k = key(cx, cy);
            for (;;)
            {
                path.unshift({ x: cx, y: cy });
                if (cx === from.x && cy === from.y) break;
                k = prev.get(k);
                cx = k % width;
                cy = Math.floor(k / width);
            }
            return path;
        }
        for (const s of steps)
        {
            const nx = cur.x + s.dx;
            const ny = cur.y + s.dy;
            if (!walkable(nx, ny)) continue;
            // No diagonal corner-cutting: both orthogonal neighbours must be open.
            if (s.dx !== 0 && s.dy !== 0 && (!walkable(cur.x + s.dx, cur.y) || !walkable(cur.x, cur.y + s.dy))) continue;
            const nk = key(nx, ny);
            if (seen.has(nk)) continue;
            seen.add(nk);
            prev.set(nk, key(cur.x, cur.y));
            queue.push({ x: nx, y: ny });
        }
    }
    return null;
};

// Placeable classnames for the in-arena editor, mirroring the server's
// SnowWarItemProperties registry.
const EDITOR_PALETTE = [
    'sw_tree1', 'sw_tree2', 'sw_tree3', 'sw_tree4',
    'block_basic', 'block_basic2', 'block_basic3', 'block_small',
    'block_ice', 'block_ice2',
    'block_arch1', 'block_arch2', 'block_arch3',
    'block_arch1b', 'block_arch2b', 'block_arch3b',
    'block_water1', 'block_water2', 'block_water3',
    'obst_snowman', 'obst_duck', 'sw_fence', 'sw_fence2',
];

/** AIR projectile caps, expressed in world coordinates like the emulator. */
const isThrowInRange = (fromX: number, fromY: number, toX: number, toY: number, trajectory: number) =>
{
    const maxRange = trajectory === TRAJECTORY_QUICK
        ? QUICK_THROW_MAX_RANGE_WORLD
        : trajectory === TRAJECTORY_SHORT_LOB
            ? SHORT_LOB_MAX_RANGE_WORLD
            : LONG_LOB_MAX_RANGE_WORLD;
    const dx = toX - fromX;
    const dy = toY - fromY;
    return ((dx * dx) + (dy * dy)) <= (maxRange * maxRange);
};

/** Original AIR modifier mapping; opponent clicks default to adaptive aim. */
const getAirTrajectory = (altKey: boolean, shiftKey: boolean, opponent: boolean): number | null =>
{
    if (altKey && shiftKey) return TRAJECTORY_SHORT_LOB;
    if (altKey) return TRAJECTORY_LONG_LOB;
    if (shiftKey) return TRAJECTORY_QUICK;
    return opponent ? TRAJECTORY_DEFAULT : null;
};

/** Exact isometric direction mapping from AIR's KeyboardControl. */
const getKeyboardDirection = (keys: Set<string>): { x: number; y: number } | null =>
{
    if (keys.has('ArrowUp') && keys.has('ArrowLeft')) return { x: -1, y: 0 };
    if (keys.has('ArrowUp') && keys.has('ArrowRight')) return { x: 0, y: -1 };
    if (keys.has('ArrowDown') && keys.has('ArrowLeft')) return { x: 0, y: 1 };
    if (keys.has('ArrowDown') && keys.has('ArrowRight')) return { x: 1, y: 0 };
    if (keys.has('ArrowUp')) return { x: -1, y: -1 };
    if (keys.has('ArrowDown')) return { x: 1, y: 1 };
    if (keys.has('ArrowLeft')) return { x: -1, y: 1 };
    if (keys.has('ArrowRight')) return { x: 1, y: -1 };

    const letterDirections: [string, number, number][] = [
        ['KeyQ', -1, 0], ['KeyW', -1, -1], ['KeyE', 0, -1],
        ['KeyA', -1, 1], ['KeyD', 1, -1],
        ['KeyZ', 0, 1], ['KeyX', 1, 1], ['KeyC', 1, 0],
    ];
    for (const [code, x, y] of letterDirections)
    {
        if (keys.has(code)) return { x, y };
    }
    return null;
};

/** Classic SnowWar props drawn as canvas shapes; everything else is furni. */
const isClassicItem = (name: string) =>
    name.startsWith('sw_') || name.startsWith('block_') || name.startsWith('obst_') || name.startsWith('snowball_machine');

export const SnowWarArenaView: FC = () =>
{
    const {
        phase,
        levelData,
        secondsLeft,
        preparingSeconds,
        preparingEndsAt,
        chatMessages,
        simulation,
        editing,
        walkTo,
        throwAtLocation,
        throwAtPlayer,
        createSnowball,
        exitGame,
        startEditing,
        saveArena,
        stopEditing,
        sendChat,
        requestFullStatus,
    } = useSnowWar();
    const { showConfirm = null } = useNotification();

    // Official client asks before leaving a running match (losing it and the
    // short block on new games are explained in the dialog).
    const confirmExitGame = useCallback(() =>
    {
        if(!showConfirm) return exitGame();

        showConfirm(
            localizeWithFallback('snowwar.exit.confirmation', 'If you leave the game you lose and get no points.'),
            () => exitGame(),
            null,
            localizeWithFallback('snowwar.exit.yes', 'Leave game'),
            localizeWithFallback('snowwar.exit.no', 'Cancel'),
            localizeWithFallback('snowwar.exit.title', 'Leave game?')
        );
    }, [ showConfirm, exitGame ]);

    const canvasRef = useRef<HTMLCanvasElement>(null);
    // Classic props (trees/blocks/fences/floor tiles) draw on their own canvas
    // ABOVE the ad backdrop overlay, so "hide tiles behind image" only hides the
    // floor grid - the props stay visible, like the hotel furni.
    const propsCanvasRef = useRef<HTMLCanvasElement>(null);
    const viewportRef = useRef<HTMLDivElement>(null);
    // Dead-zone follow camera: persisted translate + per-axis "recentring"
    // latch. Advanced at most once per animation frame (see the camera block).
    const cameraRef = useRef({ x: 0, y: 0, frame: -1, recenterX: false, recenterY: false, initialized: false });
    // Wall-clock of the last animation frame; doubles as the re-render tick.
    const [frameNow, setFrameNow] = useState(0);
    const [chatInput, setChatInput] = useState('');
    const chatInputRef = useRef<HTMLInputElement>(null);

    // Enter starts typing a chat line; other keys stay with the arena (movement).
    useEffect(() =>
    {
        if (editing) return;

        const onKeyDown = (event: KeyboardEvent) =>
        {
            if ((event.key !== 'Enter') && (event.key !== 'NumpadEnter')) return;

            const target = event.target as HTMLElement | null;

            if (isTextEntryTarget(target)) return;

            event.preventDefault();
            chatInputRef.current?.focus();
        };

        window.addEventListener('keydown', onKeyDown);

        return () => window.removeEventListener('keydown', onKeyDown);
    }, [ editing ]);
    const zoom = ZOOM;
    // Loaded height / width per official backdrop file, and the files whose
    // image-library copy failed (those fall back to the URL the server sent).
    const [backdropRatios, setBackdropRatios] = useState<Record<string, number>>({});
    const [backdropLibraryFailed, setBackdropLibraryFailed] = useState<Record<string, boolean>>({});
    const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
    // Set when a throw is blocked for being out of range; shows a short hint.
    const [rangeWarningAt, setRangeWarningAt] = useState(0);
    const ownUserId = GetSessionDataManager()?.userId ?? 0;
    const submitChat = useCallback(() =>
    {
        const message = chatInput.trim();

        if (!message) return;

        sendChat(message);
        setChatInput('');
    }, [chatInput, sendChat]);

    // Exact AIR splash frames, positioned from the authoritative collision event.
    const [splashes, setSplashes] = useState<{ id: number; x: number; y: number }[]>([]);
    const ballScreenRef = useRef<Map<number, { x: number; y: number; tx: number; ty: number }>>(new Map());
    // avatar objectId -> frameNow timestamp until which that avatar holds the
    // SnowWarThrow pose. Set when a new snowball appears (that ball's thrower).
    const throwPoseUntilRef = useRef<Map<number, number>>(new Map());

    // In-arena editor state (only meaningful while `editing`).
    const [editItems, setEditItems] = useState<EditItem[]>([]);
    const [editHeightmap, setEditHeightmap] = useState<string[]>([]);
    const [selectedIndex, setSelectedIndex] = useState(-1);
    // Palette selection: a classname to place, an explicit floor-plan tool,
    // 'walk' to test routes, or null for select/move mode. Spawns aren't placed
    // by hand - the game picks them automatically from walkable tiles.
    const [paletteSel, setPaletteSel] = useState<string | null>(null);
    const [furniSearch, setFurniSearch] = useState('');
    const [editorArenaName, setEditorArenaName] = useState('');
    const [savedAt, setSavedAt] = useState(0);
    // Tile under the cursor while editing - drives the 80%-opacity placement
    // ghost. Only updated when it changes, so it doesn't churn renders.
    const [hoverTile, setHoverTile] = useState<{ x: number; y: number } | null>(null);
    const [editorArea, setEditorArea] = useState<{ start: SnowWarEditorTile; end: SnowWarEditorTile } | null>(null);
    const [floorplanEditorVisible, setFloorplanEditorVisible] = useState(false);
    const editorGestureRef = useRef<EditorGesture | null>(null);
    const suppressEditorClickRef = useRef(false);

    // Editor preview walker (client-side only). Held in a ref and animated off
    // the RAF frameNow clock; the current interpolated position is computed in
    // render, so moving it never triggers extra state churn.
    const editorWalkRef = useRef<EditorWalk | null>(null);

    // Hotel furni matching the current search - lets the editor place any
    // real furniture (like decorating a room), not just the classic SnowWar
    // props. Floor furni only; capped so the list stays usable.
    const furniMatches = useMemo(() =>
    {
        const term = furniSearch.trim().toLowerCase();
        if (term.length < 2) return [];
        const all = GetSessionDataManager()?.getAllFurnitureData?.() ?? [];
        return all
            .filter(furni => furni.type === 'S' && (
                furni.className?.toLowerCase().includes(term) || furni.name?.toLowerCase().includes(term)))
            .slice(0, 40);
    }, [furniSearch]);

    // Seed the working copy from the current level snapshot when the editor
    // opens; the game furni become editable items.
    useEffect(() =>
    {
        if (!editing) return;
        setEditItems((levelData?.items ?? []).map(item =>
        {
            // Flat floor-tile props carry a fixed footprint client-side (the
            // server treats them as 1x1 non-blocking floor), so their selection
            // box + walkable band match how they're drawn.
            const classicSize = CUSTOM_FLOOR_SIZES[item.name];
            return {
                name: item.name, x: item.x, y: item.y, rotation: item.rotation, imageUrl: item.imageUrl, offsetZ: item.offsetZ ?? 0,
                width: classicSize?.w ?? item.width, length: classicSize?.l ?? item.length,
                state: item.state ?? 0, stateCount: item.stateCount,
                // Use the server's walkable height (0 for basic/ice, 1 for water).
                walkableHeight: item.walkableHeight,
            };
        }));
        setEditHeightmap([...(levelData?.heightmapRows ?? [])]);
        setSelectedIndex(-1);
        setPaletteSel(null);
        setFurniSearch('');
        setEditorArea(null);
        setFloorplanEditorVisible(false);
        editorGestureRef.current = null;
        setEditorArenaName(levelData?.arenaName || (levelData ? `Custom Arena ${levelData.mapId}` : 'Custom Arena'));

        // Drop the preview walker on the walkable tile nearest the arena centre.
        const rows = levelData?.heightmapRows ?? [];
        const rowH = rows.length;
        const rowW = rows.reduce((max, row) => Math.max(max, row.length), 0);
        let start: { x: number; y: number } | null = null;
        let best = Infinity;
        for (let y = 0; y < rowH; y++)
        {
            for (let x = 0; x < rows[y].length; x++)
            {
                if (!isSnowWarFloorTile(rows[y].charAt(x))) continue;
                const dist = ((x - (rowW / 2)) ** 2) + ((y - (rowH / 2)) ** 2);
                if (dist < best) { best = dist; start = { x, y }; }
            }
        }
        editorWalkRef.current = start ? { path: [start], startMs: 0, endDir: 4 } : null;
    }, [editing, levelData?.mapId]);

    // Current walker tile (rounded) at call time - used to path from where it
    // stands right now, whether idle or mid-step.
    const walkerTileNow = useCallback((): { x: number; y: number } | null =>
    {
        const plan = editorWalkRef.current;
        if (!plan || !plan.path.length) return null;
        if (plan.path.length === 1) return plan.path[0];
        const total = plan.path.length - 1;
        const stepFloat = Math.max(0, Date.now() - plan.startMs) / SNOWWAR_EDITOR_STEP_MS;
        if (stepFloat >= total) return plan.path[total];
        const seg = Math.floor(stepFloat);
        const t = stepFloat - seg;
        const a = plan.path[seg];
        const b = plan.path[seg + 1];
        return { x: Math.round(a.x + ((b.x - a.x) * t)), y: Math.round(a.y + ((b.y - a.y) * t)) };
    }, []);


    // The arena renders the editor's working copy while editing, the live
    // level items otherwise. Both references are stable across renders.
    const displayItems = editing ? editItems : (levelData?.items ?? []);

    // classname -> interaction_modes_count, learned from the server's level data.
    // Lets the editor cap a freshly-placed furni's state stepper when that furni
    // type already appears in the arena (the palette/furnidata carries no state
    // count). Unknown types stay uncapped until the arena is saved and reloaded.
    const stateCountByName = useMemo(() =>
    {
        const map = new Map<string, number>();
        for (const item of (levelData?.items ?? []))
        {
            if (item.stateCount && item.stateCount > 0) map.set(item.name, item.stateCount);
        }
        return map;
    }, [levelData]);

    const mapRows = editing ? editHeightmap : (levelData?.heightmapRows ?? []);
    const mapHeight = mapRows.length;
    const mapWidth = mapHeight > 0 ? mapRows[0].length : 0;
    const walkableTileCount = useMemo(
        () => editHeightmap.reduce(
            (total, row) => total + Array.from(row).filter(isSnowWarFloorTile).length,
            0),
        [editHeightmap]);

    const floorplanOccupiedTiles = useMemo(() =>
    {
        const occupied = editHeightmap.map(row => Array.from({ length: row.length }, () => false));
        for (const item of editItems)
        {
            if (item.imageUrl) continue;
            const rotated = item.rotation === 2 || item.rotation === 6;
            const width = Math.max(1, (rotated ? item.length : item.width) ?? 1);
            const length = Math.max(1, (rotated ? item.width : item.length) ?? 1);
            for (let y = item.y; y < item.y + length; y++)
            {
                for (let x = item.x; x < item.x + width; x++)
                {
                    if (occupied[y]?.[x] !== undefined) occupied[y][x] = true;
                }
            }
        }
        return occupied;
    }, [editHeightmap, editItems]);

    const applyEditorFloorPlan = useCallback((raw: string) =>
    {
        const rows = raw.split(/\r\n|\r|\n/).filter(row => row.length > 0);
        if (!rows.length) return;
        setEditHeightmap(rows);
        setPaletteSel(null);
        setSelectedIndex(-1);
        setHoverTile(null);
        setEditorArea(null);
        editorGestureRef.current = null;
    }, []);

    // Path the preview walker to a tile (client-side only). Returns true if a
    // walk was started, so callers can tell a walk from a no-op empty click.
    const walkPreviewTo = useCallback((tileX: number, tileY: number): boolean =>
    {
        const from = walkerTileNow();
        if (!from) return false;
        // Tiles blocked for the preview walker: any non-walkable piece
        // (walkableHeight > 0 - water, trees, blocks, solid furni) over its whole
        // footprint, so the walker routes around them just like in-game.
        const blocked = new Set<number>();
        for (const it of editItems)
        {
            if ((it.walkableHeight ?? 0) <= 0) continue;
            const swap = it.rotation === 2 || it.rotation === 6;
            const w = Math.max(1, (swap ? it.length : it.width) ?? 1);
            const l = Math.max(1, (swap ? it.width : it.length) ?? 1);
            for (let dx = 0; dx < w; dx++)
            {
                for (let dy = 0; dy < l; dy++)
                {
                    blocked.add(((it.y + dy) * mapWidth) + (it.x + dx));
                }
            }
        }
        const path = findEditorPath(mapRows, from, { x: tileX, y: tileY }, mapWidth, mapHeight, blocked);
        if (!path || path.length < 2) return false;
        const last = path[path.length - 1];
        const prevTile = path[path.length - 2];
        editorWalkRef.current = { path, startMs: Date.now(), endDir: tileDirection(last.x - prevTile.x, last.y - prevTile.y) };
        return true;
    }, [mapRows, mapWidth, mapHeight, walkerTileNow, editItems]);

    const originX = mapHeight * TILE_HALF_W;
    const canvasWidth = (mapWidth + mapHeight) * TILE_HALF_W;
    const canvasHeight = (mapWidth + mapHeight) * TILE_HALF_H + 40;

    const toScreen = useCallback((tileX: number, tileY: number) => ({
        x: originX + (tileX - tileY) * TILE_HALF_W,
        y: (tileX + tileY) * TILE_HALF_H,
    }), [originX]);

    const worldToScreen = useCallback((worldX: number, worldY: number) =>
    {
        // AIR/server tile t is centred on world t * 3200 (worldToTile rounds
        // with +1600), while floor tile t is drawn from toScreen(t, t) - its
        // top corner. Shift half a tile so a world position lands on the same
        // tile centre the props, the clicks and the painted backdrop use.
        const tx = (worldX / TILE_SIZE_WORLD) + 0.5;
        const ty = (worldY / TILE_SIZE_WORLD) + 0.5;
        return {
            x: originX + (tx - ty) * TILE_HALF_W,
            y: (tx + ty) * TILE_HALF_H,
        };
    }, [originX]);

    // Static floor: tiles + obstacles drawn once per level.
    useEffect(() =>
    {
        const canvas = canvasRef.current;
        if (!canvas || !mapHeight) return;

        const context = canvas.getContext('2d');
        if (!context) return;

        context.clearRect(0, 0, canvas.width, canvas.height);

        for (let y = 0; y < mapHeight; y++)
        {
            for (let x = 0; x < mapWidth; x++)
            {
                const char = mapRows[y]?.charAt(x);
                if (!isSnowWarFloorTile(char)) continue;

                const { x: sx, y: sy } = toScreen(x, y);
                context.beginPath();
                context.moveTo(sx, sy);
                context.lineTo(sx + TILE_HALF_W, sy + TILE_HALF_H);
                context.lineTo(sx, sy + TILE_HALF_H * 2);
                context.lineTo(sx - TILE_HALF_W, sy + TILE_HALF_H);
                context.closePath();
                context.fillStyle = ((x + y) % 2) ? '#eef5fb' : '#e4eef7';
                context.fill();
                context.strokeStyle = '#c8d8e6';
                context.lineWidth = 1;
                context.stroke();
            }
        }

        // Classic props draw on a SEPARATE canvas that sits above the ad
        // backdrop overlay, so toggling "hide tiles behind image" hides only the
        // floor grid (this canvas) and not the props.
        const propsCanvas = propsCanvasRef.current;
        const propsContext = propsCanvas?.getContext('2d');
        if (!propsContext) return;
        propsContext.clearRect(0, 0, propsCanvas.width, propsCanvas.height);

        for (const item of displayItems)
        {
            // Only the FLAT floor tiles (basic/ice/water) draw on this canvas -
            // they sit on the ground below the avatars. The tall props (trees,
            // snowman, fences, blocks) are DOM sprites that depth-sort with the
            // avatars, and hotel furni are their own DOM images.
            if (!isFlatFloorProp(item.name)) continue;

            const { x: sx, y: sy } = toScreen(item.x, item.y);
            drawCustomProp(propsContext, item.name, sx, sy, item.rotation);
        }

        // Placement preview for a flat floor tile (tall props get a DOM ghost).
        if (editing && paletteSel && isFlatFloorProp(paletteSel) && hoverTile)
        {
            const { x: sx, y: sy } = toScreen(hoverTile.x, hoverTile.y);
            propsContext.save();
            propsContext.globalAlpha = 0.55;
            drawCustomProp(propsContext, paletteSel, sx, sy);
            propsContext.restore();
        }
    }, [displayItems, mapHeight, mapWidth, mapRows, toScreen, editing, paletteSel, hoverTile]);

    // Drive the simulation clock + re-render at display rate.
    useEffect(() =>
    {
        let running = true;
        let rafId = 0;

        const loop = (now: number) =>
        {
            if (!running) return;
            simulation.update(now);
            setFrameNow(Date.now());
            rafId = requestAnimationFrame(loop);
        };

        rafId = requestAnimationFrame(loop);

        // Back from a hidden tab: skip the backlog instead of replaying it.
        const onVisibilityChange = () =>
        {
            if (!document.hidden) simulation.catchUp();
        };
        document.addEventListener('visibilitychange', onVisibilityChange);

        return () =>
        {
            running = false;
            document.removeEventListener('visibilitychange', onVisibilityChange);
            cancelAnimationFrame(rafId);
        };
    }, [simulation]);

    // Track the viewport size; the camera transform is computed from it.
    useEffect(() =>
    {
        const viewport = viewportRef.current;
        if (!viewport) return;

        const update = () => setViewportSize({ width: viewport.clientWidth, height: viewport.clientHeight });
        update();

        const observer = new ResizeObserver(update);
        observer.observe(viewport);
        return () => observer.disconnect();
    }, [levelData]);


    // Periodic authoritative resync.
    useEffect(() =>
    {
        if (phase !== 'playing') return;
        const interval = setInterval(() => requestFullStatus(), 10000);
        return () => clearInterval(interval);
    }, [phase, requestFullStatus]);

    const screenToTile = useCallback((event: { clientX: number; clientY: number }) =>
    {
        // Measure the floor canvas itself, NOT the viewport: the world is
        // centered inside a scrollable viewport, so the viewport rect is
        // offset from the isometric origin and clicks landed on the wrong
        // tile (or outside the map) whenever the arena didn't exactly fill it.
        const canvas = canvasRef.current;
        if (!canvas) return { tileX: -1, tileY: -1 };

        const bounds = canvas.getBoundingClientRect();
        const scaleX = bounds.width > 0 ? canvasWidth / bounds.width : 1;
        const px = (event.clientX - bounds.left) * scaleX - originX;
        const py = (event.clientY - bounds.top) * scaleX;

        const tileX = Math.floor(((px / TILE_HALF_W) + (py / TILE_HALF_H)) / 2);
        const tileY = Math.floor(((py / TILE_HALF_H) - (px / TILE_HALF_W)) / 2);
        return { tileX, tileY };
    }, [canvasWidth, originX]);

    const placeEditItems = useCallback((tiles: SnowWarEditorTile[]) =>
    {
        if (!paletteSel || paletteSel === 'walk' || paletteSel === 'edit') return;

        const fd = GetSessionDataManager()?.getFloorItemDataByName?.(paletteSel);
        const classicSize = CUSTOM_FLOOR_SIZES[paletteSel];
        const isBlocking = paletteSel.startsWith('block_water') || paletteSel.startsWith('sw_fence');
        const walkable = !isBlocking && (!!(fd?.canStandOn || fd?.canLayOn) || !!classicSize);

        setEditItems(items =>
        {
            const next = [...items];
            let changed = false;

            for (const tile of tiles)
            {
                if (next.length >= SNOWWAR_EDITOR_MAX_ITEMS) break;
                const item: EditItem = {
                    name: paletteSel,
                    x: tile.x,
                    y: tile.y,
                    rotation: 0,
                    imageUrl: '',
                    offsetZ: 0,
                    state: 0,
                    stateCount: stateCountByName.get(paletteSel),
                    width: fd?.tileSizeX ?? classicSize?.w,
                    length: fd?.tileSizeY ?? classicSize?.l,
                    walkableHeight: walkable ? 0 : 1,
                };
                if (!canPlaceSnowWarEditorItem(next, item, mapWidth, mapHeight)) continue;
                next.push(item);
                changed = true;
            }

            return changed ? next : items;
        });
    }, [mapHeight, mapWidth, paletteSel, stateCountByName]);

    const applyEditClick = useCallback((tileX: number, tileY: number) =>
    {
        if (paletteSel === 'walk')
        {
            // Stroll the client-side preview avatar to the clicked tile.
            walkPreviewTo(tileX, tileY);
            return;
        }

        if (paletteSel && paletteSel !== 'edit')
        {
            placeEditItems([{ x: tileX, y: tileY }]);
            // Keep the palette piece armed so you can drop several in a row; it
            // stays selected (ghost keeps following) until you pick it again to
            // toggle it off, or choose another piece / mode.
            return;
        }

        // Selection modes: 'edit' (paletteSel === 'edit') just picks a piece to
        // tweak in place (rotate / state / delete); Select/Move (paletteSel ===
        // null) additionally drops the selected piece on an empty tile. Both
        // select the piece under the click.
        // When a piece is already picked up for a move (Select/Move), a click
        // should DROP it on the tile - even a tile covered by a flat floor tile
        // (basic/ice/water) - instead of the flat tile grabbing the click and
        // re-selecting itself. So skip flat floor tiles (and the moving piece
        // itself) in the hit-test while relocating; solid furni still re-select.
        const relocating = paletteSel === null && selectedIndex >= 0;
        let hitIndex = -1;
        for (let i = editItems.length - 1; i >= 0; i--)
        {
            const it = editItems[i];
            if (relocating && (i === selectedIndex || isFlatFloorProp(it.name))) continue;
            // Hit-test the whole footprint (rotation swaps width/length), not
            // just the origin tile, so clicking any tile of a 3x3 / 2x1 / ...
            // furni selects it.
            const swap = it.rotation === 2 || it.rotation === 6;
            const effW = Math.max(1, (swap ? it.length : it.width) ?? 1);
            const effL = Math.max(1, (swap ? it.width : it.length) ?? 1);
            if (tileX >= it.x && tileX < it.x + effW && tileY >= it.y && tileY < it.y + effL) { hitIndex = i; break; }
        }

        if (hitIndex >= 0) { setSelectedIndex(hitIndex); return; }

        // Only Select/Move relocates on an empty-tile click; Edit leaves the
        // piece where it is so you can't nudge it by accident while editing.
        if (paletteSel === null && selectedIndex >= 0)
        {
            const selected = editItems[selectedIndex];
            if (!selected) return;
            const moved = { ...selected, x: tileX, y: tileY };
            if (!canPlaceSnowWarEditorItem(editItems, moved, mapWidth, mapHeight, selectedIndex)) return;
            setEditItems(items => items.map((item, index) => index === selectedIndex ? moved : item));
            // Dropped at the new tile - deselect so the moved furni shows at its
            // new spot and stops following the cursor.
            setSelectedIndex(-1);
            setHoverTile(null);
            return;
        }

        // Empty tile clicked in a selection mode with nothing to move: stroll the
        // preview avatar there too, so walking works without switching to the
        // dedicated Walk mode.
        if (paletteSel === null || paletteSel === 'edit') walkPreviewTo(tileX, tileY);
        setSelectedIndex(-1);
    }, [paletteSel, editItems, selectedIndex, mapHeight, mapWidth, placeEditItems, walkPreviewTo]);

    const onArenaClick = useCallback((event: MouseEvent<HTMLDivElement>) =>
    {
        const { tileX, tileY } = screenToTile(event);
        if (tileX < 0 || tileY < 0 || tileX >= mapWidth || tileY >= mapHeight) return;

        if (editing)
        {
            event.preventDefault();
            if (event.type === 'click' && suppressEditorClickRef.current)
            {
                suppressEditorClickRef.current = false;
                return;
            }
            applyEditClick(tileX, tileY);
            return;
        }

        if (event.type === 'contextmenu')
        {
            event.preventDefault();
            // Duckie's convenient right-click gestures remain additive to AIR:
            // right-click is quick and Shift+right-click is a long lob.
            const trajectory = event.shiftKey ? TRAJECTORY_LONG_LOB : TRAJECTORY_QUICK;
            const own = simulation.getAvatarByUserId(ownUserId);
            if (own && !isThrowInRange(own.worldX, own.worldY, tileToWorld(tileX), tileToWorld(tileY), trajectory))
            {
                setRangeWarningAt(Date.now());
                return;
            }
            throwAtLocation(tileToWorld(tileX), tileToWorld(tileY), trajectory);
            return;
        }

        const trajectory = getAirTrajectory(event.altKey, event.shiftKey, false);
        if (trajectory !== null)
        {
            event.preventDefault();
            const own = simulation.getAvatarByUserId(ownUserId);
            if (own && !isThrowInRange(own.worldX, own.worldY, tileToWorld(tileX), tileToWorld(tileY), trajectory))
            {
                setRangeWarningAt(Date.now());
                return;
            }
            throwAtLocation(tileToWorld(tileX), tileToWorld(tileY), trajectory);
            return;
        }

        const tile = mapRows[tileY]?.charAt(tileX);
        if (tile === 'x' || tile === 'X') return;
        walkTo(tileToWorld(tileX), tileToWorld(tileY));
    }, [editing, applyEditClick, mapHeight, mapRows, mapWidth, screenToTile, simulation, ownUserId, throwAtLocation, walkTo]);

    const editorBrushActive = !!paletteSel && paletteSel !== 'edit' && paletteSel !== 'walk';

    const updateEditorHover = useCallback((event: { clientX: number; clientY: number }) =>
    {
        const { tileX, tileY } = screenToTile(event);
        const inBounds = tileX >= 0 && tileY >= 0 && tileX < mapWidth && tileY < mapHeight;
        setHoverTile(previous =>
            (previous && previous.x === tileX && previous.y === tileY) || (!previous && !inBounds)
                ? previous
                : (inBounds ? { x: tileX, y: tileY } : null));
        return inBounds ? { x: tileX, y: tileY } : null;
    }, [mapHeight, mapWidth, screenToTile]);

    const onEditorPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) =>
    {
        if (!editing || !editorBrushActive || event.button !== 0) return;
        const tile = updateEditorHover(event);
        if (!tile) return;

        const mode = event.shiftKey ? 'area' : (event.ctrlKey || event.metaKey) ? 'paint' : null;
        if (!mode) return;

        event.preventDefault();
        suppressEditorClickRef.current = true;
        try { event.currentTarget.setPointerCapture(event.pointerId); } catch {}

        const gesture: EditorGesture = {
            mode,
            start: tile,
            end: tile,
        };
        editorGestureRef.current = gesture;

        if (mode === 'area')
        {
            setEditorArea({ start: tile, end: tile });
            return;
        }

        placeEditItems([tile]);
    }, [editing, editorBrushActive, placeEditItems, updateEditorHover]);

    const onEditorPointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) =>
    {
        if (!editing) return;
        const tile = updateEditorHover(event);
        const gesture = editorGestureRef.current;
        if (!gesture || !tile || (gesture.end.x === tile.x && gesture.end.y === tile.y)) return;

        gesture.end = tile;
        if (gesture.mode === 'area')
        {
            setEditorArea({ start: gesture.start, end: tile });
            return;
        }

        placeEditItems([tile]);
    }, [editing, placeEditItems, updateEditorHover]);

    const onEditorPointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) =>
    {
        const gesture = editorGestureRef.current;
        if (!gesture) return;

        editorGestureRef.current = null;
        setEditorArea(null);
        try { event.currentTarget.releasePointerCapture(event.pointerId); } catch {}

        if (gesture.mode === 'area')
        {
            const tiles = getSnowWarEditorArea(gesture.start, gesture.end);
            placeEditItems(tiles);
        }

        window.setTimeout(() => { suppressEditorClickRef.current = false; }, 0);
    }, [placeEditItems]);

    const onEditorPointerCancel = useCallback((event: ReactPointerEvent<HTMLDivElement>) =>
    {
        editorGestureRef.current = null;
        suppressEditorClickRef.current = false;
        setEditorArea(null);
        try { event.currentTarget.releasePointerCapture(event.pointerId); } catch {}
    }, []);

    const fireRayGun = useCallback((event: MouseEvent<HTMLButtonElement>, x: number, y: number, rotation: number, width: number, length: number) =>
    {
        event.stopPropagation();
        const direction = ((rotation % 8) + 8) % 8;
        const horizontalSize = (direction === 2 || direction === 6) ? length : width;
        const verticalSize = (direction === 2 || direction === 6) ? width : length;
        walkTo(
            tileToWorld(x + (direction === 6 ? horizontalSize : direction === 2 ? -1 : 0)),
            tileToWorld(y + (direction === 0 ? verticalSize : direction === 4 ? -1 : 0)));
    }, [walkTo]);

    const rotateSelected = useCallback(() =>
        setEditItems(items => items.map((item, i) => (i === selectedIndex ? { ...item, rotation: (item.rotation + 2) % 8 } : item))),
    [selectedIndex]);

    // Multistate furni: step the state index up/down, clamped to the furni's
    // real range - [0, interaction_modes_count - 1] from items_base, sent by the
    // server. When the count is unknown (a freshly-placed furni type not yet in
    // the arena), leave the high end open until the arena is saved and reloaded.
    const cycleState = useCallback((delta: number) =>
        setEditItems(items => items.map((item, i) =>
        {
            if (i !== selectedIndex) return item;
            const count = item.stateCount ?? stateCountByName.get(item.name);
            const max = count && count > 0 ? count - 1 : Number.MAX_SAFE_INTEGER;
            return { ...item, state: Math.min(max, Math.max(0, (item.state ?? 0) + delta)) };
        })),
    [selectedIndex, stateCountByName]);

    const deleteSelected = useCallback(() =>
    {
        setEditItems(items => items.filter((_, i) => i !== selectedIndex));
        setSelectedIndex(-1);
    }, [selectedIndex]);

    // The arena backdrop is a single always-full-screen ad image, edited via
    // the dedicated background control rather than by selecting a tile.
    const setBackdropUrl = useCallback((url: string) =>
        setEditItems(items =>
        {
            const trimmed = url;
            const index = items.findIndex(item => item.imageUrl);
            if (!trimmed) return items.filter(item => !item.imageUrl);
            if (index >= 0) return items.map((item, i) => (i === index ? { ...item, imageUrl: trimmed } : item));
            return [...items, { name: 'ads_background', x: 0, y: 0, rotation: 0, imageUrl: trimmed, offsetZ: 0, state: 0 }];
        }), []);

    const setBackdropOffsetZ = useCallback((offsetZ: number) =>
        setEditItems(items => items.map(item => (item.imageUrl ? { ...item, offsetZ } : item))), []);

    const clearAllItems = useCallback(() =>
    {
        setEditItems([]);
        setSelectedIndex(-1);
    }, []);

    const saveEditor = useCallback(() =>
    {
        if (!levelData) return;
        // Spawns are auto-generated server-side, so none are sent from the editor.
        saveArena(levelData.mapId, editItems, [], editHeightmap, editorArenaName);
        setSavedAt(Date.now());
    }, [levelData, editItems, editHeightmap, editorArenaName, saveArena]);

    const ownAvatar = simulation.getAvatarByUserId(ownUserId);
    const alpha = simulation.interpolationAlpha;

    // AIR SnowWarEngine.registerHit -> SnowWarUI.flashOwnScore: the own-stats
    // panel flashes minus when this player is hit and plus when their ball
    // hits someone (the only things that lower health / raise the score).
    const ownHealth = ownAvatar?.health ?? null;
    const ownScore = ownAvatar?.score ?? null;
    const lastOwnStatsRef = useRef<{ health: number; score: number }>(null);
    const [scoreFlash, setScoreFlash] = useState<{ plus: boolean; at: number }>(null);

    useEffect(() =>
    {
        const last = lastOwnStatsRef.current;
        lastOwnStatsRef.current = (ownHealth === null) ? null : { health: ownHealth, score: ownScore };

        if (!last || (ownHealth === null) || (phase !== 'playing')) return;

        const flash = (ownHealth < last.health) ? { plus: false, at: Date.now() } : (ownScore > last.score) ? { plus: true, at: Date.now() } : null;

        if (flash) setTimeout(() => setScoreFlash(flash), 0);
    }, [ ownHealth, ownScore, phase ]);

    // AIR supports arrows and the isometric QWE/AD/ZXC cluster. While a key is
    // held it sends a target two tiles away once per 300 ms game turn.
    useEffect(() =>
    {
        if (editing || phase !== 'playing') return;

        const keys = new Set<string>();
        let interval: ReturnType<typeof setInterval> | null = null;
        const movementCodes = new Set([
            'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
            'KeyQ', 'KeyW', 'KeyE', 'KeyA', 'KeyD', 'KeyZ', 'KeyX', 'KeyC',
        ]);
        const isEditableTarget = (target: EventTarget | null): boolean =>
        {
            return isTextEntryTarget(target);
        };
        const move = () =>
        {
            const direction = getKeyboardDirection(keys);
            const avatar = simulation.getAvatarByUserId(ownUserId);
            if (!direction || !avatar) return;
            const targetX = Math.max(0, Math.min(mapWidth - 1, avatar.tileX + (direction.x * 2)));
            const targetY = Math.max(0, Math.min(mapHeight - 1, avatar.tileY + (direction.y * 2)));
            walkTo(tileToWorld(targetX), tileToWorld(targetY));
        };
        const stopTimerIfIdle = () =>
        {
            if (getKeyboardDirection(keys) || interval === null) return;
            clearInterval(interval);
            interval = null;
        };
        const onKeyDown = (event: KeyboardEvent) =>
        {
            if (!movementCodes.has(event.code) || isEditableTarget(event.target)) return;
            event.preventDefault();
            const wasMoving = getKeyboardDirection(keys) !== null;
            keys.add(event.code);
            if (!wasMoving) move();
            if (interval === null) interval = setInterval(move, 300);
        };
        const onKeyUp = (event: KeyboardEvent) =>
        {
            if (!movementCodes.has(event.code)) return;
            keys.delete(event.code);
            stopTimerIfIdle();
        };
        const onBlur = () =>
        {
            keys.clear();
            stopTimerIfIdle();
        };

        window.addEventListener('keydown', onKeyDown);
        window.addEventListener('keyup', onKeyUp);
        window.addEventListener('blur', onBlur);
        return () =>
        {
            window.removeEventListener('keydown', onKeyDown);
            window.removeEventListener('keyup', onKeyUp);
            window.removeEventListener('blur', onBlur);
            if (interval !== null) clearInterval(interval);
        };
    }, [editing, mapHeight, mapWidth, ownUserId, phase, simulation, walkTo]);

    // Track newly launched balls for the throw pose and drain authoritative
    // impact positions. The server sends the post-movement x/y/z, avoiding the
    // old one-subturn-early splash and nearest-prop guessing.
    useEffect(() =>
    {
        const prev = ballScreenRef.current;
        const next = new Map<number, { x: number; y: number; tx: number; ty: number }>();
        for (const ball of simulation.snowballs.values())
        {
            const lx = ball.prevLocH + (ball.locH - ball.prevLocH) * alpha;
            const ly = ball.prevLocV + (ball.locV - ball.prevLocV) * alpha;
            const { x, y } = worldToScreen(lx, ly);
            next.set(ball.objectId, { x, y, tx: Math.round(lx / TILE_SIZE_WORLD), ty: Math.round(ly / TILE_SIZE_WORLD) });

            // A newly-appeared ball means its thrower just threw - flash that
            // avatar into the SnowWarThrow pose for a short window.
            if (!prev.has(ball.objectId) && ball.throwerObjectId)
            {
                throwPoseUntilRef.current.set(ball.throwerObjectId, frameNow + SNOWWAR_THROW_POSE_MS);
            }
        }

        ballScreenRef.current = next;

        const impacts = simulation.drainImpacts();
        if (impacts.length)
        {
            setSplashes(list => [...list, ...impacts.map(impact =>
            {
                const point = worldToScreen(impact.worldX, impact.worldY);
                return {
                    id: impact.id,
                    x: point.x,
                    y: point.y - snowballRise(Math.max(0, impact.height)),
                };
            })]);
        }
    }, [frameNow, alpha, worldToScreen, simulation]);

    // First room-ad furni's image is the arena backdrop. offsetZ doubles as an
    // overlay flag: 0 = drawn behind the arena (full-screen), 1 = overlaid on
    // top of the floor tiles (hiding them, but they stay walkable) while still
    // sitting under the furni and avatars. Edit-aware so it previews live.
    const arenaBackdrop = displayItems.find(item => item.imageUrl) ?? null;
    // An official backdrop (keyed on its file name) is fitted to the floor and is
    // never an overlay, whatever offsetZ the server sends (AIR's depth push).
    const officialBackdrop = findSnowWarOfficialBackdrop(arenaBackdrop?.imageUrl);
    const backdropOverlay = !!(arenaBackdrop && !officialBackdrop && (arenaBackdrop.offsetZ ?? 0) > 0);
    const selectedItem = (editing && selectedIndex >= 0 && editItems[selectedIndex]) ? editItems[selectedIndex] : null;
    const placingFurni = (editing && paletteSel && paletteSel !== 'edit' && paletteSel !== 'walk')
        ? GetSessionDataManager()?.getFloorItemDataByName?.(paletteSel) : null;
    const selectedFurni = selectedItem ? GetSessionDataManager()?.getFloorItemDataByName?.(selectedItem.name) : null;
    const backdropItem = editing ? (editItems.find(item => item.imageUrl) ?? null) : null;

    // Interaction-mode count for the selected furni (items_base value from the
    // server). Known => the stepper caps at count-1 and single-state furni hide
    // it entirely; unknown => leave the stepper open (freshly-placed new type).
    const selectedStateCount = selectedItem ? (selectedItem.stateCount ?? stateCountByName.get(selectedItem.name)) : undefined;
    const selectedStateMax = (selectedStateCount && selectedStateCount > 0) ? selectedStateCount - 1 : Number.MAX_SAFE_INTEGER;

    // Placement ghost: the furni being placed (palette pick) or the selected
    // furni being moved, previewed at the hovered tile at 80% opacity.
    const ghostFurni = placingFurni ?? (selectedItem ? selectedFurni : null);
    const ghostRotation = placingFurni ? 0 : (selectedItem?.rotation ?? 0);
    // A freshly-placed furni starts at state 0; a moving furni keeps its state.
    const ghostState = placingFurni ? 0 : (selectedItem?.state ?? 0);

    // "Actively moving": Select/Move mode (paletteSel === null) with a piece
    // selected and the cursor over a tile. Only then do we hide the piece at its
    // old spot, float the move ghost, and suppress the selection box. Edit mode
    // (paletteSel === 'edit') never moves, so it keeps the piece + selection box.
    const movingSelected = editing && paletteSel === null && !!selectedItem && !!hoverTile;

    const editorAreaOutline = useMemo(() =>
    {
        if (!editorArea) return null;
        const minX = Math.min(editorArea.start.x, editorArea.end.x);
        const maxX = Math.max(editorArea.start.x, editorArea.end.x);
        const minY = Math.min(editorArea.start.y, editorArea.end.y);
        const maxY = Math.max(editorArea.start.y, editorArea.end.y);
        const top = toScreen(minX, minY);
        const right = toScreen(maxX, minY);
        const bottom = toScreen(maxX, maxY);
        const left = toScreen(minX, maxY);
        const corners = [
            top,
            { x: right.x + TILE_HALF_W, y: right.y + TILE_HALF_H },
            { x: bottom.x, y: bottom.y + (TILE_HALF_H * 2) },
            { x: left.x - TILE_HALF_W, y: left.y + TILE_HALF_H },
        ];
        const leftPx = Math.min(...corners.map(corner => corner.x));
        const topPx = Math.min(...corners.map(corner => corner.y));
        return {
            left: leftPx,
            top: topPx,
            width: Math.max(...corners.map(corner => corner.x)) - leftPx,
            height: Math.max(...corners.map(corner => corner.y)) - topPx,
            points: corners.map(corner => `${corner.x - leftPx},${corner.y - topPx}`).join(' '),
        };
    }, [editorArea, toScreen]);

    // Editor preview walker: current interpolated tile position + facing, derived
    // purely from the walk plan + the RAF frameNow clock (recomputed each frame).
    const editorWalker = (() =>
    {
        if (!editing) return null;
        const plan = editorWalkRef.current;
        if (!plan || !plan.path.length) return null;
        if (plan.path.length === 1) return { x: plan.path[0].x, y: plan.path[0].y, dir: plan.endDir, walking: false };
        const total = plan.path.length - 1;
        const stepFloat = Math.max(0, frameNow - plan.startMs) / SNOWWAR_EDITOR_STEP_MS;
        if (stepFloat >= total)
        {
            const last = plan.path[total];
            return { x: last.x, y: last.y, dir: plan.endDir, walking: false };
        }
        const seg = Math.floor(stepFloat);
        const t = stepFloat - seg;
        const a = plan.path[seg];
        const b = plan.path[seg + 1];
        return { x: a.x + ((b.x - a.x) * t), y: a.y + ((b.y - a.y) * t), dir: tileDirection(b.x - a.x, b.y - a.y), walking: true };
    })();
    const ownPlayer = levelData?.players?.find(player => player.userId === ownUserId) ?? null;
    const editorWalkerFigure = ownPlayer?.figure ?? GetSessionDataManager()?.figure ?? '';
    const editorWalkerGender = ownPlayer?.gender ?? 'M';

    // Fixed 1920x1080 design stage: the background fills it and the floor sits
    // centred on it. On screens >= the stage the whole stage is centred in the
    // viewport; on smaller screens the viewport is capped to the screen and the
    // camera follows the own avatar (like a normal room), so background + tiles
    // pan together. The stage grows past the base only if a map is larger.
    const floorW = canvasWidth * zoom;
    const floorH = canvasHeight * zoom;
    let stageW = Math.max(DESIGN_W, floorW);
    let stageH = Math.max(DESIGN_H, floorH);
    let floorOffsetX = (stageW - floorW) / 2;
    let floorOffsetY = (stageH - floorH) / 2;
    let officialBackdropRect: { left: number; top: number; width: number; height: number } | null = null;

    if (officialBackdrop)
    {
        // Stage = artwork (plus the floor, should a map ever stick out of it),
        // so the camera can never pan onto an area the artwork doesn't paint.
        const anchor = toScreen(officialBackdrop.tile.x, officialBackdrop.tile.y);
        const layout = layoutSnowWarOfficialBackdrop(
            officialBackdrop,
            backdropRatios[officialBackdrop.file] ?? officialBackdrop.defaultRatio,
            { x: anchor.x * zoom, y: anchor.y * zoom },
            floorW,
            floorH,
        );
        stageW = layout.stageW;
        stageH = layout.stageH;
        floorOffsetX = layout.floorOffsetX;
        floorOffsetY = layout.floorOffsetY;
        officialBackdropRect = layout.rect;
    }

    // Camera as a GPU translate on the stage. Instead of hard-locking the
    // avatar to centre (which scrolls the background on every step and makes
    // the smallest jitter obvious), the camera holds still while the avatar
    // roams a central dead zone and only eases back to centre once the avatar
    // pushes into the outer CAMERA_DEADZONE band near a screen edge.
    let cameraX = (viewportSize.width - stageW) / 2;
    let cameraY = (viewportSize.height - stageH) / 2;

    if (ownAvatar && viewportSize.width > 0)
    {
        const followX = ownAvatar.prevWorldX + (ownAvatar.worldX - ownAvatar.prevWorldX) * alpha;
        const followY = ownAvatar.prevWorldY + (ownAvatar.worldY - ownAvatar.prevWorldY) * alpha;
        const { x, y } = worldToScreen(followX, followY);
        const avatarStageX = floorOffsetX + (x * zoom);
        const avatarStageY = floorOffsetY + (y * zoom);

        const followsX = stageW > viewportSize.width;
        const followsY = stageH > viewportSize.height;
        const centeredX = Math.min(0, Math.max(viewportSize.width - stageW, (viewportSize.width / 2) - avatarStageX));
        const centeredY = Math.min(0, Math.max(viewportSize.height - stageH, (viewportSize.height / 2) - avatarStageY));

        const cam = cameraRef.current;
        if (!cam.initialized)
        {
            cam.x = followsX ? centeredX : cameraX;
            cam.y = followsY ? centeredY : cameraY;
            cam.initialized = true;
        }

        // Advance the dead-zone camera at most once per animation frame; the
        // frame gate also makes a Strict-Mode double render idempotent.
        if (cam.frame !== frameNow)
        {
            cam.frame = frameNow;

            if (followsX)
            {
                const screenX = avatarStageX + cam.x;
                if (!cam.recenterX && (screenX < viewportSize.width * CAMERA_DEADZONE || screenX > viewportSize.width * (1 - CAMERA_DEADZONE)))
                    cam.recenterX = true;
                if (cam.recenterX)
                {
                    cam.x += (centeredX - cam.x) * CAMERA_EASE;
                    if (Math.abs(centeredX - cam.x) < 0.5) { cam.x = centeredX; cam.recenterX = false; }
                }
                cam.x = Math.min(0, Math.max(viewportSize.width - stageW, cam.x));
            }
            else cam.x = cameraX;

            if (followsY)
            {
                const screenY = avatarStageY + cam.y;
                if (!cam.recenterY && (screenY < viewportSize.height * CAMERA_DEADZONE || screenY > viewportSize.height * (1 - CAMERA_DEADZONE)))
                    cam.recenterY = true;
                if (cam.recenterY)
                {
                    cam.y += (centeredY - cam.y) * CAMERA_EASE;
                    if (Math.abs(centeredY - cam.y) < 0.5) { cam.y = centeredY; cam.recenterY = false; }
                }
                cam.y = Math.min(0, Math.max(viewportSize.height - stageH, cam.y));
            }
            else cam.y = cameraY;
        }

        cameraX = cam.x;
        cameraY = cam.y;
    }
    else
    {
        cameraRef.current.initialized = false;
    }

    const teamScores = useMemo(() =>
    {
        const scores = new Map<number, number>();
        for (const avatar of simulation.avatars.values())
        {
            scores.set(avatar.teamId, (scores.get(avatar.teamId) ?? 0) + avatar.score);
        }
        return [...scores.entries()].sort((a, b) => a[0] - b[0]);
    }, [simulation, simulation.subturnCount]);

    const formatClock = (totalSeconds: number) =>
    {
        const minutes = Math.floor(Math.max(0, totalSeconds) / 60);
        const seconds = Math.max(0, totalSeconds) % 60;
        return `${minutes}:${seconds.toString().padStart(2, '0')}`;
    };
    const redScore = teamScores.find(([teamId]) => teamId === 0)?.[1] ?? 0;
    const blueScore = teamScores.find(([teamId]) => teamId === 1)?.[1] ?? 0;
    const healthFrame = Math.max(0, Math.min(5, ownAvatar?.health ?? 0));
    const scoreFlashFrame = scoreFlash ? getScoreFlashFrame(frameNow - scoreFlash.at) : 0;
    const countdownFrame = getCountdownFrame(frameNow, preparingEndsAt);
    const visibleChatMessages = chatMessages
        .filter(message => (frameNow - message.receivedAt) < 10000)
        .slice(-8)
        .flatMap(message =>
        {
            const avatar = simulation.avatars.get(message.objectId);

            return avatar ? [{ avatar, message, stackIndex: 0 }] : [];
        });
    const chatStackCounts = new Map<number, number>();

    for (let index = visibleChatMessages.length - 1; index >= 0; index--)
    {
        const entry = visibleChatMessages[index];
        entry.stackIndex = chatStackCounts.get(entry.avatar.teamId) ?? 0;
        chatStackCounts.set(entry.avatar.teamId, entry.stackIndex + 1);
    }

    if (!levelData)
    {
        return (
            <NativeTextTree>
                <div className="snowwar-arena snowwar-arena--loading">
                    <div className="snowwar-banner">{localizeWithFallback('snowwar.loading_arena', 'Going to the arena...')}</div>
                </div>
            </NativeTextTree>
        );
    }

    return (
        <NativeTextTree>
            <div className="snowwar-arena">
                {editing ? (
                    <div className="snowwar-hud snowwar-hud--editor">
                        <div className="snowwar-hud__clock">{formatClock(secondsLeft)}</div>
                        <div className="snowwar-hud__actions">
                            <input
                                type="text"
                                className="snowwar-hud__arena-name"
                                value={editorArenaName}
                                maxLength={64}
                                aria-label={localizeWithFallback('snowwar.editor.name', 'Arena name')}
                                onChange={event => setEditorArenaName(event.target.value)}
                            />
                            <BaseButton type="button" className="snowwar-button" onClick={() => saveEditor()}>
                                {localizeWithFallback('snowwar.editor.publish', 'Publish arena')}
                            </BaseButton>
                            <BaseButton type="button" className="snowwar-button snowwar-button--danger" onClick={() => stopEditing()}>
                                {localizeWithFallback('snowwar.editor.exit', 'Exit editor')}
                            </BaseButton>
                        </div>
                    </div>
                ) : (
                    <div className="snowwar-official-hud">
                        {/* AIR snowwar_exit: the ui_exit_up / ui_exit_down bitmap button, no caption. */}
                        <Hint content={localizeWithFallback('snowwar.exit.title', 'Leave game?')}>
                            <BaseButton
                                aria-label={localizeWithFallback('snowwar.exit.title', 'Leave game?')}
                                className="snowwar-official-hud__quit"
                                style={{ '--exit-up': `url(${getHudAssetUrl('ui_exit_up')})`, '--exit-down': `url(${getHudAssetUrl('ui_exit_down')})` } as CSSProperties}
                                type="button"
                                onClick={confirmExitGame}
                            />
                        </Hint>

                        {ownAvatar && (
                            <>
                                <div
                                    className="snowwar-official-hud__ammo"
                                    title={localizeWithFallback('snowwar.hud.snowballs', 'Snowball inventory')}
                                >
                                    <img
                                        alt=""
                                        className="snowwar-official-hud__ammo-bg"
                                        draggable={false}
                                        src={getHudAssetUrl('ui_ball_indicator_bg')}
                                    />
                                    {Array.from({ length: INITIAL_SNOWBALL_COUNT }, (_, index) => (
                                        <img
                                            key={index}
                                            alt=""
                                            className="snowwar-official-hud__ammo-ball"
                                            draggable={false}
                                            src={getHudAssetUrl('ui_ball')}
                                            style={{ top: 10 + (index * 37), visibility: index < ownAvatar.snowballCount ? 'visible' : 'hidden' }}
                                        />
                                    ))}
                                    {/* AIR SnowWarUI: ui_no_balls_ flashes while empty, load_ spins while making a ball. */}
                                    {(ownAvatar.snowballCount === 0) && (ownAvatar.activityState !== SNOWWAR_STATE_CREATING) && (
                                        <img
                                            alt=""
                                            className="snowwar-official-hud__ammo-empty"
                                            draggable={false}
                                            src={getHudAssetUrl(`ui_no_balls_${getPingPongFrame(frameNow, 4, 75)}`)}
                                        />
                                    )}
                                    {(ownAvatar.activityState === SNOWWAR_STATE_CREATING) && (
                                        <img
                                            alt=""
                                            className="snowwar-official-hud__ammo-loading"
                                            draggable={false}
                                            src={getHudAssetUrl(`load_${getLoopFrame(frameNow, 8)}`)}
                                        />
                                    )}
                                    <Hint content={localizeWithFallback('snowwar.make_snowball', 'Scoop up a snowball')}><BaseButton aria-label={localizeWithFallback('snowwar.make_snowball', 'Scoop up a snowball')}
                                        type="button"
                                        className="snowwar-official-hud__make-ball"
                                        disabled={phase !== 'playing' || ownAvatar.snowballCount >= INITIAL_SNOWBALL_COUNT}
                                        onClick={() => createSnowball()}
                                    /></Hint>
                                </div>

                                <div
                                    className="snowwar-official-hud__personal"
                                    title={localizeWithFallback('snowwar.hud.health', 'Energy and personal score')}
                                >
                                    <img
                                        alt=""
                                        className="snowwar-official-hud__personal-bg"
                                        draggable={false}
                                        src={getHudAssetUrl('ui_me_bg')}
                                    />
                                    <img
                                        alt=""
                                        className="snowwar-official-hud__health"
                                        draggable={false}
                                        src={getHudAssetUrl(`ui_me_health_${healthFrame}`)}
                                    />
                                    <div className="snowwar-official-hud__head">
                                        <LayoutAvatarImageView
                                            compactHead
                                            compactHeadPadding={0}
                                            compactHeadSize={52}
                                            direction={2}
                                            figure={ownAvatar.figure}
                                            gender={ownAvatar.gender}
                                            headOnly
                                        />
                                    </div>
                                    {(scoreFlashFrame > 0) && (
                                        <img
                                            alt=""
                                            className="snowwar-official-hud__score-flash"
                                            draggable={false}
                                            src={getHudAssetUrl(`${scoreFlash.plus ? 'ui_me_plus_' : 'ui_me_minus_'}${scoreFlashFrame}`)}
                                        />
                                    )}
                                    <div className="snowwar-official-hud__personal-score">{ownAvatar.score}</div>
                                </div>
                            </>
                        )}

                        <div className="snowwar-official-hud__scores">
                            <img
                                alt=""
                                className="snowwar-official-hud__scores-bg"
                                draggable={false}
                                src={getHudAssetUrl('ui_timer_and_points')}
                            />
                            {/* AIR snowwar_team_scores / snowwar_timer: white over UbuntuThick strokes. */}
                            <div className="snowwar-official-hud__score snowwar-official-hud__score--blue"><SnowWarStrokeText stroke="#1077ac" text={blueScore} /></div>
                            <div className="snowwar-official-hud__score snowwar-official-hud__score--red"><SnowWarStrokeText stroke="#fd6859" text={redScore} /></div>
                            <div className="snowwar-official-hud__timer"><SnowWarStrokeText stroke="#1077ac" text={formatClock(secondsLeft)} /></div>
                            <div className="snowwar-official-hud__time-label">{localizeWithFallback('snowwar.hud.time_left', 'Time left')}</div>
                        </div>

                        {levelData.canEditRoom && (
                            <BaseButton type="button" className="snowwar-button snowwar-official-hud__edit" onClick={() => startEditing()}>
                                {localizeWithFallback('snowwar.editor.build_custom', 'Build Custom Arena')}
                            </BaseButton>
                        )}
                    </div>
                )}

                {editing && (
                    <div className="snowwar-editor">
                        <div className="snowwar-editor__title">
                            <span className="snowwar-editor__title-ball" />
                            {localizeWithFallback('snowwar.editor.title', 'SnowStorm Arena Workshop')}
                        </div>
                        <div className="snowwar-editor__hint">
                            {localizeWithFallback('snowwar.editor.hint', 'Pick a piece, floor tile, or hotel furni and build directly on the arena. Select / Move and Edit let you refine placed pieces; Walk tests the finished routes.')}
                        </div>
                        <div className="snowwar-editor__shortcuts">
                            <span><kbd>Ctrl / ⌘</kbd> + drag <b>Paint line</b></span>
                            <span><kbd>Shift</kbd> + drag <b>Fill area</b></span>
                        </div>
                        {/* Function controls (modes + tools), grouped and separated
                            from the furni palette below - these are actions, not furni. */}
                        <div className="snowwar-editor__tools snowwar-editor__tools--modes">
                            <BaseButton
                                type="button"
                                className={'snowwar-editor__chip' + (paletteSel === null ? ' snowwar-editor__chip--active' : '')}
                                onClick={() => setPaletteSel(null)}
                            >
                                {localizeWithFallback('snowwar.editor.select', 'Select / Move')}
                            </BaseButton>
                            <BaseButton
                                type="button"
                                className={'snowwar-editor__chip' + (paletteSel === 'edit' ? ' snowwar-editor__chip--active' : '')}
                                onClick={() => setPaletteSel('edit')}
                            >
                                {localizeWithFallback('snowwar.editor.edit', 'Edit')}
                            </BaseButton>
                            <BaseButton
                                type="button"
                                className={'snowwar-editor__chip' + (paletteSel === 'walk' ? ' snowwar-editor__chip--active' : '')}
                                onClick={() => { setPaletteSel('walk'); setSelectedIndex(-1); }}
                            >
                                {localizeWithFallback('snowwar.editor.walk', 'Walk')}
                            </BaseButton>
                            <BaseButton type="button" className="snowwar-button snowwar-button--danger" onClick={() => clearAllItems()}>
                                {localizeWithFallback('snowwar.editor.clear', 'Clear all furni')}
                            </BaseButton>
                        </div>
                        <div className="snowwar-editor__floor-plan">
                            <div className="snowwar-editor__floor-plan-heading">
                                <strong>{localizeWithFallback('snowwar.editor.floor_plan', 'Floor plan')}</strong>
                                <span>{walkableTileCount} {localizeWithFallback('snowwar.editor.walkable_tiles', 'walkable tiles')}</span>
                            </div>
                            <div className="snowwar-editor__hint">
                                {localizeWithFallback('snowwar.editor.floor_plan_hint', 'Open the full floor plan editor to shape the playable snowfield with its canvas, height tools, selection, undo and redo.')}
                            </div>
                            <div className="snowwar-editor__tools">
                                <BaseButton type="button" className="snowwar-button" onClick={() => setFloorplanEditorVisible(true)}>
                                    {localizeWithFallback('snowwar.editor.floor_open', 'Edit floor plan')}
                                </BaseButton>
                            </div>
                        </div>
                        {EDITOR_PALETTE.length > 0 && (
                            <div className="snowwar-editor__palette">
                                {EDITOR_PALETTE.map(name => (
                                    <BaseButton
                                        key={name}
                                        type="button"
                                        className={'snowwar-editor__chip' + (paletteSel === name ? ' snowwar-editor__chip--active' : '')}
                                        onClick={() => { setPaletteSel(prev => prev === name ? null : name); setSelectedIndex(-1); setHoverTile(null); }}
                                    >
                                        {name.replace('block_', '').replace('obst_', '').replace('sw_', '')}
                                    </BaseButton>
                                ))}
                            </div>
                        )}
                        <input
                            type="text"
                            className="snowwar-editor__search"
                            value={furniSearch}
                            placeholder={localizeWithFallback('snowwar.editor.furni_search', 'Search furniture to place...')}
                            onChange={event => setFurniSearch(event.target.value)}
                        />
                        {furniMatches.length > 0 && (
                            <div className="snowwar-editor__palette snowwar-editor__palette--furni">
                                {furniMatches.map(furni => (
                                    <Hint key={furni.id} content={furni.className}><BaseButton
                                        type="button"
                                        className={'snowwar-editor__chip' + (paletteSel === furni.className ? ' snowwar-editor__chip--active' : '')}
                                        onClick={() => { setPaletteSel(prev => prev === furni.className ? null : furni.className); setSelectedIndex(-1); setHoverTile(null); }}
                                    >
                                        {(furni.name && furni.name.trim()) || furni.className}
                                    </BaseButton></Hint>
                                ))}
                            </div>
                        )}
                        {placingFurni && (
                            <div className="snowwar-editor__preview">
                                <span className="snowwar-editor__preview-label">{localizeWithFallback('snowwar.editor.placing', 'Placing')}:</span>
                                <LayoutFurniImageView direction={2} productClassId={placingFurni.id} productType="s" style={{ transform: 'scale(0.5)' }} />
                                <span>{(placingFurni.name && placingFurni.name.trim()) || paletteSel}</span>
                            </div>
                        )}
                        {selectedItem && (
                            <div className="snowwar-editor__selected">
                                <div className="snowwar-editor__preview">
                                    {selectedFurni
                                        ? <LayoutFurniImageView direction={selectedItem.rotation} productClassId={selectedFurni.id} productType="s" state={selectedItem.state ?? 0} style={{ transform: 'scale(0.5)' }} />
                                        : <div className="snowwar-furni__fallback" />}
                                    <span>{(selectedFurni?.name && selectedFurni.name.trim()) || selectedItem.name}</span>
                                </div>
                                <div className="snowwar-editor__tools">
                                    <BaseButton type="button" className="snowwar-button" onClick={() => rotateSelected()}>
                                        {localizeWithFallback('snowwar.editor.rotate', 'Rotate')}
                                    </BaseButton>
                                    <BaseButton type="button" className="snowwar-button snowwar-button--danger" onClick={() => deleteSelected()}>
                                        {localizeWithFallback('snowwar.editor.delete', 'Delete')}
                                    </BaseButton>
                                </div>
                                {!selectedItem.imageUrl && selectedStateMax > 0 && (
                                    <div className="snowwar-editor__state">
                                        <span>{localizeWithFallback('snowwar.editor.state', 'State')}</span>
                                        <BaseButton
                                            type="button"
                                            className="snowwar-button snowwar-button--icon"
                                            disabled={(selectedItem.state ?? 0) <= 0}
                                            onClick={() => cycleState(-1)}>
                                            &#8722;
                                        </BaseButton>
                                        <span className="snowwar-editor__state-value">
                                            {(selectedItem.state ?? 0)}{selectedStateCount ? ` / ${selectedStateMax}` : ''}
                                        </span>
                                        <BaseButton
                                            type="button"
                                            className="snowwar-button snowwar-button--icon"
                                            disabled={(selectedItem.state ?? 0) >= selectedStateMax}
                                            onClick={() => cycleState(1)}>
                                            +
                                        </BaseButton>
                                    </div>
                                )}
                            </div>
                        )}

                        <div className="snowwar-editor__selected">
                            <div className="snowwar-editor__field snowwar-editor__field--stack">
                                <span>{localizeWithFallback('snowwar.editor.bg', 'Arena background (ads_bg)')}</span>
                                <input
                                    type="text"
                                    className="snowwar-editor__search"
                                    value={backdropItem?.imageUrl ?? ''}
                                    placeholder={localizeWithFallback('snowwar.editor.bg_url', 'Full-screen background image URL...')}
                                    onChange={event => setBackdropUrl(event.target.value)}
                                />
                            </div>
                            {backdropItem && (
                                <label className="snowwar-editor__field">
                                    {localizeWithFallback('snowwar.editor.overlay', 'Overlay floor tiles (hide tiles behind image)')}
                                    <Checkbox checked={(backdropItem.offsetZ ?? 0) > 0} onCheckedChange={(nextChecked) => setBackdropOffsetZ(nextChecked ? 1 : 0)} />
                                </label>
                            )}
                        </div>
                    </div>
                )}
                {editing && floorplanEditorVisible && (
                    <FloorplanEditorView
                        externalSession={{
                            tilemap: editHeightmap.join('\r'),
                            occupiedTiles: floorplanOccupiedTiles,
                            title: localizeWithFallback('snowwar.editor.floor_title', 'SnowStorm Floor Plan Editor'),
                            onClose: () => setFloorplanEditorVisible(false),
                            onSave: applyEditorFloorPlan,
                        }}
                    />
                )}
                {(frameNow - savedAt) < 2500 && savedAt > 0 && (
                    <div className="snowwar-banner snowwar-banner--saved">
                        {localizeWithFallback('snowwar.editor.published', 'Custom arena published to the game pool!')}
                    </div>
                )}

                {phase === 'preparing' && !editing && (countdownFrame === 0) && (
                    <div className="snowwar-banner snowwar-banner--countdown">
                        {localizeWithFallback('snowwar.loading.title', 'Get ready!')} {preparingSeconds > 0 ? preparingSeconds : ''}
                    </div>
                )}
                {/* AIR counter: digits 5..1, then the explosion as the match starts. */}
                {!editing && (countdownFrame > 0) && (
                    <div className="snowwar-countdown" aria-hidden="true">
                        <img
                            alt=""
                            draggable={false}
                            src={getHudAssetUrl(getExplosionAssetName(countdownFrame))}
                            style={{ left: -SNOWWAR_EXPLOSION_OFFSETS[countdownFrame][0], top: -SNOWWAR_EXPLOSION_OFFSETS[countdownFrame][1] }}
                        />
                    </div>
                )}
                {(frameNow - rangeWarningAt) < 2000 && rangeWarningAt > 0 && (
                    <div className="snowwar-banner snowwar-banner--warning">
                        {localizeWithFallback('snowwar.throw.too_far', 'Too far away! Use a long throw or move closer.')}
                    </div>
                )}
                {phase === 'ending' && (
                    <div className="snowwar-banner snowwar-banner--countdown">
                        {localizeWithFallback('snowwar.time_up', 'Time is up!')}
                    </div>
                )}

                <div className="snowwar-viewport-wrap">
                <div
                    ref={viewportRef}
                    className="snowwar-viewport"
                    onClick={onArenaClick}
                    onContextMenu={onArenaClick}
                    onPointerDown={editing ? onEditorPointerDown : undefined}
                    onPointerMove={editing ? onEditorPointerMove : undefined}
                    onPointerUp={editing ? onEditorPointerUp : undefined}
                    onPointerCancel={editing ? onEditorPointerCancel : undefined}
                    onMouseLeave={editing ? () => setHoverTile(null) : undefined}
                >
                    <div
                        className="snowwar-world"
                        // Snap the camera translate to whole pixels. The easing keeps
                        // running in floats (cam.x/cam.y), but a fractional translate here
                        // makes the browser rasterise the whole arena subtree at a sub-pixel
                        // offset, so every scaled sprite (furni at 0.375, avatars) samples
                        // between pixels and looks blurry / shimmers while the camera moves.
                        style={{ width: stageW, height: stageH, transform: `translate(${Math.round(cameraX)}px, ${Math.round(cameraY)}px)`, transformOrigin: '0 0' }}
                    >
                        {officialBackdrop && arenaBackdrop && (
                            <img
                                key={officialBackdrop.file}
                                alt=""
                                className="snowwar-official-backdrop"
                                draggable={false}
                                src={backdropLibraryFailed[officialBackdrop.file] ? arenaBackdrop.imageUrl : getOfficialSnowWarAssetUrl(officialBackdrop.file)}
                                style={officialBackdropRect ?? undefined}
                                onLoad={event =>
                                {
                                    const { naturalWidth, naturalHeight } = event.currentTarget;
                                    const file = officialBackdrop.file;
                                    if (naturalWidth > 0 && naturalHeight > 0) setBackdropRatios(ratios => ({ ...ratios, [file]: naturalHeight / naturalWidth }));
                                }}
                                onError={() =>
                                {
                                    const file = officialBackdrop.file;
                                    setBackdropLibraryFailed(failed => (failed[file] ? failed : { ...failed, [file]: true }));
                                }}
                            />
                        )}
                        {arenaBackdrop && !officialBackdrop && !backdropOverlay && (
                            <img
                                alt=""
                                className="snowwar-arena-bg"
                                draggable={false}
                                src={arenaBackdrop.imageUrl}
                            />
                        )}
                        <div
                            className="snowwar-floor-layer"
                            style={{ left: floorOffsetX, top: floorOffsetY, width: canvasWidth, height: canvasHeight, transform: `scale(${zoom})`, transformOrigin: '0 0' }}
                        >
                            <canvas
                                ref={canvasRef}
                                width={canvasWidth}
                                height={canvasHeight}
                                className={`snowwar-floor${officialBackdrop ? ' snowwar-floor--official-backdrop' : ''}`}
                            />

                        {arenaBackdrop && !officialBackdrop && backdropOverlay && (
                            <img
                                alt=""
                                className="snowwar-arena-bg-overlay"
                                draggable={false}
                                src={arenaBackdrop.imageUrl}
                                style={{ width: canvasWidth, height: canvasHeight }}
                            />
                        )}

                        {/* Classic props, above the overlay so they aren't hidden. */}
                        <canvas ref={propsCanvasRef} width={canvasWidth} height={canvasHeight} className="snowwar-floor snowwar-floor--props" />

                        {editorAreaOutline && (
                            <svg
                                aria-hidden="true"
                                className="snowwar-edit-area"
                                width={editorAreaOutline.width}
                                height={editorAreaOutline.height}
                                style={{ left: editorAreaOutline.left, top: editorAreaOutline.top }}
                            >
                                <polygon points={editorAreaOutline.points} />
                            </svg>
                        )}

                        {displayItems
                            .filter(item => !isClassicItem(item.name) && !item.imageUrl)
                            // While a selected furni is being moved (its ghost is
                            // following the cursor), hide its copy at the old tile so
                            // it isn't shown twice.
                            .filter(item => !(movingSelected && item === selectedItem))
                            .map((item, index) =>
                        {
                            // A multi-tile furni occupies width x length tiles from its
                            // origin (+x/+y, swapped for the 90/270 rotations) - the same
                            // footprint the server blocks. Draw the sprite over the
                            // footprint CENTRE (a 1x1 prop is unchanged) and depth-sort by
                            // the FRONT (nearest-camera) tile, so a 3x3 prop sits on and
                            // occludes its whole footprint instead of drawing over an
                            // avatar standing beside or in front of it.
                            const swap = item.rotation === 2 || item.rotation === 6;
                            const effW = Math.max(1, (swap ? item.length : item.width) ?? 1);
                            const effL = Math.max(1, (swap ? item.width : item.length) ?? 1);
                            // Ground the furni at the FRONT (nearest-camera) corner of its
                            // footprint and anchor the image by its BOTTOM-CENTRE there, so
                            // its base rests on the floor (size-independent; a fixed % lift
                            // floated tall props). Depth, however, sorts by the ORIGIN (back)
                            // tile - the same rule Octane uses for a real floor furni - so an
                            // avatar on the near sides (left/front) draws in front of the
                            // furni and only one standing behind it is covered.
                            const front = toScreen(item.x + effW - 1, item.y + effL - 1);
                            // Depth anchor = origin tile CENTRE (+ TILE_HALF_H), matching how
                            // avatars anchor (worldToScreen is tile-centre). Without the
                            // half-tile offset the furni sorted half a tile behind where it
                            // should, which tipped the overlay the wrong way for an avatar
                            // standing right at a corner.
                            // AIR stores furni altitude in 1600 world units per room
                            // floor. Convert that to one rendered tile-height so the
                            // stacked Arctic Island snow blocks keep their original
                            // elevation instead of collapsing onto each other.
                            const altitudePx = Math.max(0, item.offsetZ ?? 0) / 1600 * (TILE_HALF_H * 2);
                            const originY = toScreen(item.x, item.y).y + TILE_HALF_H - altitudePx;

                            // A walkable furni (walkableHeight 0 - a rug/floor tile you
                            // stand ON) is flat on the ground and must NEVER occlude an
                            // avatar. Depth-sorting the whole sprite by one tile lets its
                            // front edge out-sort an avatar standing on it, so put flat
                            // furni in a low band (above the floor + overlay, below the
                            // machines at 90 and the avatars/solid furni at 100+). Solid
                            // furni keep the shared depth band so they occlude correctly.
                            const furniData = GetSessionDataManager()?.getFloorItemDataByName?.(item.name);
                            // Flat = a walkable floor furni (rug / water / tile you stand
                            // ON). Prefer the server's walkableHeight; for a freshly-placed
                            // editor furni that doesn't carry it yet, fall back to the
                            // furnidata "can stand on" flag so it's still drawn below the
                            // avatar.
                            const flat = item.walkableHeight != null
                                ? item.walkableHeight === 0
                                : !!(furniData?.canStandOn || furniData?.canLayOn);
                            const zIndex = flat
                                ? 3 + Math.min(80, Math.max(0, Math.round(originY / 8)))
                                : 100 + Math.round(originY);
                            const tree = simulation.trees.get(`${item.x},${item.y}`);
                            const pile = simulation.piles.get(`${item.x},${item.y}`);
                            const state = tree
                                ? Math.min(tree.maximumHits - 1, tree.hits)
                                : pile
                                    ? pile.maxSnowballs - pile.snowballCount
                                    : (item.state ?? 0);

                            return (
                                <div
                                    key={`furni-${index}`}
                                    className="snowwar-furni"
                                    data-snowwar-item={item.name}
                                    data-snowwar-x={item.x}
                                    data-snowwar-y={item.y}
                                    data-snowwar-count={pile?.snowballCount}
                                    style={{ left: front.x, top: front.y + (TILE_HALF_H * 2) - altitudePx, zIndex }}
                                    title={pile ? localizeWithFallback('snowwar.pile.hint', '%count% snowballs left').replace('%count%', pile.snowballCount.toString()) : undefined}
                                    onClick={pile ? event =>
                                    {
                                        event.stopPropagation();
                                        const candidates = [
                                            { x: item.x, y: item.y - 1 },
                                            { x: item.x + 1, y: item.y },
                                            { x: item.x, y: item.y + 1 },
                                            { x: item.x - 1, y: item.y },
                                        ].filter(candidate =>
                                        {
                                            const tile = mapRows[candidate.y]?.charAt(candidate.x);
                                            if (!tile || tile === 'x' || tile === 'X') return false;
                                            if (levelData.machines.some(machine =>
                                                candidate.y === machine.y && candidate.x >= machine.x && candidate.x <= machine.x + 2)) return false;
                                            return !displayItems.some(other =>
                                            {
                                                if (other === item || (other.walkableHeight ?? 0) <= 0) return false;
                                                const rotated = other.rotation === 2 || other.rotation === 6;
                                                const width = Math.max(1, (rotated ? other.length : other.width) ?? 1);
                                                const length = Math.max(1, (rotated ? other.width : other.length) ?? 1);
                                                return candidate.x >= other.x && candidate.x < other.x + width
                                                    && candidate.y >= other.y && candidate.y < other.y + length;
                                            });
                                        });
                                        candidates.sort((a, b) =>
                                            (Math.abs(a.x - (ownAvatar?.tileX ?? a.x)) + Math.abs(a.y - (ownAvatar?.tileY ?? a.y)))
                                            - (Math.abs(b.x - (ownAvatar?.tileX ?? b.x)) + Math.abs(b.y - (ownAvatar?.tileY ?? b.y))));
                                        if (candidates[0]) walkTo(tileToWorld(candidates[0].x), tileToWorld(candidates[0].y));
                                    } : undefined}
                                >
                                    {item.name === 'ads_igorraygun' && !editing && (
                                        <Hint content={localizeWithFallback('snowwar.raygun.use', 'Use Ray Gun')}>
                                            <BaseButton
                                                type="button"
                                                className="snowwar-raygun-hitbox"
                                                aria-label={localizeWithFallback('snowwar.raygun.use', 'Use Ray Gun')}
                                                onClick={event => fireRayGun(event, item.x, item.y, item.rotation, item.width ?? 1, item.length ?? 1)}
                                            />
                                        </Hint>
                                    )}
                                    {item.name === 'snst_ballpile'
                                        ? furniData
                                            ? <LayoutFurniImageView
                                                direction={item.rotation}
                                                productClassId={furniData.id}
                                                productType="s"
                                                state={state}
                                                style={{ position: 'absolute', transformOrigin: 'center bottom', transform: `translate(-50%, -100%) scale(${SNOWWAR_GAME_SPRITE_SCALE})` }}
                                            />
                                            : <img
                                                alt=""
                                                className="snowwar-official-ballpile"
                                                draggable={false}
                                                src={snowballPileImage}
                                                style={{ position: 'absolute', width: 23, height: 18, maxWidth: 'none', transformOrigin: 'center bottom', transform: `translate(-50%, -100%) scale(${SNOWWAR_GAME_SPRITE_SCALE})` }}
                                            />
                                        : furniData
                                        ? <LayoutFurniImageView
                                            direction={item.rotation}
                                            productClassId={furniData.id}
                                            productType="s"
                                            state={state}
                                            style={{ position: 'absolute', transformOrigin: 'center bottom', transform: `translate(-50%, -100%) scale(${SNOWWAR_FURNI_SCALE})` }}
                                        />
                                        : <div className="snowwar-furni__fallback" />}
                                </div>
                            );
                        })}

                        {editing && ghostFurni && hoverTile && (placingFurni || movingSelected) && (() =>
                        {
                            const swap = ghostRotation === 2 || ghostRotation === 6;
                            const effW = Math.max(1, (swap ? ghostFurni.tileSizeY : ghostFurni.tileSizeX) || 1);
                            const effL = Math.max(1, (swap ? ghostFurni.tileSizeX : ghostFurni.tileSizeY) || 1);
                            const front = toScreen(hoverTile.x + effW - 1, hoverTile.y + effL - 1);
                            const originY = toScreen(hoverTile.x, hoverTile.y).y + TILE_HALF_H;
                            return (
                                <div
                                    className="snowwar-furni snowwar-furni--ghost"
                                    style={{ left: front.x, top: front.y + (TILE_HALF_H * 2), zIndex: 100 + Math.round(originY), opacity: 0.8, pointerEvents: 'none' }}
                                >
                                    <LayoutFurniImageView
                                        direction={ghostRotation}
                                        productClassId={ghostFurni.id}
                                        productType="s"
                                        state={ghostState}
                                        style={{ position: 'absolute', transformOrigin: 'center bottom', transform: `translate(-50%, -100%) scale(${SNOWWAR_FURNI_SCALE})` }}
                                    />
                                </div>
                            );
                        })()}

                        {/* Tall classic props (trees / snowman / fences / blocks) as
                            per-tile sprites, depth-sorted with the avatars so one
                            standing behind a prop is occluded, like hotel furni. */}
                        {displayItems
                            .filter(item => isClassicItem(item.name) && !isFlatFloorProp(item.name) && !item.name.startsWith('snowball_machine'))
                            .map((item, index) =>
                            {
                                const { x: screenX, y: screenY } = toScreen(item.x, item.y);
                                return (
                                    <div
                                        key={`classic-${index}`}
                                        className="snowwar-classic-prop"
                                        data-snowwar-item={item.name}
                                        data-snowwar-x={item.x}
                                        data-snowwar-y={item.y}
                                        style={{ left: screenX - (PROP_BOX_W / 2), top: screenY - PROP_ANCHOR_Y, zIndex: 100 + Math.round(screenY + TILE_HALF_H) }}
                                    >
                                        <ClassicPropSprite name={item.name} rotation={item.rotation} />
                                    </div>
                                );
                            })}

                        {editing && paletteSel && isClassicItem(paletteSel) && !isFlatFloorProp(paletteSel) && !paletteSel.startsWith('snowball_machine') && hoverTile && (() =>
                        {
                            const { x: screenX, y: screenY } = toScreen(hoverTile.x, hoverTile.y);
                            return (
                                <div
                                    className="snowwar-classic-prop"
                                    style={{ left: screenX - (PROP_BOX_W / 2), top: screenY - PROP_ANCHOR_Y, zIndex: 100 + Math.round(screenY + TILE_HALF_H) }}
                                >
                                    <ClassicPropSprite name={paletteSel} rotation={0} opacity={0.55} />
                                </div>
                            );
                        })()}

                        {!editing && levelData.machines.map(machine =>
                        {
                            const state = simulation.machines.get(machine.objectId);
                            const { x, y } = toScreen(machine.x, machine.y);
                            const snowballCount = Math.max(0, Math.min(5, state?.snowballCount ?? 0));
                            const indicatorFrame = Math.floor(frameNow / 250) % 2;

                            return (
                                <div
                                    key={machine.objectId}
                                    // Depth-sort the original machine like a floor furni (origin-tile
                                    // anchor, same formula as avatars/furni) so it draws in
                                    // front of anyone behind it and behind anyone in front,
                                    // and sorts correctly against normal furniture too.
                                    className="snowwar-machine"
                                    // Depth anchor = tile CENTRE (+ TILE_HALF_H) to match the
                                    // avatar reference, so the machine sorts cleanly at corners.
                                    style={{ left: x, top: y + (TILE_HALF_H * 2), zIndex: 100 + Math.round(y + TILE_HALF_H), transform: `scale(${SNOWWAR_GAME_SPRITE_SCALE})` }}
                                    title={localizeWithFallback('snowwar.machine.hint', 'Walk here to collect snowballs')}
                                    // Clicking the machine walks you to its pickup tile (the
                                    // tile in front of its left cell), where the server's
                                    // auto-collect tops you up - it isn't an instant click
                                    // grab, so give the player the one-click "go get ammo".
                                    onClick={event =>
                                    {
                                        event.stopPropagation();
                                        walkTo(tileToWorld(machine.x), tileToWorld(machine.y + 1));
                                    }}
                                >
                                    <img
                                        alt=""
                                        className="snowwar-machine__official-shadow"
                                        draggable={false}
                                        height={26}
                                        src={getOfficialSnowWarAssetUrl('s_snowball_machine_32_sd.png')}
                                        width={56}
                                    />
                                    <img
                                        alt=""
                                        className="snowwar-machine__official-body"
                                        draggable={false}
                                        height={57}
                                        src={getOfficialSnowWarAssetUrl(`s_snowball_machine_32_a_0_${snowballCount}.png`)}
                                        width={41}
                                    />
                                    <img
                                        alt=""
                                        className="snowwar-machine__official-indicator"
                                        draggable={false}
                                        height={38}
                                        src={getOfficialSnowWarAssetUrl(`s_snowball_machine_32_b_0_${indicatorFrame}.png`)}
                                        width={32}
                                    />
                                    <div className="snowwar-machine__count">{state?.snowballCount ?? 0}</div>
                                </div>
                            );
                        })}

                        {editing && editItems.map((item, index) => item.imageUrl
                            ? (() =>
                            {
                                const { x, y } = toScreen(item.x, item.y);
                                return <div key={`admarker-${index}`} className="snowwar-edit-admarker" style={{ left: x, top: y + TILE_HALF_H }}>🖼</div>;
                            })()
                            : null)}

                        {editing && selectedIndex >= 0 && editItems[selectedIndex] && !movingSelected && (() =>
                        {
                            // Hidden while the ghost is following the cursor (a move in
                            // progress) - the ghost is the indicator then, so the box
                            // isn't left floating at the old/empty tile.
                            // Outline the WHOLE footprint as one isometric parallelogram
                            // (rotation swaps width/length) so the selection follows the
                            // tile angle instead of a grid of axis-aligned boxes.
                            const sel = editItems[selectedIndex];
                            const swap = sel.rotation === 2 || sel.rotation === 6;
                            const effW = Math.max(1, (swap ? sel.length : sel.width) ?? 1);
                            const effL = Math.max(1, (swap ? sel.width : sel.length) ?? 1);
                            // The four extreme tile-diamond vertices of the footprint.
                            const topV = toScreen(sel.x, sel.y);
                            const rightV = toScreen(sel.x + effW - 1, sel.y);
                            const bottomV = toScreen(sel.x + effW - 1, sel.y + effL - 1);
                            const leftV = toScreen(sel.x, sel.y + effL - 1);
                            const corners = [
                                { x: topV.x, y: topV.y },
                                { x: rightV.x + TILE_HALF_W, y: rightV.y + TILE_HALF_H },
                                { x: bottomV.x, y: bottomV.y + (TILE_HALF_H * 2) },
                                { x: leftV.x - TILE_HALF_W, y: leftV.y + TILE_HALF_H },
                            ];
                            const minX = Math.min(...corners.map(c => c.x));
                            const minY = Math.min(...corners.map(c => c.y));
                            const boxW = Math.max(...corners.map(c => c.x)) - minX;
                            const boxH = Math.max(...corners.map(c => c.y)) - minY;
                            const points = corners.map(c => `${c.x - minX},${c.y - minY}`).join(' ');
                            return (
                                <svg className="snowwar-edit-selection" width={boxW} height={boxH} style={{ left: minX, top: minY }}>
                                    <polygon points={points} />
                                </svg>
                            );
                        })()}

                        {[...simulation.snowballs.values()].map(ball =>
                        {
                            const lx = ball.prevLocH + (ball.locH - ball.prevLocH) * alpha;
                            const ly = ball.prevLocV + (ball.locV - ball.prevLocV) * alpha;
                            const lh = Math.max(0, ball.prevHeight + (ball.height - ball.prevHeight) * alpha);
                            const { x, y } = worldToScreen(lx, ly);
                            const rise = snowballRise(lh);
                            return (
                                <div
                                    key={ball.objectId}
                                    className="snowwar-snowball"
                                    style={{ left: x, top: y, transform: `scale(${SNOWWAR_GAME_SPRITE_SCALE})` }}
                                >
                                    <img alt="" className="snowwar-snowball__shadow" src={SNOWWAR_SNOWBALL_SHADOW} />
                                    <img alt="" className="snowwar-snowball__ball" src={SNOWWAR_SNOWBALL_IMAGE} style={{ transform: `translateY(${-rise}px)` }} />
                                </div>
                            );
                        })}

                        {splashes.map(splash =>
                            <div
                                key={splash.id}
                                className="snowwar-splash"
                                style={{ left: splash.x, top: splash.y, transform: `translate(-50%, -50%) scale(${SNOWWAR_GAME_SPRITE_SCALE})` }}
                                onAnimationEnd={() => setSplashes(list => list.filter(item => item.id !== splash.id))}
                            >
                                {SNOWWAR_SPLASH_FRAMES.map((frame, index) =>
                                    <img alt="" className={`snowwar-splash__frame snowwar-splash__frame--${index + 1}`} key={frame} src={frame} />)}
                            </div>
                        )}

                        {!editing && [...simulation.avatars.values()].map(avatar =>
                        {
                            const ax = avatar.prevWorldX + (avatar.worldX - avatar.prevWorldX) * alpha;
                            const ay = avatar.prevWorldY + (avatar.worldY - avatar.prevWorldY) * alpha;
                            const { x, y } = worldToScreen(ax, ay);
                            const isOwn = avatar.userId === ownUserId;
                            const walking = (avatar.worldX !== avatar.prevWorldX) || (avatar.worldY !== avatar.prevWorldY);
                            const throwUntil = throwPoseUntilRef.current.get(avatar.objectId) ?? 0;
                            const throwing = frameNow < throwUntil;
                            const throwProgress = throwing ? 1 - (throwUntil - frameNow) / SNOWWAR_THROW_POSE_MS : 0;
                            const stunned = avatar.activityState === SNOWWAR_STATE_STUNNED;
                            const invincible = avatar.activityState === SNOWWAR_STATE_INVINCIBLE;
                            const throwAtOpponent = (event: MouseEvent<HTMLDivElement>, trajectory: number) =>
                            {
                                event.preventDefault();
                                event.stopPropagation();
                                if (!ownAvatar || avatar.teamId === ownAvatar.teamId) return;
                                if (!isThrowInRange(ownAvatar.worldX, ownAvatar.worldY, avatar.worldX, avatar.worldY, trajectory))
                                {
                                    setRangeWarningAt(Date.now());
                                    return;
                                }
                                throwAtPlayer(avatar.objectId, trajectory);
                            };

                            return (
                                <div
                                    key={avatar.objectId}
                                    className={
                                        'snowwar-avatar' +
                                        (stunned ? ' snowwar-avatar--stunned' : '') +
                                        (invincible ? ' snowwar-avatar--invincible' : '')
                                    }
                                    style={{ left: x, top: y, zIndex: 100 + Math.round(y) }}
                                    onClick={event =>
                                    {
                                        if (isOwn)
                                        {
                                            event.stopPropagation();
                                            createSnowball();
                                            return;
                                        }
                                        throwAtOpponent(event, getAirTrajectory(event.altKey, event.shiftKey, true) ?? TRAJECTORY_DEFAULT);
                                    }}
                                    onContextMenu={event =>
                                    {
                                        if (isOwn)
                                        {
                                            event.preventDefault();
                                            event.stopPropagation();
                                            return;
                                        }
                                        throwAtOpponent(event, getAirTrajectory(event.altKey, event.shiftKey, true) ?? TRAJECTORY_DEFAULT);
                                    }}
                                >
                                    <div aria-hidden="true" className="snowwar-avatar__hitbox" />
                                    <div
                                        className={'snowwar-avatar__name' + (isOwn ? ' snowwar-avatar__name--own' : '')}
                                        style={{ color: TEAM_COLORS[avatar.teamId % TEAM_COLORS.length] }}
                                    >
                                        {avatar.name}
                                    </div>
                                    <span className="snowwar-avatar__body">
                                        <SnowWarAvatarView
                                            figure={avatar.figure}
                                            gender={avatar.gender}
                                            teamId={avatar.teamId}
                                            direction={avatar.rotation}
                                            walking={walking && !stunned}
                                            stunned={stunned}
                                            stunElapsedMs={(STUN_TIME - avatar.activityTimer) * SUBTURN_MS}
                                            picking={avatar.activityState === SNOWWAR_STATE_CREATING}
                                            throwing={throwing && !stunned}
                                            throwProgress={throwProgress}
                                            frameNow={frameNow}
                                            scale={SNOWWAR_FURNI_SCALE}
                                        />
                                    </span>
                                </div>
                            );
                        })}

                        {editorWalker && (() =>
                        {
                            // Client-side preview avatar shown only in the editor (the
                            // game simulation is empty here). Anchored the same way as an
                            // in-game avatar so it lines up with the tiles it walks.
                            const { x, y } = toScreen(editorWalker.x + 0.5, editorWalker.y + 0.5);
                            return (
                                <div
                                    className="snowwar-avatar snowwar-avatar--preview"
                                    style={{ left: x, top: y, zIndex: 100 + Math.round(y) }}
                                >
                                    <span className="snowwar-avatar__body">
                                        <SnowWarAvatarView
                                            figure={editorWalkerFigure}
                                            gender={editorWalkerGender}
                                            direction={editorWalker.dir}
                                            walking={editorWalker.walking}
                                            throwing={false}
                                            throwProgress={0}
                                            frameNow={frameNow}
                                            scale={SNOWWAR_FURNI_SCALE}
                                        />
                                    </span>
                                </div>
                            );
                        })()}
                        </div>
                    </div>
                </div>
                </div>

                {!editing && visibleChatMessages.map(({ avatar, message, stackIndex }) => (
                    <div
                        key={message.id}
                        className={`snowwar-chat-bubble snowwar-chat-bubble--${avatar.teamId === 1 ? 'blue' : 'red'}`}
                        style={{
                            left: `calc(50% + ${avatar.teamId === 1 ? -300 : 300}px)`,
                            top: `calc(25% - ${stackIndex * 36}px)`,
                        }}
                    >
                        <div className="snowwar-chat-bubble__face">
                            <LayoutAvatarImageView
                                compactHead
                                compactHeadPadding={0}
                                compactHeadSize={22}
                                direction={2}
                                figure={avatar.figure}
                                gender={avatar.gender}
                                headOnly
                            />
                        </div>
                        <span className="snowwar-chat-bubble__text"><b>{avatar.name}: </b>{message.message}</span>
                    </div>
                ))}

                {!editing && (
                    // AIR keeps the room chat input in a game session (RoomChatInputView,
                    // centred at desktop height - 104, no style menu) and sends its text as
                    // Game2GameChat. Same skin as the room chat bar; the style button is inert.
                    // Typing does not grab focus here, so the QWEADZXC movement keys keep working.
                    <div className="snowwar-chat-input">
                        <div className="octane-chat-input-container swf-chat-input relative flex w-full items-center justify-start overflow-visible">
                            <div aria-hidden="true" className="swf-chat-style-trigger flex items-center select-none">
                                <svg className="swf-chat-style-arrow shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path d="M19 9l-7 7-7-7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} />
                                </svg>
                                <div className="swf-chat-style-icon" />
                            </div>
                            <div className="flex-1 items-center input-sizer swf-chat-input-sizer" data-value={chatInput}>
                                <input
                                    ref={chatInputRef}
                                    aria-label={localizeWithFallback('widgets.chatinput.default', 'Click here to chat')}
                                    className="swf-chat-input-field w-full border-none bg-transparent"
                                    maxLength={100}
                                    placeholder={localizeWithFallback('widgets.chatinput.default', 'Click here to chat')}
                                    type="text"
                                    value={chatInput}
                                    onChange={event => setChatInput(event.target.value)}
                                    onKeyDown={event =>
                                    {
                                        if ((event.key === 'Enter') || (event.key === 'NumpadEnter'))
                                        {
                                            event.preventDefault();
                                            submitChat();
                                        }
                                        else if (event.key === 'Escape')
                                        {
                                            event.currentTarget.blur();
                                        }
                                    }}
                                />
                            </div>
                        </div>
                    </div>
                )}

                <div className="snowwar-help">
                    {editing
                        ? localizeWithFallback('snowwar.editor.help', 'Editor: click to place • Ctrl/Cmd + drag paints • Shift + drag fills an area • one piece per occupied tile')
                        : localizeWithFallback('snowwar.help', 'Click: walk • Enemy: adaptive throw • Shift: quick • Alt: long lob • Alt+Shift: short lob • Arrows/QWEADZXC: move • Right-click: quick')}
                </div>
            </div>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
3En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser