5180 fichiers 38.5 Mo 0 téléchargements 265 vues mis à jour il y a 5 heures ui
useSnowWar.ts 36.8 Ko Télécharger le fichier
import {
    Game2GetTotalGroupLeaderboardComposer,
    Game2InArenaQueueMessageEvent,
    Game2GetWeeklyGroupLeaderboardComposer,
    Game2TotalGroupLeaderboardEvent,
    Game2WeeklyFriendsLeaderboardEvent,
    Game2WeeklyGroupLeaderboardEvent,
    Game2WeeklyGroupLeaderboardParser,
    Game2WeeklyLeaderboardEvent,
    SnowWarCreateSnowballComposer,
    SnowWarEditRoomComposer,
    SnowWarExitEditorComposer,
    SnowWarExitGameComposer,
    SnowWarFullGameStatusEvent,
    SnowWarGameChatComposer,
    SnowWarGameEndedEvent,
    SnowWarGameStatusEvent,
    SnowWarGamesInformationEvent,
    SnowWarGamesLeftEvent,
    SnowWarGenericErrorEvent,
    SnowWarGetAllTimeFriendsLeaderboardComposer,
    SnowWarGetAllTimeLeaderboardComposer,
    SnowWarGetWeeklyFriendsLeaderboardComposer,
    SnowWarGetWeeklyLeaderboardComposer,
    SnowWarInitArenaEvent,
    SnowWarJoinQueueComposer,
    SnowWarLeaveQueueComposer,
    SnowWarLevelDataEvent,
    SnowWarLoadStageReadyComposer,
    SnowWarLobbyTeamsEvent,
    SnowWarOnGameEndingEvent,
    SnowWarOnStageEndingEvent,
    SnowWarOnStageRunningEvent,
    SnowWarOnStageStartEvent,
    SnowWarPlayAgainComposer,
    SnowWarPlayerExitedArenaEvent,
    SnowWarQueuePositionEvent,
    SnowWarRejoinPreviousRoomEvent,
    SnowWarRequestFullGameStatusComposer,
    SnowWarSaveEditorComposer,
    SnowWarSelectArenaComposer,
    SnowWarStartLobbyCounterEvent,
    SnowWarThrowAtLocationComposer,
    SnowWarThrowAtPlayerComposer,
    SnowWarUserChatEvent,
    SnowWarUserRematchedEvent,
    SnowWarWalkComposer,
    WeeklyCompetitiveFriendsLeaderboardEvent,
    WeeklyCompetitiveLeaderboardEvent,
} from '@octane/renderer';
import { GetCommunication, OctaneEventType } from '@octane/renderer';
import type { ConnectionStatePhase } from '@octane/renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { registerSharedHook, useSharedHook } from '@/state/useSharedHook';
import { CreateRoomSession, PlaySound, SendMessageComposer, SoundNames, TryVisitRoom } from '../../api';
import {
    consumeSnowWarReturnRoom,
    SNOWWAR_EVENT_MACHINE_TRANSFER,
    SnowWarSimEvent,
    SnowWarSimulation,
    SUBTURN_MS,
    SUBTURNS_PER_TICK,
    snowWarDeadlineFromSeconds,
    snowWarSecondsRemaining,
} from '../../api/snowwar';
import { useMessageEvent, useOctaneEvent } from '../events';

// The server drops the player from queue, lobby and game when the socket goes, so no message ends the
// session: it is cleared as soon as the connection is lost (the phases the reconnect view shows).
// SnowStormGenericError code for "no games left" (SnowWarConstants.ERROR_NO_TICKETS).
const SNOWWAR_ERROR_NO_TICKETS = 4;

const CONNECTION_LOST_PHASES: readonly ConnectionStatePhase[] = ['disconnected', 'reconnecting', 'reauthenticating', 'failed'];

export type SnowWarPhase =
    | 'idle'
    | 'queued'
    | 'lobby'
    | 'loading'
    | 'preparing'
    | 'playing'
    | 'ending'
    | 'results';

export interface SnowWarLevelState {
    gameLengthSeconds: number;
    canEditRoom: boolean;
    arenaName: string;
    mapId: number;
    teamCount: number;
    heightmapRows: string[];
    items: { name: string; x: number; y: number; rotation: number; imageUrl: string; offsetZ: number; walkableHeight?: number; width?: number; length?: number; state?: number; stateCount?: number }[];
    machines: { objectId: number; x: number; y: number }[];
    players: { objectId: number; userId: number; teamId: number; name: string; figure: string; gender: string }[];
}

export interface SnowWarLobbyTeamsState {
    teamCount: number;
    leaderUserId: number;
    selectedArenaId: number;
    arenas: { id: number; name: string; official: boolean }[];
    players: { userId: number; teamId: number; name: string; figure: string; gender: string; skillLevel?: number }[];
}

export interface SnowWarResultsState {
    secondsToResults: number;
    teams: {
        teamId: number;
        score: number;
        players: SnowWarResultPlayer[];
    }[];
    // Player ids the server names for the Most Hits / Most K.O.'s spotlights (0 = none).
    playerWithMostHits?: number;
    playerWithMostKills?: number;
}

/**
 * One row of the end-of-game screen. The ending packet's optional tail adds
 * the figure, gender, hits, K.O.'s and skill level; they stay optional for a
 * server without it (the view then takes the figure from the level data).
 */
export interface SnowWarResultPlayer {
    userId: number;
    name: string;
    score: number;
    figure?: string;
    gender?: string;
    snowballHits?: number;
    kills?: number;
    skillLevel?: number;
}

export interface SnowWarChatMessage {
    id: number;
    objectId: number;
    name: string;
    message: string;
    receivedAt: number;
}

export interface SnowWarLeaderboardState {
    isOpen: boolean;
    weekly: boolean;
    friendsOnly: boolean;
    // AIR group tables (Game2Total/WeeklyGroupLeaderboard): rows are groups,
    // `figure` holds the badge code and `favouriteGroupId` the viewer's group.
    groups: boolean;
    favouriteGroupId: number;
    loading: boolean;
    year: number;
    week: number;
    maxOffset: number;
    currentOffset: number;
    minutesUntilReset: number;
    totalListSize: number;
    entries: { userId: number; score: number; rank: number; name: string; figure: string; gender: string }[];
}

// One shared world per session — the arena view reads it every animation frame.
const SNOWWAR_SIMULATION = new SnowWarSimulation();

// Dev-only console handle: Vite serves HMR-updated modules under versioned
// URLs, so a plain dynamic import from devtools gets a second instance. This
// is the only reliable way to inspect the live replica while debugging.
if ((import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV) (window as Window & { __SNOWWAR_SIM?: SnowWarSimulation }).__SNOWWAR_SIM = SNOWWAR_SIMULATION;

// Sounds fire when the replica APPLIES an event, not when the packet lands —
// the replay can trail the wire by a few subturns, and AIR keys its audio off
// the engine replay too, so this keeps what you hear matched to what you see.
SNOWWAR_SIMULATION.onEventApplied = event =>
{
    switch (event.type)
    {
        case 3:
            PlaySound(SoundNames.SNOWWAR_MAKE_SNOWBALL);
            break;
        case 4:
        case 10:
            PlaySound(SoundNames.SNOWWAR_THROW);
            break;
        case 5:
            PlaySound(SoundNames.SNOWWAR_HIT);
            break;
        case SNOWWAR_EVENT_MACHINE_TRANSFER:
            PlaySound(SoundNames.SNOWWAR_GET_SNOWBALL);
            break;
        case 9:
            PlaySound(SoundNames.SNOWWAR_STUN);
            break;
    }
};

const SNOWWAR_QUEUE_MAX_WAIT_MS = 120000;

let CHAT_MESSAGE_ID = 0;

export const GetSnowWarSimulation = (): SnowWarSimulation => SNOWWAR_SIMULATION;

/** Map a parsed subturn event onto the simulation's positional event shape. */
const toSimEvent = (event: {
    eventType: number;
    objectId: number;
    throwerObjectId: number;
    targetObjectId: number;
    targetX: number;
    targetY: number;
    trajectory: number;
    direction: number;
    machineObjectId: number;
    avatarObjectId: number;
    height: number;
    state: number;
}): SnowWarSimEvent =>
{
    switch (event.eventType)
    {
        case 2: return { type: 2, p1: event.objectId, p2: event.targetX, p3: event.targetY, p4: 0, p5: 0 };
        case 3: return { type: 3, p1: event.objectId, p2: 0, p3: 0, p4: 0, p5: 0 };
        case 4: return {
            type: 4,
            p1: event.objectId,
            p2: event.throwerObjectId,
            p3: event.targetX,
            p4: event.targetY,
            p5: event.trajectory,
        };
        case 5: return { type: 5, p1: event.throwerObjectId, p2: event.targetObjectId, p3: event.direction, p4: 0, p5: 0 };
        case 11: return { type: 11, p1: event.machineObjectId, p2: 0, p3: 0, p4: 0, p5: 0 };
        case 12: return { type: 12, p1: event.avatarObjectId, p2: event.machineObjectId, p3: 0, p4: 0, p5: 0 };
        case 8: return { type: 8, p1: event.objectId, p2: event.targetX, p3: event.targetY, p4: event.height, p5: event.trajectory };
        case 9: return { type: 9, p1: event.targetObjectId, p2: event.throwerObjectId, p3: event.direction, p4: 0, p5: 0 };
        case 10: return {
            type: 10,
            p1: event.objectId,
            p2: event.throwerObjectId,
            p3: event.targetX,
            p4: event.targetY,
            p5: event.trajectory,
        };
        case 13: return { type: 13, p1: event.targetX, p2: event.targetY, p3: event.state, p4: 0, p5: 0 };
        default: return { type: event.eventType, p1: 0, p2: 0, p3: 0, p4: 0, p5: 0 };
    }
};

const useSnowWarState = () =>
{
    const [phase, setPhase] = useState<SnowWarPhase>('idle');
    const [queuePosition, setQueuePosition] = useState(0);
    const [queueSize, setQueueSize] = useState(0);
    const [lobbySeconds, setLobbySeconds] = useState(0);
    const [preparingSeconds, setPreparingSeconds] = useState(0);
    // Wall-clock end of the preparing phase (0 = none); drives the AIR counter.
    const [preparingEndsAt, setPreparingEndsAt] = useState(0);
    // AIR Game2InArenaQueue: full matches ahead while every arena is busy
    // (-1 = not waiting for an arena).
    const [arenaQueuePosition, setArenaQueuePosition] = useState(-1);
    const [secondsLeft, setSecondsLeft] = useState(0);
    const [levelData, setLevelData] = useState<SnowWarLevelState>(null);
    const [lobbyTeams, setLobbyTeams] = useState<SnowWarLobbyTeamsState>(null);
    const [results, setResults] = useState<SnowWarResultsState>(null);
    const [chatMessages, setChatMessages] = useState<SnowWarChatMessage[]>([]);
    const [rematchedUserIds, setRematchedUserIds] = useState<number[]>([]);
    const [errorCode, setErrorCode] = useState<number>(null);
    const [queueExpired, setQueueExpired] = useState(false);
    const [gamesLeft, setGamesLeft] = useState(-1);
    const [queueInfo, setQueueInfo] = useState<{ playersInQueue: number; gamesPlayed: number; minPlayers: number; canEdit: boolean }>(null);
    const [leaderboard, setLeaderboard] = useState<SnowWarLeaderboardState>({
        isOpen: false,
        weekly: false,
        friendsOnly: true,
        groups: false,
        favouriteGroupId: -1,
        loading: false,
        year: 0,
        week: 0,
        maxOffset: 0,
        currentOffset: 0,
        minutesUntilReset: 0,
        totalListSize: 0,
        entries: [],
    });
    // In-arena WYSIWYG editor: the client edits the current level snapshot and
    // publishes it with the save packet. editingRef mirrors it for the stable
    // packet callbacks (which capture [] deps).
    const [editing, setEditing] = useState(false);
    const editingRef = useRef(false);
    // AIR stamps every arena action with the last authoritative turn and the
    // locally replayed subturn. Polaris keeps these fields optional server-side
    // so older custom clients remain compatible.
    const protocolTurnRef = useRef(0);
    const lobbyDeadlineRef = useRef<number | null>(null);
    const preparingDeadlineRef = useRef<number | null>(null);
    const gameDeadlineRef = useRef<number | null>(null);
    const lastWalkSentAtRef = useRef(0);
    const queuedWalkRef = useRef<{ worldX: number; worldY: number } | null>(null);
    const walkFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

    const getProtocolClock = useCallback((): [number, number] => [
        Math.max(0, protocolTurnRef.current),
        SNOWWAR_SIMULATION.subturnCount % SUBTURNS_PER_TICK,
    ], []);

    const resetToIdle = useCallback(() =>
    {
        SNOWWAR_SIMULATION.reset();
        protocolTurnRef.current = 0;
        lobbyDeadlineRef.current = null;
        preparingDeadlineRef.current = null;
        gameDeadlineRef.current = null;
        lastWalkSentAtRef.current = 0;
        queuedWalkRef.current = null;
        if (walkFlushTimerRef.current !== null) clearTimeout(walkFlushTimerRef.current);
        walkFlushTimerRef.current = null;
        editingRef.current = false;
        setEditing(false);
        setPhase('idle');
        setQueuePosition(0);
        setQueueSize(0);
        setLobbySeconds(0);
        setPreparingSeconds(0);
        setPreparingEndsAt(0);
        setArenaQueuePosition(-1);
        setSecondsLeft(0);
        setLevelData(null);
        setLobbyTeams(null);
        setResults(null);
        setChatMessages([]);
        setRematchedUserIds([]);
    }, []);

    // Lobby / preparing / game clocks are wall-clock deadlines. setInterval
    // and requestAnimationFrame are throttled in background/minimized tabs, so
    // neither may be used as elapsed time. Server packets reset the deadline;
    // every visible tick derives the display from Date.now().
    const lobbyTicking = (phase === 'lobby') && (lobbySeconds > 0);
    const preparingTicking = (phase === 'preparing') && (preparingSeconds > 0);
    const clockTicking = (phase === 'playing') && (secondsLeft > 0);

    useEffect(() =>
    {
        if (!lobbyTicking) return;
        const tick = () =>
        {
            if (lobbyDeadlineRef.current !== null)
                setLobbySeconds(snowWarSecondsRemaining(lobbyDeadlineRef.current, Date.now()));
        };
        tick();
        const interval = setInterval(tick, 250);
        document.addEventListener('visibilitychange', tick);
        return () =>
        {
            clearInterval(interval);
            document.removeEventListener('visibilitychange', tick);
        };
    }, [lobbyTicking]);

    useEffect(() =>
    {
        if (!preparingTicking) return;
        const tick = () =>
        {
            if (preparingDeadlineRef.current !== null)
                setPreparingSeconds(snowWarSecondsRemaining(preparingDeadlineRef.current, Date.now()));
        };
        tick();
        const interval = setInterval(tick, 250);
        document.addEventListener('visibilitychange', tick);
        return () =>
        {
            clearInterval(interval);
            document.removeEventListener('visibilitychange', tick);
        };
    }, [preparingTicking]);

    useEffect(() =>
    {
        if (!clockTicking) return;
        const tick = () =>
        {
            if (gameDeadlineRef.current !== null)
                setSecondsLeft(snowWarSecondsRemaining(gameDeadlineRef.current, Date.now()));
        };
        tick();
        const interval = setInterval(tick, 250);
        document.addEventListener('visibilitychange', tick);
        return () =>
        {
            clearInterval(interval);
            document.removeEventListener('visibilitychange', tick);
        };
    }, [clockTicking]);

    useEffect(() =>
    {
        if (!errorCode) return;
        const timeout = setTimeout(() => setErrorCode(null), 5000);
        return () => clearTimeout(timeout);
    }, [errorCode]);

    useEffect(() =>
    {
        if (phase !== 'queued') return;
        const timeout = setTimeout(() =>
        {
            SendMessageComposer(new SnowWarLeaveQueueComposer());
            resetToIdle();
            setQueueExpired(true);
        }, SNOWWAR_QUEUE_MAX_WAIT_MS);
        return () => clearTimeout(timeout);
    }, [phase, resetToIdle]);

    useEffect(() =>
    {
        if (!queueExpired) return;
        const timeout = setTimeout(() => setQueueExpired(false), 6000);
        return () => clearTimeout(timeout);
    }, [queueExpired]);

    // All packet handlers are useCallback-stable and read the parser into a
    // local BEFORE any setState. Both rules are load-bearing: this hook lives
    // inside a shared singleton whose effects react to state updates, so
    // synchronously on state updates, so an unstable handler identity makes
    // useMessageEvent unregister+dispose its event (nulling the parser)
    // mid-callback — and briefly leaves the header without a listener, which
    // can drop packets from the 150ms GameStatus stream.

    const onQueuePosition = useCallback((event: SnowWarQueuePositionEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        setQueueExpired(false);
        // From the results screen a queue position means this player's rematch
        // group (or a late Play again) is back in the queue: the server only
        // queues rematchers when the rematch window ends.
        setPhase(current => (current === 'idle' || current === 'queued' || current === 'lobby' || current === 'results') ? 'queued' : current);
        setQueuePosition(parser.position);
        setQueueSize(parser.queueSize);
    }, []);

    const onStartLobbyCounter = useCallback((event: SnowWarStartLobbyCounterEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        const seconds = parser.secondsUntilStart;
        lobbyDeadlineRef.current = snowWarDeadlineFromSeconds(Date.now(), seconds);
        setArenaQueuePosition(-1);
        setPhase('lobby');
        setLobbySeconds(seconds);
    }, []);

    const onLobbyTeams = useCallback((event: SnowWarLobbyTeamsEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        setLobbyTeams({
            teamCount: parser.teamCount,
            leaderUserId: parser.leaderUserId,
            selectedArenaId: parser.selectedArenaId,
            arenas: parser.arenas,
            players: parser.players.map(player => ({
                userId: player.userId,
                teamId: player.teamId,
                name: player.name,
                figure: player.figure,
                gender: player.gender,
                skillLevel: player.skillLevel,
            })),
        });
    }, []);

    const onInitArena = useCallback(() =>
    {
        SNOWWAR_SIMULATION.reset();
        setResults(null);
        setRematchedUserIds([]);
        setChatMessages([]);
        // A match starting must never stay hidden behind the leaderboard.
        setLeaderboard(current => (current.isOpen ? { ...current, isOpen: false } : current));
        setPhase('loading');
        SendMessageComposer(new SnowWarLoadStageReadyComposer(100));
    }, []);

    const onLevelData = useCallback((event: SnowWarLevelDataEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        SNOWWAR_SIMULATION.setLevel(parser.heightmapRows, parser.items, parser.machines);
        setLevelData({
            gameLengthSeconds: parser.gameLengthSeconds,
            canEditRoom: parser.canEditRoom,
            arenaName: parser.arenaName,
            mapId: parser.mapId,
            teamCount: parser.teamCount,
            heightmapRows: parser.heightmapRows,
            items: parser.items,
            machines: parser.machines,
            players: parser.players,
        });
        gameDeadlineRef.current = null;
        setSecondsLeft(parser.gameLengthSeconds);
        if (editingRef.current) setPhase('playing');
    }, []);

    const onFullGameStatus = useCallback((event: SnowWarFullGameStatusEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        protocolTurnRef.current = parser.turn;
        SNOWWAR_SIMULATION.applyFullStatus(parser.objects);
        gameDeadlineRef.current = snowWarDeadlineFromSeconds(Date.now(), parser.totalSecondsLeft);
        setSecondsLeft(parser.totalSecondsLeft);
    }, []);

    const onStageStart = useCallback((event: SnowWarOnStageStartEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        const seconds = parser.preparingSeconds;
        preparingDeadlineRef.current = snowWarDeadlineFromSeconds(Date.now(), seconds);
        setPreparingEndsAt(preparingDeadlineRef.current);
        setPhase('preparing');
        setPreparingSeconds(seconds);
        PlaySound(SoundNames.SNOWWAR_COUNTDOWN);
    }, []);

    const onGameStatus = useCallback((event: SnowWarGameStatusEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        // One server turn always represents exactly three 50 ms subturns.
        // Duplicate/out-of-order packets must not be replayed: doing so applies
        // movement frames twice and makes click-spam appear to accelerate the
        // avatar even though the authoritative server speed is fixed.
        if (parser.turn <= protocolTurnRef.current) return;
        protocolTurnRef.current = parser.turn;
        SNOWWAR_SIMULATION.queueGameStatus(parser.subturns.map(subturn => subturn.map(toSimEvent)));
        setPhase(current => ((current === 'preparing') || (current === 'loading')) ? 'playing' : current);
    }, []);

    const onStageRunning = useCallback((event: SnowWarOnStageRunningEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        gameDeadlineRef.current = snowWarDeadlineFromSeconds(Date.now(), parser.totalSecondsLeft);
        setSecondsLeft(parser.totalSecondsLeft);
        setPhase(current => ((current === 'preparing') || (current === 'loading')) ? 'playing' : current);
    }, []);

    const onStageEnding = useCallback(() => setPhase('ending'), []);

    const onGameEnding = useCallback((event: SnowWarOnGameEndingEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        const nextResults = {
            secondsToResults: parser.secondsToResults,
            teams: parser.teams,
            playerWithMostHits: parser.playerWithMostHits,
            playerWithMostKills: parser.playerWithMostKills,
        };
        setResults(nextResults);
        setPhase('results');
    }, []);

    const onGameEnded = useCallback(() =>
    {
        setPhase(current => (current === 'results') ? current : 'results');
    }, []);

    // Connection lost during a SnowWar session: clear it at once (the arena used to stay up while the
    // client reconnected), and after re-authentication go back to the room the game replaced. That room
    // is entered directly, as a normal room is after a reconnect, so the home-room entry that follows
    // every login finds the session and does not enter a second room.
    const phaseRef = useRef<SnowWarPhase>('idle');
    const connectionLostRef = useRef(false);

    useEffect(() => {
        phaseRef.current = phase;
    }, [phase]);

    useOctaneEvent(OctaneEventType.CONNECTION_STATE_CHANGED, useCallback(() =>
    {
        const state = GetCommunication()?.connection?.connectionState;

        if (!state || !CONNECTION_LOST_PHASES.includes(state.phase)) return;
        if (phaseRef.current === 'idle' && !editingRef.current) return;

        connectionLostRef.current = true;
        resetToIdle();
    }, [resetToIdle]));

    useOctaneEvent(OctaneEventType.SOCKET_REAUTHENTICATED, useCallback(() =>
    {
        if (!connectionLostRef.current) return;

        connectionLostRef.current = false;

        const roomId = consumeSnowWarReturnRoom();

        if (roomId) CreateRoomSession(roomId);
    }, []));

    const onRejoinPreviousRoom = useCallback(() =>
    {
        if (editingRef.current) return;
        resetToIdle();
        const roomId = consumeSnowWarReturnRoom();
        if (roomId) TryVisitRoom(roomId);
    }, [resetToIdle]);

    const onPlayerExitedArena = useCallback((event: SnowWarPlayerExitedArenaEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        SNOWWAR_SIMULATION.removeAvatarAfterBuffer(parser.objectId);
    }, []);

    const onUserChat = useCallback((event: SnowWarUserChatEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        const objectId = parser.objectId;
        const message = parser.message;
        const avatar = SNOWWAR_SIMULATION.avatars.get(objectId);
        setChatMessages(messages => [
            ...messages.slice(-30),
            {
                id: ++CHAT_MESSAGE_ID,
                objectId,
                name: avatar?.name ?? '',
                message,
                receivedAt: Date.now(),
            },
        ]);
    }, []);

    const onGenericError = useCallback((event: SnowWarGenericErrorEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        // No games left: the server dropped this player from the match it was
        // forming, so leave the waiting screen (the hub shows the error).
        if (parser.errorCode === SNOWWAR_ERROR_NO_TICKETS && (phaseRef.current === 'queued' || phaseRef.current === 'lobby')) resetToIdle();
        setErrorCode(parser.errorCode);
    }, [resetToIdle]);

    const onUserRematched = useCallback((event: SnowWarUserRematchedEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        const userId = parser.userId;
        setRematchedUserIds(ids => (ids.includes(userId) ? ids : [...ids, userId]));
    }, []);

    const onGamesLeft = useCallback((event: SnowWarGamesLeftEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        setGamesLeft(parser.gamesLeft);
    }, []);

    const onGamesInformation = useCallback((event: SnowWarGamesInformationEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        setQueueInfo({ playersInQueue: parser.playersInQueue, gamesPlayed: parser.gamesPlayed, minPlayers: parser.minPlayers, canEdit: parser.canEdit });
    }, []);

    const applyLeaderboard = useCallback((event: Game2WeeklyLeaderboardEvent | Game2WeeklyFriendsLeaderboardEvent | WeeklyCompetitiveLeaderboardEvent | WeeklyCompetitiveFriendsLeaderboardEvent, weekly: boolean, friendsOnly: boolean) =>
    {
        const parser = event.getParser();
        if (!parser || parser.gameTypeId !== 0) return;
        setLeaderboard({
            isOpen: true,
            weekly,
            friendsOnly,
            groups: false,
            favouriteGroupId: -1,
            loading: false,
            year: parser.year,
            week: parser.week,
            maxOffset: parser.maxOffset,
            currentOffset: parser.currentOffset,
            minutesUntilReset: parser.minutesUntilReset,
            totalListSize: parser.totalListSize,
            entries: parser.leaderboard.map(entry => ({
                userId: entry.userId,
                score: entry.score,
                rank: entry.rank,
                name: entry.name,
                figure: entry.figure,
                gender: entry.gender,
            })),
        });
    }, []);

    const applyGroupLeaderboard = useCallback((event: Game2TotalGroupLeaderboardEvent | Game2WeeklyGroupLeaderboardEvent, weekly: boolean) =>
    {
        const parser = event.getParser();
        if (!parser || parser.gameTypeId !== 0) return;
        const weeklyParser = weekly ? (parser as Game2WeeklyGroupLeaderboardParser) : null;
        setLeaderboard(current => ({
            isOpen: true,
            weekly,
            friendsOnly: false,
            groups: true,
            favouriteGroupId: parser.favouriteGroupId,
            loading: false,
            year: weeklyParser?.year ?? current.year,
            week: weeklyParser?.week ?? current.week,
            maxOffset: weeklyParser?.maxOffset ?? 0,
            currentOffset: weeklyParser?.currentOffset ?? 0,
            minutesUntilReset: weeklyParser?.minutesUntilReset ?? current.minutesUntilReset,
            totalListSize: parser.totalListSize,
            entries: parser.leaderboard.map(entry => ({
                userId: entry.userId,
                score: entry.score,
                rank: entry.rank,
                name: entry.name,
                figure: entry.figure,
                gender: entry.gender,
            })),
        }));
    }, []);
    const onTotalGroupLeaderboard = useCallback((event: Game2TotalGroupLeaderboardEvent) =>
        applyGroupLeaderboard(event, false), [applyGroupLeaderboard]);
    const onWeeklyGroupLeaderboard = useCallback((event: Game2WeeklyGroupLeaderboardEvent) =>
        applyGroupLeaderboard(event, true), [applyGroupLeaderboard]);

    const onAllTimeLeaderboard = useCallback((event: Game2WeeklyLeaderboardEvent) =>
        applyLeaderboard(event, false, false), [applyLeaderboard]);
    const onAllTimeFriendsLeaderboard = useCallback((event: Game2WeeklyFriendsLeaderboardEvent) =>
        applyLeaderboard(event, false, true), [applyLeaderboard]);
    const onWeeklyLeaderboard = useCallback((event: WeeklyCompetitiveLeaderboardEvent) =>
        applyLeaderboard(event, true, false), [applyLeaderboard]);
    const onWeeklyFriendsLeaderboard = useCallback((event: WeeklyCompetitiveFriendsLeaderboardEvent) =>
        applyLeaderboard(event, true, true), [applyLeaderboard]);

    useMessageEvent<SnowWarQueuePositionEvent>(SnowWarQueuePositionEvent, onQueuePosition);
    useMessageEvent<Game2InArenaQueueMessageEvent>(Game2InArenaQueueMessageEvent, useCallback((event: Game2InArenaQueueMessageEvent) =>
    {
        const parser = event.getParser();
        if (!parser) return;
        setArenaQueuePosition((parser.position > 0) ? parser.position : -1);
    }, []));
    useMessageEvent<SnowWarStartLobbyCounterEvent>(SnowWarStartLobbyCounterEvent, onStartLobbyCounter);
    useMessageEvent<SnowWarLobbyTeamsEvent>(SnowWarLobbyTeamsEvent, onLobbyTeams);
    useMessageEvent<SnowWarInitArenaEvent>(SnowWarInitArenaEvent, onInitArena);
    useMessageEvent<SnowWarLevelDataEvent>(SnowWarLevelDataEvent, onLevelData);
    useMessageEvent<SnowWarFullGameStatusEvent>(SnowWarFullGameStatusEvent, onFullGameStatus);
    useMessageEvent<SnowWarOnStageStartEvent>(SnowWarOnStageStartEvent, onStageStart);
    useMessageEvent<SnowWarGameStatusEvent>(SnowWarGameStatusEvent, onGameStatus);
    useMessageEvent<SnowWarOnStageRunningEvent>(SnowWarOnStageRunningEvent, onStageRunning);
    useMessageEvent<SnowWarOnStageEndingEvent>(SnowWarOnStageEndingEvent, onStageEnding);
    useMessageEvent<SnowWarOnGameEndingEvent>(SnowWarOnGameEndingEvent, onGameEnding);
    useMessageEvent<SnowWarGameEndedEvent>(SnowWarGameEndedEvent, onGameEnded);
    useMessageEvent<SnowWarRejoinPreviousRoomEvent>(SnowWarRejoinPreviousRoomEvent, onRejoinPreviousRoom);
    useMessageEvent<SnowWarPlayerExitedArenaEvent>(SnowWarPlayerExitedArenaEvent, onPlayerExitedArena);
    useMessageEvent<SnowWarUserChatEvent>(SnowWarUserChatEvent, onUserChat);
    useMessageEvent<SnowWarGenericErrorEvent>(SnowWarGenericErrorEvent, onGenericError);
    useMessageEvent<SnowWarUserRematchedEvent>(SnowWarUserRematchedEvent, onUserRematched);
    useMessageEvent<SnowWarGamesLeftEvent>(SnowWarGamesLeftEvent, onGamesLeft);
    useMessageEvent<SnowWarGamesInformationEvent>(SnowWarGamesInformationEvent, onGamesInformation);
    useMessageEvent<Game2WeeklyLeaderboardEvent>(Game2WeeklyLeaderboardEvent, onAllTimeLeaderboard);
    useMessageEvent<Game2WeeklyFriendsLeaderboardEvent>(Game2WeeklyFriendsLeaderboardEvent, onAllTimeFriendsLeaderboard);
    useMessageEvent<WeeklyCompetitiveLeaderboardEvent>(WeeklyCompetitiveLeaderboardEvent, onWeeklyLeaderboard);
    useMessageEvent<WeeklyCompetitiveFriendsLeaderboardEvent>(WeeklyCompetitiveFriendsLeaderboardEvent, onWeeklyFriendsLeaderboard);
    useMessageEvent<Game2TotalGroupLeaderboardEvent>(Game2TotalGroupLeaderboardEvent, onTotalGroupLeaderboard);
    useMessageEvent<Game2WeeklyGroupLeaderboardEvent>(Game2WeeklyGroupLeaderboardEvent, onWeeklyGroupLeaderboard);

    const requestLeaderboard = useCallback((weekly = false, friendsOnly = true, weekOffset = 0, groups = false) =>
    {
        setLeaderboard(current => ({ ...current, isOpen: true, weekly, friendsOnly: friendsOnly && !groups, groups, loading: true }));
        const common = [ 0, -1, 0, 8, 50 ] as const;
        if (groups)
        {
            SendMessageComposer(weekly
                ? new Game2GetWeeklyGroupLeaderboardComposer(0, weekOffset, -1, 0, 8, 50)
                : new Game2GetTotalGroupLeaderboardComposer(...common));
        }
        else if (weekly)
        {
            SendMessageComposer(friendsOnly
                ? new SnowWarGetWeeklyFriendsLeaderboardComposer(0, weekOffset, -1, 0, 8, 50)
                : new SnowWarGetWeeklyLeaderboardComposer(0, weekOffset, -1, 0, 8, 50));
        }
        else
        {
            SendMessageComposer(friendsOnly
                ? new SnowWarGetAllTimeFriendsLeaderboardComposer(...common)
                : new SnowWarGetAllTimeLeaderboardComposer(...common));
        }
    }, []);

    const closeLeaderboard = useCallback(() =>
        setLeaderboard(current => ({ ...current, isOpen: false })), []);

    const joinQueue = useCallback(() => SendMessageComposer(new SnowWarJoinQueueComposer()), []);

    const selectArena = useCallback((arenaId: number) =>
        SendMessageComposer(new SnowWarSelectArenaComposer(arenaId)), []);

    const leaveQueue = useCallback(() =>
    {
        SendMessageComposer(new SnowWarLeaveQueueComposer());
        resetToIdle();
    }, [resetToIdle]);

    const startEditing = useCallback(() =>
    {
        // Server verifies acc_snowwar_arena_build and removes us from the running
        // game/queue; we keep the level snapshot and edit it in place.
        editingRef.current = true;
        setEditing(true);
        SendMessageComposer(new SnowWarEditRoomComposer());
    }, []);

    const saveArena = useCallback((
        mapId: number,
        items: { name: string; x: number; y: number; rotation: number; imageUrl: string; offsetZ: number; state: number }[],
        spawns: { x: number; y: number }[],
        heightmap: string[],
        arenaName: string) =>
    {
        SendMessageComposer(new SnowWarSaveEditorComposer(mapId, items, spawns, heightmap, arenaName));
    }, []);

    const stopEditing = useCallback(() =>
    {
        editingRef.current = false;
        setEditing(false);
        SendMessageComposer(new SnowWarExitEditorComposer());
        resetToIdle();
        const roomId = consumeSnowWarReturnRoom();
        if (roomId) TryVisitRoom(roomId);
    }, [resetToIdle]);

    const exitGame = useCallback(() =>
    {
        SendMessageComposer(new SnowWarExitGameComposer());
        resetToIdle();
    }, [resetToIdle]);

    const playAgain = useCallback(() => SendMessageComposer(new SnowWarPlayAgainComposer()), []);

    const walkTo = useCallback((worldX: number, worldY: number) =>
    {
        const send = (targetX: number, targetY: number) =>
        {
            const [turn, subturn] = getProtocolClock();
            SendMessageComposer(new SnowWarWalkComposer(targetX, targetY, turn, subturn));
            lastWalkSentAtRef.current = Date.now();
        };

        const elapsed = Date.now() - lastWalkSentAtRef.current;
        if (elapsed >= SUBTURN_MS && walkFlushTimerRef.current === null)
        {
            send(worldX, worldY);
            return;
        }

        // AIR can consume at most one new movement goal per subturn. Keep only
        // the latest clicked tile during that 50 ms window; queuing every click
        // just floods duplicate goals and can cause clients to replay excess
        // movement packets.
        queuedWalkRef.current = { worldX, worldY };
        if (walkFlushTimerRef.current !== null) return;

        walkFlushTimerRef.current = setTimeout(() =>
        {
            walkFlushTimerRef.current = null;
            const queued = queuedWalkRef.current;
            queuedWalkRef.current = null;
            if (queued) send(queued.worldX, queued.worldY);
        }, Math.max(0, SUBTURN_MS - elapsed));
    }, [getProtocolClock]);

    const throwAtLocation = useCallback((worldX: number, worldY: number, trajectory: number) =>
    {
        const [turn, subturn] = getProtocolClock();
        SendMessageComposer(new SnowWarThrowAtLocationComposer(worldX, worldY, trajectory, turn, subturn));
    }, [getProtocolClock]);

    const throwAtPlayer = useCallback((targetObjectId: number, trajectory: number) =>
    {
        const [turn, subturn] = getProtocolClock();
        SendMessageComposer(new SnowWarThrowAtPlayerComposer(targetObjectId, trajectory, turn, subturn));
    }, [getProtocolClock]);

    const createSnowball = useCallback(() =>
    {
        const [turn, subturn] = getProtocolClock();
        SendMessageComposer(new SnowWarCreateSnowballComposer(turn, subturn));
    }, [getProtocolClock]);

    const sendChat = useCallback((message: string) =>
    {
        const trimmed = message.trim();
        if (!trimmed.length) return;
        SendMessageComposer(new SnowWarGameChatComposer(trimmed.substring(0, 100)));
    }, []);

    const requestFullStatus = useCallback(() =>
        SendMessageComposer(new SnowWarRequestFullGameStatusComposer()), []);

    return {
        phase,
        queuePosition,
        queueSize,
        lobbySeconds,
        preparingSeconds,
        preparingEndsAt,
        arenaQueuePosition,
        secondsLeft,
        levelData,
        lobbyTeams,
        results,
        chatMessages,
        rematchedUserIds,
        errorCode,
        gamesLeft,
        queueInfo,
        leaderboard,
        queueExpired,
        editing,
        simulation: SNOWWAR_SIMULATION,
        joinQueue,
        selectArena,
        leaveQueue,
        exitGame,
        startEditing,
        saveArena,
        stopEditing,
        playAgain,
        walkTo,
        throwAtLocation,
        throwAtPlayer,
        createSnowball,
        sendChat,
        requestFullStatus,
        requestLeaderboard,
        closeLeaderboard,
    };
};

export const useSnowWar = () => useSharedHook(useSnowWarState);

registerSharedHook(useSnowWarState);
15Sujets
1Dépôts
20Membres
2En 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