Prepare game updates and single-process PM2 deployment
This commit is contained in:
+32
-14
@@ -1,10 +1,11 @@
|
||||
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS, TURN_DURATION_MS } from '../../constants/gameplay.js';
|
||||
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS } from '../../constants/gameplay.js';
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
import type { LocalMatchState } from '../shared/p2p-types.js';
|
||||
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
import { createActionRenderer } from './action-renderer.js';
|
||||
import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js';
|
||||
import { createFinishCutscene } from './finish-cutscene.js';
|
||||
import { matchIntroductionMessage } from '../shared/match-introduction.js';
|
||||
|
||||
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
|
||||
<div class="avatar"><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div>
|
||||
@@ -12,7 +13,7 @@ const fighter = (i: number, appearance?: FighterAppearance) => `<div class="figh
|
||||
</div>`;
|
||||
|
||||
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) {
|
||||
container.innerHTML = `<div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
|
||||
container.innerHTML = `<section class="match-introduction waiting" hidden><div class="queue-fighter" aria-hidden="true"></div><h1 role="status"></h1></section><div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
|
||||
<div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
|
||||
<div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div>
|
||||
<div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div>
|
||||
@@ -48,31 +49,39 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
function tick() {
|
||||
if (!current) return;
|
||||
const now = Date.now() + offset, s = current;
|
||||
const revealing = !!s.reveal && now < s.reveal.revealEndsAt;
|
||||
const introduction = matchIntroductionMessage(s, now);
|
||||
el('.match-introduction').hidden = !introduction;
|
||||
el('.match-introduction h1').textContent = introduction ?? '';
|
||||
arena.hidden = !!introduction;
|
||||
const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt;
|
||||
const resultVisible = s.phase === 'finished' && !revealing;
|
||||
if (resultVisible && resultReported !== s.matchId) renderer.reset();
|
||||
const recovering = !resultVisible && !s.opponentPresent;
|
||||
const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase;
|
||||
container.dataset.phase = phase;
|
||||
container.dataset.locked = String(!!s.ownAction);
|
||||
const finishing = arena.dataset.outcome === 'hit';
|
||||
el('.player-controls').hidden = finishing || resultVisible;
|
||||
el('.timing').hidden = finishing || resultVisible;
|
||||
el('.match-top').hidden = resultVisible || !!introduction;
|
||||
el('.player-controls').hidden = finishing || resultVisible || !!introduction;
|
||||
el('.timing').hidden = finishing || resultVisible || !!introduction;
|
||||
el('.match-result').hidden = !resultVisible;
|
||||
el('.result-banner').hidden = !resultVisible;
|
||||
el('.mode-label').textContent = s.ranked ? '랭킹 대전' : s.mode === 'bot' ? '봇 대전' : '친구 대전';
|
||||
el('.rematch').textContent = s.ranked ? '다시 매칭' : '다시 대전';
|
||||
el('.turn-heading').textContent = s.phase === 'countdown' ? '대전 준비' : resultVisible ? '대전 종료' : `${s.turnId} 턴`;
|
||||
el('.turn-heading').textContent = s.phase === 'countdown' ? s.ranked ? '랭킹대전 준비 중…' : '대전 준비' : resultVisible ? '대전 종료' : `${s.turnId} 턴`;
|
||||
const remaining = s.phase === 'countdown' ? Math.max(0, s.startsAt - now) : Math.max(0, s.deadline - now);
|
||||
const count = Number.isFinite(remaining) ? String(Math.ceil(remaining / 1000)) : '';
|
||||
const countdown = el('.countdown-number');
|
||||
if (s.phase === 'countdown' && countdown.textContent !== count) { countdown.textContent = count; countdown.dataset.count = count; }
|
||||
el('.duel-cue').hidden = revealing || resultVisible;
|
||||
el('.duel-cue').hidden = revealing || resultVisible || recovering;
|
||||
el('.selection-status').textContent = !resultVisible && !s.opponentPresent ? '연결 복구 중' : s.phase === 'countdown' ? Number.isFinite(remaining) ? '' : '상대 연결 중' : revealing ? '동시 공개' : resultVisible ? '' : busy ? '선택 중' : s.ownAction ? '선택 완료' : remaining > 0 ? '행동 선택' : '결과 대기';
|
||||
el('.time-label').textContent = s.phase === 'selecting' && !revealing ? `${(remaining / 1000).toFixed(1)}초` : '';
|
||||
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / TURN_DURATION_MS : 0;
|
||||
el('.time-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : '';
|
||||
el('.time-track').hidden = recovering;
|
||||
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / (s.deadline - s.startsAt) : 0;
|
||||
el('.time-track').style.setProperty('--time', String(Math.min(1, progress)));
|
||||
el('.time-track').setAttribute('aria-valuenow', String(Math.round(Math.min(1, progress) * 100)));
|
||||
el('.time-track').setAttribute('aria-label', s.phase === 'countdown' ? '대전 시작까지 남은 시간' : '남은 선택 시간');
|
||||
el('.timing').dataset.urgent = String(s.phase === 'selecting' && remaining <= 1000 && !s.ownAction);
|
||||
el('.timing').dataset.urgent = String(!recovering && s.phase === 'selecting' && remaining <= 1000 && !s.ownAction);
|
||||
buttons.forEach(button => {
|
||||
const id = button.dataset.action as ActionId;
|
||||
const affordable = s.energy[s.selfIndex] >= ACTIONS[id].cost;
|
||||
@@ -95,6 +104,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
if (resultVisible && s.finished) {
|
||||
if (resultReported !== s.matchId) { resultReported = s.matchId; onResult(); }
|
||||
const f = s.finished;
|
||||
el('.finish-reason').hidden = f.reason === 'hit';
|
||||
const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss';
|
||||
container.dataset.outcome = outcome;
|
||||
arena.dataset.result = outcome;
|
||||
@@ -105,7 +115,9 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
el('.result-stats').textContent = f.saved ? `현재 연승 ${stats.current} · 최고 연승 ${stats.best}` : '';
|
||||
el('.save-status').textContent = s.saveStatus === 'failed' ? '기록을 저장하지 못했습니다. 다시 시도하세요.' : f.saved ? '' : '기록 저장 중';
|
||||
el<HTMLButtonElement>('.rematch').disabled = !f.saved || (!s.ranked && (!!s.rematch[s.selfIndex] || s.opponentLeft));
|
||||
el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대가 나갔습니다.' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : '';
|
||||
el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대방이 나갔습니다' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : '';
|
||||
el('.rematch-status').setAttribute('role', s.opponentLeft ? 'alert' : 'status');
|
||||
el('.rematch-status').classList.toggle('opponent-left', s.opponentLeft);
|
||||
} else {
|
||||
delete arena.dataset.result;
|
||||
if (arena.dataset.outcome) container.dataset.outcome = arena.dataset.outcome;
|
||||
@@ -116,7 +128,10 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
return {
|
||||
update(state: LocalMatchState) {
|
||||
if (current?.matchId === state.matchId && current.version > state.version) return;
|
||||
if (lastId !== state.matchId) { cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; }
|
||||
if (lastId !== state.matchId) {
|
||||
cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++;
|
||||
el('.match-introduction .queue-fighter').innerHTML = fighterArt(state.selfIndex, false, (state.appearances ?? appearances)?.[state.selfIndex]);
|
||||
}
|
||||
if (current && current.turnId !== state.turnId) { busy = false; submission++; }
|
||||
current = state; offset = state.clockNow - Date.now();
|
||||
container.dataset.self = String(state.selfIndex);
|
||||
@@ -133,8 +148,11 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
node.setAttribute('role', 'group');
|
||||
node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터');
|
||||
});
|
||||
if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset);
|
||||
else renderer.reset();
|
||||
// Rematch/presence updates must never restart a match's completed presentation.
|
||||
if (resultReported !== state.matchId) {
|
||||
if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset);
|
||||
else renderer.reset();
|
||||
}
|
||||
tick();
|
||||
cutscene.update(state);
|
||||
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; },
|
||||
|
||||
Reference in New Issue
Block a user