5180 fichiers 38.5 Mo 0 téléchargements 252 vues mis à jour il y a 5 heures ui
nitro-v36-with-renderer-v36 / src / api / snowwar / SnowWarGamesUi.ts
SnowWarGamesUi.ts 11.1 Ko Télécharger le fichier
/**
 * Pure helpers behind the SnowWar (SnowStorm) games UI: the games-left
 * counter and the end-of-game screen. The leave-block countdown is in
 * SnowWarLeaveBlock.
 * Every rule here mirrors the AIR 13 client (GamesMainViewController /
 * GameEndingViewController / GameLoadingViewController) so the views stay
 * declarative and the arithmetic is unit-testable.
 */

/** AIR: `freeGamesLeft == -1` (Game2AccountGameStatus) means unlimited games. */
export const SNOWWAR_UNLIMITED_GAMES = -1;

export interface SnowWarGamesLeftStatus {
    /** The whole "Total Games Left" region — hidden for unlimited (HC) accounts. */
    showCounter: boolean;
    /** AIR paints the number red with 0 games left, blue otherwise. */
    counterTone: 'red' | 'blue';
    /** Play button caption: `snowwar.play` or, with 0 games left, `catalog.vip.buy.title`. */
    playLabelKey: 'snowwar.play' | 'catalog.vip.buy.title';
    /** Whether pressing play starts a game (else it opens the "get more games" offer). */
    canStart: boolean;
}

/**
 * GamesMainViewController.checkGameAmountStatus / GameEndingViewController.
 * updateGamesLeft: the counter region, its colour and the play caption for a
 * given amount of free games (`-1` = unlimited).
 */
export const getGamesLeftStatus = (freeGamesLeft: number, hasUnlimitedGames = freeGamesLeft === SNOWWAR_UNLIMITED_GAMES): SnowWarGamesLeftStatus => {
    if (hasUnlimitedGames || freeGamesLeft < 0) {
        return { showCounter: false, counterTone: 'blue', playLabelKey: 'snowwar.play', canStart: true };
    }

    if (freeGamesLeft === 0) {
        return { showCounter: true, counterTone: 'red', playLabelKey: 'catalog.vip.buy.title', canStart: false };
    }

    return { showCounter: true, counterTone: 'blue', playLabelKey: 'snowwar.play', canStart: true };
};

/** AIR INSTRUCTION_ASSETS / INSTRUCTION_FRAME_COUNTS: five animated pages, one text key each. */
export const SNOWWAR_INSTRUCTION_PAGES: readonly { asset: string; frames: number; textKey: string }[] = [
    { asset: 'move_', frames: 4, textKey: 'snowwar.instructions.1' },
    { asset: 'throw_1_', frames: 4, textKey: 'snowwar.instructions.2' },
    { asset: 'throw_2_', frames: 5, textKey: 'snowwar.instructions.3' },
    { asset: 'throw_3_', frames: 5, textKey: 'snowwar.instructions.4' },
    { asset: 'balls_', frames: 5, textKey: 'snowwar.instructions.5' }
];

/** AIR INSTRUCTION_FRAME_LENGTH: one animation frame per second. */
export const SNOWWAR_INSTRUCTION_FRAME_MS = 1000;

/** onNext / onPrevious wrap around both ends (`(page + delta + count) % count`). */
export const wrapInstructionPage = (page: number, delta: number, pageCount = SNOWWAR_INSTRUCTION_PAGES.length): number => {
    if (pageCount <= 0) return 0;

    return (((page + delta) % pageCount) + pageCount) % pageCount;
};

/** Which frame (0-based) of a page's animation is showing after `elapsedMs`. The AIR files are numbered from 1. */
export const getInstructionFrame = (elapsedMs: number, frames: number, frameMs = SNOWWAR_INSTRUCTION_FRAME_MS): number => {
    if (frames <= 0) return 0;

    return Math.floor(Math.max(0, elapsedMs) / frameMs) % frames;
};

/** showInstructions: pagination balls up to and including the current page are highlighted. */
export const isInstructionPageHighlighted = (ballIndex: number, currentPage: number): boolean => ballIndex <= currentPage;

/**
 * AIR games_main "get more games" buttons (GamesMainViewController
 * windowEventProc): each buys the token offer with that localization id.
 */
export const SNOWWAR_TOKEN_BUTTONS: readonly { localizationId: string; games: number; asset: string }[] = [
    { localizationId: 'GET_SNOWWAR_TOKENS', games: 10, asset: 'btn_more_games_10' },
    { localizationId: 'GET_SNOWWAR_TOKENS2', games: 100, asset: 'btn_more_games_100' },
    { localizationId: 'GET_SNOWWAR_TOKENS3', games: 300, asset: 'btn_more_games_300' }
];

/**
 * The price text of a token offer with the catalogue keys
 * (`catalog.purchase.price.credits`, `...activitypoints.N`,
 * `...credits_and_activitypoints.N`).
 */
export const getSnowWarTokenPrice = (credits: number, points: number, pointsType: number): { key: string; parameters: string[]; replacements: string[] } => {
    if (credits > 0 && points > 0) {
        return {
            key: `catalog.purchase.price.credits_and_activitypoints.${pointsType}`,
            parameters: ['credits', 'activitypoints', 'pixels'],
            replacements: [String(credits), String(points), String(points)]
        };
    }

    if (points > 0) {
        return { key: `catalog.purchase.price.activitypoints.${pointsType}`, parameters: ['activitypoints', 'pixels'], replacements: [String(points), String(points)] };
    }

    return { key: 'catalog.purchase.price.credits', parameters: ['credits'], replacements: [String(Math.max(0, credits))] };
};

export type SnowWarStarTier = 'none' | 'bronze' | 'silver' | 'gold';

export interface SnowWarSkillStars {
    tier: SnowWarStarTier;
    /** How many of the ten stars are filled with the tier's colour. */
    filled: number;
    total: number;
}

/** Skill levels above 30 render like level 30 (three full rows of gold). */
export const SNOWWAR_MAX_SKILL_LEVEL = 30;
export const SNOWWAR_STARS_PER_ROW = 10;

/**
 * GameEndingViewController.getSkillLevelImage: ten stars, levels 1-10 fill
 * bronze, 11-20 silver, 21-30 gold, with `(level - 1) % 10 + 1` of them lit.
 */
export const getSkillLevelStars = (skillLevel: number): SnowWarSkillStars => {
    const level = Math.min(Math.max(0, Math.floor(skillLevel)), SNOWWAR_MAX_SKILL_LEVEL);

    if (level <= 0) return { tier: 'none', filled: 0, total: SNOWWAR_STARS_PER_ROW };

    const filled = ((level - 1) % SNOWWAR_STARS_PER_ROW) + 1;
    const tier: SnowWarStarTier = level > 20 ? 'gold' : level > 10 ? 'silver' : 'bronze';

    return { tier, filled, total: SNOWWAR_STARS_PER_ROW };
};

/**
 * AIR numbers teams 1 (blue) and 2 (red); our packets number them 0 (red)
 * and 1 (blue) (SnowWarGame round robin). The official text keys (`snowwar.team_1_wins`) and colours
 * are keyed by the AIR reference.
 */
export const getOfficialTeamReference = (teamId: number): 1 | 2 => (teamId === 1 ? 1 : 2);

export const SNOWWAR_TEAM_COLORS: Record<1 | 2, string> = {
    1: '#1077ac', // AIR getTeamColor(1) = 0xFF1077AC (blue; also the games-left digit colour)
    2: '#fd6859' // AIR getTeamColor(2) = 0xFFFD6859 (red)
};

/** AIR getNeutralTeamColor() = 0x7D8A9A. */
export const SNOWWAR_NEUTRAL_COLOR = '#7d8a9a';

export type SnowWarResultHeadline =
    | { type: 'tie'; textKey: 'snowwar.result.tie'; color: string }
    | { type: 'winner'; textKey: `snowwar.team_${1 | 2}_wins`; teamId: number; color: string };

/**
 * The ending banner. AIR reads `resultType` (2 = tie) and `winnerId` from
 * Game2GameResult; our OnGameEnding packet only carries the team scores, so
 * the winner is the top-scoring team and equal top scores are a tie.
 */
export const getResultHeadline = (teams: readonly { teamId: number; score: number }[]): SnowWarResultHeadline => {
    if (!teams.length) return { type: 'tie', textKey: 'snowwar.result.tie', color: SNOWWAR_NEUTRAL_COLOR };

    const ordered = [...teams].sort((a, b) => b.score - a.score);
    const isTie = ordered.length > 1 && ordered[0].score === ordered[1].score;

    if (isTie) return { type: 'tie', textKey: 'snowwar.result.tie', color: SNOWWAR_NEUTRAL_COLOR };

    const reference = getOfficialTeamReference(ordered[0].teamId);

    return { type: 'winner', textKey: `snowwar.team_${reference}_wins`, teamId: ordered[0].teamId, color: SNOWWAR_TEAM_COLORS[reference] };
};

export type SnowWarRematchButton =
    | { kind: 'join_hc'; textKey: 'catalog.vip.buy.title' }
    | { kind: 'rematch'; textKey: 'snowwar.rematch'; seconds: number }
    | { kind: 'please_wait'; textKey: 'snowwar.please_wait'; seconds: number };

/**
 * GameEndingViewController.updateDialog: with 0 games left the button sells
 * HC, otherwise it counts down as "Rematch (n)" and, once pressed, as
 * "Please wait (n)".
 */
export const getRematchButton = (freeGamesLeft: number, hasUnlimitedGames: boolean, seconds: number, rematchRequested: boolean): SnowWarRematchButton => {
    if (!hasUnlimitedGames && freeGamesLeft === 0) return { kind: 'join_hc', textKey: 'catalog.vip.buy.title' };

    const clamped = Math.max(0, Math.floor(seconds));

    return rematchRequested
        ? { kind: 'please_wait', textKey: 'snowwar.please_wait', seconds: clamped }
        : { kind: 'rematch', textKey: 'snowwar.rematch', seconds: clamped };
};

/**
 * AIR plural texts such as `snowwar.lobby_game_start_countdown`
 * (`%{seconds|0 seconden|1 seconde|%% seconden} voor de Storm!`): picks the
 * zero / one / many form for `value` and fills `%%`. The renderer only
 * matches upper-case parameter names, so SnowWar texts go through this.
 * Plain `%parameter%` placeholders are filled too.
 */
export const applySnowWarPlural = (text: string, parameter: string, value: number): string =>
{
    if (!text) return text;

    const plural = new RegExp(`%\\{${ parameter }\\|([^|]*)\\|([^|]*)\\|([^}]*)\\}`, 'i');
    const count = String(value);

    return text
        .replace(plural, (_match, zero: string, one: string, many: string) => ((value === 0) ? zero : (value === 1) ? one : many).replace('%%', count))
        .split(`%${ parameter }%`).join(count);
};

export interface SnowWarLeaderboardView
{
    weekly: boolean;
    friendsOnly: boolean;
    groups: boolean;
}

export type SnowWarLeaderboardAction = 'everyone' | 'friends' | 'groups' | 'this_week' | 'all_time';

/**
 * AIR LeaderboardViewController onChangeView / onChangeFriendsView /
 * onChangeGroupView / onThisWeekButtonDown / onAllTimeButtonDown: a category
 * link keeps the period when that category is already showing and opens its
 * weekly table otherwise; the two tabs switch the period within the category.
 */
export const getLeaderboardTarget = (action: SnowWarLeaderboardAction, current: SnowWarLeaderboardView): SnowWarLeaderboardView =>
{
    const category = current.groups ? 'groups' : current.friendsOnly ? 'friends' : 'everyone';

    switch (action)
    {
        case 'this_week':
        case 'all_time':
            return { ...current, weekly: (action === 'this_week') };
        default:
            return {
                weekly: (category === action) ? current.weekly : true,
                friendsOnly: (action === 'friends'),
                groups: (action === 'groups')
            };
    }
};

/**
 * The rows of one page (AIR viewSize 8). In the everyone and group tables
 * the viewer's own row stays visible: when it is not on the page it takes
 * the last slot, with empty rows (`null`) between, as AIR pads its list.
 */
export const getLeaderboardRows = <T extends { userId: number }>(entries: readonly T[], offset: number, pageSize: number, ownId: number, pinOwn: boolean): (T | null)[] =>
{
    const page = entries.slice(offset, offset + pageSize);

    if (!pinOwn || (ownId <= 0) || page.some(entry => entry.userId === ownId)) return page;

    const own = entries.find(entry => entry.userId === ownId);

    if (!own) return page;

    const rows: (T | null)[] = page.slice(0, pageSize - 1);

    while (rows.length < (pageSize - 1)) rows.push(null);

    rows.push(own);

    return rows;
};
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