优化:战斗UI
This commit is contained in:
1 parent
d2b7b349fb
commit
2006363a7e
16 files changed
+1518
-452
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
@@ -4,17 +4,18 @@ services:
|
||||
nats:
|
||||
image: nats:2.11-alpine
|
||||
command:
|
||||
- --config=/etc/nats/nats-server.conf
|
||||
- --jetstream
|
||||
- --store_dir=/data/jetstream
|
||||
- --http_port=8222
|
||||
- --user=app
|
||||
- --pass=local-nats-password
|
||||
- --max_payload=8MB
|
||||
ports:
|
||||
- '4222:4222'
|
||||
- '8222:8222'
|
||||
volumes:
|
||||
- nats-data:/data/jetstream
|
||||
- ./nats-server.conf:/etc/nats/nats-server.conf:ro
|
||||
healthcheck:
|
||||
test:
|
||||
['CMD-SHELL', 'wget -qO- http://127.0.0.1:8222/healthz | grep -q ok']
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# NATS server settings that are not exposed as command-line flags in NATS 2.11.
|
||||
max_payload: 8MB
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { BattlePublicUnitStateV1 } from '@shared/engine/battle-v5/match/BattlePublicSnapshot';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export type RealtimeBattleConnectionStatus =
|
||||
'connected' | 'disconnected' | 'connecting';
|
||||
|
||||
export type RealtimeBattleRoundPhase =
|
||||
| 'connecting'
|
||||
| 'waiting'
|
||||
| 'planning'
|
||||
| 'committed'
|
||||
| 'resolving'
|
||||
| 'presenting'
|
||||
| 'finished';
|
||||
|
||||
interface BattleRoundHudProps {
|
||||
round?: number;
|
||||
phase: RealtimeBattleRoundPhase;
|
||||
remainingSeconds?: number;
|
||||
}
|
||||
|
||||
const ROUND_SECONDS = 30;
|
||||
|
||||
function phaseLabel(phase: RealtimeBattleRoundPhase) {
|
||||
switch (phase) {
|
||||
case 'connecting':
|
||||
return '连接战局';
|
||||
case 'waiting':
|
||||
return '等待入阵';
|
||||
case 'planning':
|
||||
return '选择行动';
|
||||
case 'committed':
|
||||
return '本方已定';
|
||||
case 'resolving':
|
||||
return '统一演算';
|
||||
case 'presenting':
|
||||
return '行动播放';
|
||||
case 'finished':
|
||||
return '战局已定';
|
||||
}
|
||||
}
|
||||
|
||||
function phaseMark(phase: RealtimeBattleRoundPhase, remaining?: number) {
|
||||
if (phase === 'planning') return remaining ?? '—';
|
||||
if (phase === 'committed') return '定';
|
||||
if (phase === 'resolving') return '演';
|
||||
if (phase === 'presenting') return '战';
|
||||
if (phase === 'finished') return '终';
|
||||
return '候';
|
||||
}
|
||||
|
||||
export function BattleRoundHud({
|
||||
round,
|
||||
phase,
|
||||
remainingSeconds,
|
||||
}: BattleRoundHudProps) {
|
||||
const remaining = Math.max(0, remainingSeconds ?? ROUND_SECONDS);
|
||||
const progress = phase === 'planning' ? remaining / ROUND_SECONDS : 1;
|
||||
const urgency =
|
||||
phase !== 'planning'
|
||||
? 'neutral'
|
||||
: remaining <= 5
|
||||
? 'danger'
|
||||
: remaining <= 10
|
||||
? 'warning'
|
||||
: 'normal';
|
||||
const style = {
|
||||
'--battle-round-progress': `${Math.max(0, Math.min(1, progress)) * 360}deg`,
|
||||
} as CSSProperties;
|
||||
const mark = phaseMark(phase, remainingSeconds);
|
||||
const numericMark = typeof mark === 'number';
|
||||
|
||||
return (
|
||||
<section
|
||||
className="pointer-events-none absolute top-[calc(env(safe-area-inset-top)+0.75rem)] left-1/2 z-30 -translate-x-1/2 text-center text-[#2c1810]"
|
||||
aria-label={`第 ${round ?? '未知'} 回合,${phaseLabel(phase)}${phase === 'planning' && remainingSeconds !== undefined ? `,剩余 ${remainingSeconds} 秒` : ''}`}
|
||||
>
|
||||
<p
|
||||
key={round}
|
||||
className="battle-round-caption mb-1 text-[0.68rem] font-semibold tracking-[0.24em] text-[#2c1810]/65 sm:text-xs"
|
||||
>
|
||||
第 {round ?? '—'} 回合
|
||||
</p>
|
||||
<div
|
||||
className={`battle-round-seal battle-round-seal--${urgency} relative grid size-[4.75rem] place-items-center rounded-full shadow-[0_8px_30px_rgba(44,24,16,0.16)] sm:size-[5.75rem]`}
|
||||
style={style}
|
||||
>
|
||||
<div className="battle-round-seal__inner relative z-[1] grid size-[calc(100%_-_6px)] place-items-center rounded-full bg-[#eee7d6]/92 shadow-inner backdrop-blur-md">
|
||||
<strong
|
||||
className={`battle-round-seal__mark ${numericMark ? 'battle-round-seal__mark--numeric' : 'battle-round-seal__mark--phase'}`}
|
||||
>
|
||||
{mark}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[0.68rem] font-semibold tracking-[0.18em] text-[#2c1810]/72 sm:text-xs">
|
||||
{phaseLabel(phase)}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface BattleUtilityHudProps {
|
||||
connectionStatus: RealtimeBattleConnectionStatus;
|
||||
debugOpen: boolean;
|
||||
onToggleDebug: () => void;
|
||||
}
|
||||
|
||||
export function BattleUtilityHud({
|
||||
connectionStatus,
|
||||
debugOpen,
|
||||
onToggleDebug,
|
||||
}: BattleUtilityHudProps) {
|
||||
const connectionLabel =
|
||||
connectionStatus === 'connected'
|
||||
? '战局连接正常'
|
||||
: connectionStatus === 'disconnected'
|
||||
? '连接中断,正在重连'
|
||||
: '正在连接战局';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute top-[calc(env(safe-area-inset-top)+0.85rem)] left-[calc(env(safe-area-inset-left)+0.85rem)] z-30">
|
||||
<Link
|
||||
to="/game/battle/history"
|
||||
className="inline-flex min-h-10 items-center rounded-full border border-[#2c1810]/18 bg-[#eee7d6]/78 px-3 text-xs text-[#2c1810]/72 shadow-sm backdrop-blur-md transition-colors hover:bg-[#eee7d6]/95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8f2433]"
|
||||
>
|
||||
离阵
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-[calc(env(safe-area-inset-top)+0.85rem)] right-[calc(env(safe-area-inset-right)+0.85rem)] z-30 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDebug}
|
||||
className="inline-flex min-h-10 items-center rounded-full border border-[#2c1810]/18 bg-[#eee7d6]/78 px-3 text-xs text-[#2c1810]/72 shadow-sm backdrop-blur-md transition-colors hover:bg-[#eee7d6]/95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8f2433]"
|
||||
>
|
||||
{debugOpen ? '收起日志' : '战斗日志'}
|
||||
</button>
|
||||
<span
|
||||
className="grid size-10 place-items-center rounded-full border border-[#2c1810]/18 bg-[#eee7d6]/78 shadow-sm backdrop-blur-md"
|
||||
title={connectionLabel}
|
||||
aria-label={connectionLabel}
|
||||
>
|
||||
<span
|
||||
className={`size-2.5 rounded-full ${connectionStatus === 'connected' ? 'bg-[#3f6b56]' : connectionStatus === 'disconnected' ? 'bg-[#8f2433]' : 'bg-[#946718]'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface BattleCommandDockProps {
|
||||
expanded: boolean;
|
||||
actions: ReactNode;
|
||||
units: ReactNode;
|
||||
summary: ReactNode;
|
||||
guidance?: ReactNode;
|
||||
}
|
||||
|
||||
export function BattleCommandDock({
|
||||
expanded,
|
||||
actions,
|
||||
units,
|
||||
summary,
|
||||
guidance,
|
||||
}: BattleCommandDockProps) {
|
||||
return (
|
||||
<section
|
||||
className={`battle-command-dock absolute inset-x-0 bottom-0 z-30 flex justify-center px-[calc(env(safe-area-inset-left)+0.65rem)] pb-[calc(env(safe-area-inset-bottom)+0.65rem)] transition-[transform,opacity] duration-300 motion-reduce:transition-none ${expanded ? 'translate-y-0' : 'pointer-events-none translate-y-[calc(100%_-_4.4rem)]'}`}
|
||||
aria-label="战斗指令台"
|
||||
data-expanded={expanded}
|
||||
>
|
||||
<div className="battle-command-dock__surface pointer-events-auto w-full max-w-[72rem] overflow-hidden rounded-t-2xl border border-b-0 border-[#2c1810]/18 bg-[linear-gradient(180deg,rgba(238,231,214,0.9),rgba(232,223,202,0.97))] px-3 pt-2.5 pb-2 shadow-[0_-12px_40px_rgba(44,24,16,0.16)] backdrop-blur-lg sm:rounded-2xl sm:border-b sm:px-4">
|
||||
<div className="flex min-h-10 items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">{summary}</div>
|
||||
{!expanded && (
|
||||
<span className="shrink-0 text-[0.68rem] tracking-[0.12em] text-[#2c1810]/50">
|
||||
指令台已收起
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`grid transition-[grid-template-rows,opacity] duration-300 motion-reduce:transition-none ${expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div className="mt-1.5 flex gap-2 overflow-x-auto pb-1">
|
||||
{actions}
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-2 overflow-x-auto border-t border-[#2c1810]/10 pt-1.5">
|
||||
{units}
|
||||
</div>
|
||||
{guidance ? (
|
||||
<div className="mt-1 min-h-4 text-[0.68rem] leading-4 text-[#2c1810]/58">
|
||||
{guidance}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface BattleTargetPromptProps {
|
||||
abilityName: string;
|
||||
targetLabel: string;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function BattleTargetPrompt({
|
||||
abilityName,
|
||||
targetLabel,
|
||||
onCancel,
|
||||
}: BattleTargetPromptProps) {
|
||||
return (
|
||||
<div className="battle-target-prompt pointer-events-none absolute inset-x-0 bottom-[var(--battle-command-safe)] z-30 flex justify-center px-4 transition-[bottom] duration-300 motion-reduce:transition-none">
|
||||
<div className="pointer-events-auto flex max-w-[min(92vw,36rem)] items-center gap-3 rounded-full border border-[#8f2433]/35 bg-[#eee7d6]/92 px-4 py-2.5 text-sm shadow-lg backdrop-blur-md">
|
||||
<span className="min-w-0 truncate">
|
||||
<strong>「{abilityName}」</strong> · 请选择{targetLabel}目标
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 border-b border-dashed border-[#2c1810]/40 px-1 text-xs text-[#2c1810]/65"
|
||||
onClick={onCancel}
|
||||
>
|
||||
换招
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BattleStatusNoticeProps {
|
||||
tone: 'danger' | 'warning';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function BattleStatusNotice({
|
||||
tone,
|
||||
children,
|
||||
}: BattleStatusNoticeProps) {
|
||||
return (
|
||||
<div
|
||||
className={`battle-status-notice pointer-events-auto absolute top-[calc(env(safe-area-inset-top)+7.75rem)] left-1/2 z-40 w-[min(92vw,42rem)] -translate-x-1/2 rounded-lg border bg-[#eee7d6]/94 px-4 py-2 text-center text-sm shadow-lg backdrop-blur-md ${tone === 'danger' ? 'border-[#8f2433]/30 text-[#8f2433]' : 'border-[#946718]/30 text-[#694d1d]'}`}
|
||||
role="status"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BattleUnitInspectorProps {
|
||||
unit: BattlePublicUnitStateV1;
|
||||
isAlly: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BattleUnitInspector({
|
||||
unit,
|
||||
isAlly,
|
||||
onClose,
|
||||
}: BattleUnitInspectorProps) {
|
||||
const visibleEffects = unit.effects.slice(-4);
|
||||
return (
|
||||
<aside className="battle-unit-inspector absolute right-[calc(env(safe-area-inset-right)+0.85rem)] bottom-[calc(var(--battle-command-safe)+0.7rem)] z-30 w-[min(18rem,calc(100vw-1.7rem))] rounded-xl border border-[#2c1810]/15 bg-[#eee7d6]/88 px-3 py-3 shadow-lg backdrop-blur-md transition-[bottom] duration-300 motion-reduce:transition-none">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[0.65rem] tracking-[0.16em] text-[#2c1810]/48">
|
||||
{isAlly ? '我方单位' : '敌方单位'}
|
||||
</span>
|
||||
<strong className="mt-0.5 block truncate text-base">
|
||||
{unit.name}
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="grid size-8 shrink-0 place-items-center rounded-full text-sm text-[#2c1810]/55 hover:bg-[#2c1810]/6"
|
||||
aria-label="关闭单位详情"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
<span className="text-[#8f2433]">
|
||||
气血 {unit.hp.current}/{unit.hp.max}
|
||||
</span>
|
||||
<span className="text-[#28758d]">
|
||||
真元 {unit.mp.current}/{unit.mp.max}
|
||||
</span>
|
||||
<span className="text-[#946718]">护盾 {unit.shield}</span>
|
||||
<span className="text-[#2c1810]/58">
|
||||
{unit.alive ? '仍在阵中' : '已经离阵'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(unit.actionStates.length > 0 || visibleEffects.length > 0) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 border-t border-[#2c1810]/10 pt-2">
|
||||
{unit.actionStates.map((state) => (
|
||||
<span
|
||||
key={state.id}
|
||||
className="rounded-full bg-[#735080]/10 px-2 py-1 text-[0.68rem] text-[#735080]"
|
||||
>
|
||||
{state.label} · {state.remainingActions}
|
||||
</span>
|
||||
))}
|
||||
{visibleEffects.map((effect) => (
|
||||
<span
|
||||
key={effect.id}
|
||||
className={`rounded-full px-2 py-1 text-[0.68rem] ${effect.statusType === 'buff' ? 'bg-[#3f6b56]/10 text-[#3f6b56]' : 'bg-[#8f2433]/9 text-[#8f2433]'}`}
|
||||
>
|
||||
{effect.label}
|
||||
{effect.layers > 1 ? ` ×${effect.layers}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -6,19 +6,57 @@ import type {
|
||||
CombatVisualSpec,
|
||||
CombatVisualTimeline,
|
||||
} from '@shared/engine/battle-v5/presentation';
|
||||
import * as Phaser from 'phaser';
|
||||
import type {
|
||||
BattlePresentationEntityV1,
|
||||
BattlePresentationSnapshotV1,
|
||||
BattlePresentationTeamV1,
|
||||
} from '@shared/online-battle/BattlePresentation';
|
||||
import * as Phaser from 'phaser';
|
||||
|
||||
type RealtimeBattleEntity = BattlePresentationEntityV1;
|
||||
type RealtimeBattleSnapshot = BattlePresentationSnapshotV1;
|
||||
type RealtimeBattleTeam = BattlePresentationTeamV1;
|
||||
|
||||
const DESKTOP_STAGE = { width: 1280, height: 720 } as const;
|
||||
const MOBILE_STAGE = { width: 720, height: 1080 } as const;
|
||||
type BattleStageProfile = 'portrait' | 'compact-landscape' | 'wide';
|
||||
|
||||
interface StageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
profile: BattleStageProfile;
|
||||
}
|
||||
|
||||
interface StageSafeBounds {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
const PORTRAIT_STAGE: StageSize = {
|
||||
width: 720,
|
||||
height: 1080,
|
||||
profile: 'portrait',
|
||||
};
|
||||
const COMPACT_LANDSCAPE_STAGE: StageSize = {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
profile: 'compact-landscape',
|
||||
};
|
||||
const WIDE_STAGE: StageSize = {
|
||||
width: 1440,
|
||||
height: 810,
|
||||
profile: 'wide',
|
||||
};
|
||||
const REALTIME_BATTLE_ASSETS = {
|
||||
arenaLandscape: {
|
||||
key: 'realtime-arena-landscape',
|
||||
url: '/assets/battle/realtime/arena-landscape.webp',
|
||||
},
|
||||
arenaPortrait: {
|
||||
key: 'realtime-arena-portrait',
|
||||
url: '/assets/battle/realtime/arena-portrait.webp',
|
||||
},
|
||||
} as const;
|
||||
const FONT_FAMILY = 'LXGWWenKai, serif';
|
||||
const TEXT_OUTLINE_COLOR = '#eee7d6';
|
||||
|
||||
@@ -29,9 +67,15 @@ function outlinedText(strokeThickness: number) {
|
||||
};
|
||||
}
|
||||
|
||||
type StageSize = { width: number; height: number };
|
||||
type FormationPoint = { x: number; y: number };
|
||||
|
||||
interface FormationRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
const FORMATION_OWNER_ORDER = [
|
||||
'sikong-ye',
|
||||
'shen-yanqiu',
|
||||
@@ -87,6 +131,7 @@ interface EntityVisual {
|
||||
nameControlFx: Phaser.GameObjects.Graphics;
|
||||
controlMode?: CombatControlVisual;
|
||||
isPet: boolean;
|
||||
baseRadius: number;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
@@ -142,20 +187,62 @@ function damageColor(
|
||||
}
|
||||
}
|
||||
|
||||
function formationRadius(entity: RealtimeBattleEntity, stage: StageSize) {
|
||||
const compact = stage.width < 900;
|
||||
if (entity.kind === 'spirit-pet') return compact ? 34 : 44;
|
||||
return compact ? 50 : 62;
|
||||
function selectStage(width: number, height: number): StageSize {
|
||||
const aspect = width / Math.max(height, 1);
|
||||
if (aspect < 0.9) return { ...PORTRAIT_STAGE };
|
||||
if (height < 680 || aspect < 1.55) return { ...COMPACT_LANDSCAPE_STAGE };
|
||||
return { ...WIDE_STAGE };
|
||||
}
|
||||
|
||||
function formationBackY(team: RealtimeBattleTeam, stage: StageSize) {
|
||||
const compact = stage.width < 900;
|
||||
if (team === 'enemies') return stage.height * (compact ? 0.28 : 0.24);
|
||||
return stage.height * (compact ? 0.72 : 0.76);
|
||||
function stageSafeBounds(stage: StageSize): StageSafeBounds {
|
||||
const portrait = stage.profile === 'portrait';
|
||||
const compact = stage.profile === 'compact-landscape';
|
||||
const sideRatio = portrait ? 0.07 : compact ? 0.055 : 0.05;
|
||||
const topRatio = portrait ? 0.145 : compact ? 0.14 : 0.13;
|
||||
const bottomRatio = portrait ? 0.26 : compact ? 0.27 : 0.235;
|
||||
return {
|
||||
left: stage.width * sideRatio,
|
||||
right: stage.width * (1 - sideRatio),
|
||||
top: stage.height * topRatio,
|
||||
bottom: stage.height * (1 - bottomRatio),
|
||||
};
|
||||
}
|
||||
|
||||
function formationRadius(
|
||||
entity: RealtimeBattleEntity,
|
||||
stage: StageSize,
|
||||
teamSize: number,
|
||||
) {
|
||||
const base =
|
||||
stage.profile === 'portrait'
|
||||
? 58
|
||||
: stage.profile === 'compact-landscape'
|
||||
? 66
|
||||
: 76;
|
||||
const rosterScale = teamSize <= 1 ? 1.18 : teamSize === 2 ? 1.1 : 1;
|
||||
const cultivatorRadius = base * rosterScale;
|
||||
return entity.kind === 'spirit-pet'
|
||||
? Math.round(cultivatorRadius * 0.68)
|
||||
: Math.round(cultivatorRadius);
|
||||
}
|
||||
|
||||
function formationBackY(
|
||||
team: RealtimeBattleTeam,
|
||||
stage: StageSize,
|
||||
) {
|
||||
const safe = stageSafeBounds(stage);
|
||||
const available = safe.bottom - safe.top;
|
||||
const teamRatio = team === 'enemies' ? 0.2 : 0.8;
|
||||
return safe.top + available * teamRatio;
|
||||
}
|
||||
|
||||
function formationPetOffset(team: RealtimeBattleTeam, stage: StageSize) {
|
||||
const distance = stage.width < 900 ? 110 : 120;
|
||||
const distance =
|
||||
stage.profile === 'portrait'
|
||||
? 116
|
||||
: stage.profile === 'compact-landscape'
|
||||
? 126
|
||||
: 142;
|
||||
const angle = (35 * Math.PI) / 180;
|
||||
const frontDirection = team === 'enemies' ? 1 : -1;
|
||||
const sideDirection = team === 'enemies' ? 1 : -1;
|
||||
@@ -165,13 +252,114 @@ function formationPetOffset(team: RealtimeBattleTeam, stage: StageSize) {
|
||||
};
|
||||
}
|
||||
|
||||
function formationSlotX(slot: number, count: number, stage: StageSize) {
|
||||
const sideInset = stage.width * 0.235;
|
||||
return sideInset + ((stage.width - sideInset * 2) * slot) / Math.max(count - 1, 1);
|
||||
function formationSlotX(
|
||||
slot: number,
|
||||
count: number,
|
||||
stage: StageSize,
|
||||
) {
|
||||
const safe = stageSafeBounds(stage);
|
||||
const center = (safe.left + safe.right) / 2;
|
||||
if (count <= 1) return center;
|
||||
const widthRatio = count === 2 ? 0.34 : count === 3 ? 0.58 : 0.7;
|
||||
const span = (safe.right - safe.left) * widthRatio;
|
||||
return center - span / 2 + (span * slot) / (count - 1);
|
||||
}
|
||||
|
||||
function projectFormation(entities: readonly RealtimeBattleEntity[], stage: StageSize) {
|
||||
const positions = new Map<string, FormationPoint>();
|
||||
function formationVisualRects(
|
||||
entity: RealtimeBattleEntity,
|
||||
point: FormationPoint,
|
||||
stage: StageSize,
|
||||
teamSize: number,
|
||||
): FormationRect[] {
|
||||
const radius = formationRadius(entity, stage, teamSize);
|
||||
const isPet = entity.kind === 'spirit-pet';
|
||||
const bodyPadding = isPet ? 22 : 30;
|
||||
const upperHalfWidth = isPet ? Math.max(50, radius) : Math.max(86, radius);
|
||||
const lowerHalfWidth = isPet ? Math.max(62, radius) : Math.max(104, radius);
|
||||
return [
|
||||
{
|
||||
left: point.x - radius - bodyPadding,
|
||||
right: point.x + radius + bodyPadding,
|
||||
top: point.y - radius - bodyPadding,
|
||||
bottom: point.y + radius + bodyPadding,
|
||||
},
|
||||
{
|
||||
left: point.x - upperHalfWidth,
|
||||
right: point.x + upperHalfWidth,
|
||||
top: point.y - radius - (isPet ? 42 : 50),
|
||||
bottom: point.y - radius + 4,
|
||||
},
|
||||
{
|
||||
left: point.x - lowerHalfWidth,
|
||||
right: point.x + lowerHalfWidth,
|
||||
top: point.y + radius + 2,
|
||||
bottom: point.y + radius + (isPet ? 60 : 76),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formationRectsOverlap(
|
||||
left: FormationRect,
|
||||
right: FormationRect,
|
||||
gap: number,
|
||||
) {
|
||||
return !(
|
||||
left.right + gap <= right.left ||
|
||||
right.right + gap <= left.left ||
|
||||
left.bottom + gap <= right.top ||
|
||||
right.bottom + gap <= left.top
|
||||
);
|
||||
}
|
||||
|
||||
function formationIsReadable(
|
||||
entities: readonly RealtimeBattleEntity[],
|
||||
positions: ReadonlyMap<string, FormationPoint>,
|
||||
stage: StageSize,
|
||||
) {
|
||||
const safe = stageSafeBounds(stage);
|
||||
const teamSizes = new Map<RealtimeBattleTeam, number>();
|
||||
const rectsByTeam = new Map<RealtimeBattleTeam, FormationRect[]>();
|
||||
for (const team of ['enemies', 'allies'] as const) {
|
||||
teamSizes.set(
|
||||
team,
|
||||
entities.filter(
|
||||
(entity) => entity.team === team && entity.kind === 'cultivator',
|
||||
).length,
|
||||
);
|
||||
rectsByTeam.set(team, []);
|
||||
}
|
||||
for (const entity of entities) {
|
||||
const point = positions.get(entity.id);
|
||||
if (!point) continue;
|
||||
const rects = formationVisualRects(
|
||||
entity,
|
||||
point,
|
||||
stage,
|
||||
teamSizes.get(entity.team) ?? 1,
|
||||
);
|
||||
if (rects.some((rect) => rect.left < safe.left || rect.right > safe.right)) {
|
||||
return false;
|
||||
}
|
||||
rectsByTeam.get(entity.team)?.push(...rects);
|
||||
}
|
||||
const enemies = rectsByTeam.get('enemies') ?? [];
|
||||
const allies = rectsByTeam.get('allies') ?? [];
|
||||
const readabilityGap = stage.profile === 'wide' ? 18 : 14;
|
||||
return !enemies.some((enemy) =>
|
||||
allies.some((ally) =>
|
||||
formationRectsOverlap(enemy, ally, readabilityGap),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function projectFormation(
|
||||
entities: readonly RealtimeBattleEntity[],
|
||||
stage: StageSize,
|
||||
) {
|
||||
const groupsByTeam = new Map<
|
||||
RealtimeBattleTeam,
|
||||
Array<{ owner?: RealtimeBattleEntity; pet?: RealtimeBattleEntity }>
|
||||
>();
|
||||
for (const team of ['enemies', 'allies'] as const) {
|
||||
const teamEntities = entities.filter((entity) => entity.team === team);
|
||||
const owners = teamEntities
|
||||
@@ -201,29 +389,57 @@ function projectFormation(entities: readonly RealtimeBattleEntity[], stage: Stag
|
||||
.map((pet) => ({ owner: undefined, pet })),
|
||||
);
|
||||
|
||||
const backY = formationBackY(team, stage);
|
||||
const petOffset = formationPetOffset(team, stage);
|
||||
groups.forEach(({ owner, pet }, slot) => {
|
||||
const x = formationSlotX(slot, groups.length, stage);
|
||||
if (owner) positions.set(owner.id, { x, y: backY });
|
||||
if (pet) {
|
||||
positions.set(pet.id, {
|
||||
x: x + petOffset.x,
|
||||
y: backY + petOffset.y,
|
||||
});
|
||||
}
|
||||
});
|
||||
groupsByTeam.set(team, groups);
|
||||
}
|
||||
return positions;
|
||||
|
||||
const maxGroupCount = Math.max(
|
||||
1,
|
||||
groupsByTeam.get('enemies')?.length ?? 0,
|
||||
groupsByTeam.get('allies')?.length ?? 0,
|
||||
);
|
||||
const safe = stageSafeBounds(stage);
|
||||
const formationSpanRatio =
|
||||
maxGroupCount === 2 ? 0.34 : maxGroupCount === 3 ? 0.58 : 0.7;
|
||||
const slotGap =
|
||||
maxGroupCount <= 1
|
||||
? stage.width * 0.28
|
||||
: ((safe.right - safe.left) * formationSpanRatio) / (maxGroupCount - 1);
|
||||
const staggerDistance =
|
||||
maxGroupCount <= 1 ? slotGap : Math.min(slotGap * 0.55, stage.width * 0.14);
|
||||
|
||||
const positionsForStagger = (stagger: number) => {
|
||||
const positions = new Map<string, FormationPoint>();
|
||||
for (const team of ['enemies', 'allies'] as const) {
|
||||
const groups = groupsByTeam.get(team) ?? [];
|
||||
const backY = formationBackY(team, stage);
|
||||
const petOffset = formationPetOffset(team, stage);
|
||||
const teamOffset = team === 'enemies' ? stagger / 2 : -stagger / 2;
|
||||
groups.forEach(({ owner, pet }, slot) => {
|
||||
const x = formationSlotX(slot, groups.length, stage) + teamOffset;
|
||||
if (owner) positions.set(owner.id, { x, y: backY });
|
||||
if (pet) {
|
||||
positions.set(pet.id, {
|
||||
x: x + petOffset.x,
|
||||
y: backY + petOffset.y,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
};
|
||||
|
||||
const candidates = [0, staggerDistance, -staggerDistance];
|
||||
for (const stagger of candidates) {
|
||||
const positions = positionsForStagger(stagger);
|
||||
if (formationIsReadable(entities, positions, stage)) return positions;
|
||||
}
|
||||
return positionsForStagger(staggerDistance);
|
||||
}
|
||||
|
||||
export function attachRealtimeBattlePhaser(
|
||||
args: RealtimeBattlePhaserArguments,
|
||||
): RealtimeBattlePhaserController {
|
||||
const rootAspect =
|
||||
args.root.clientWidth / Math.max(args.root.clientHeight, 1);
|
||||
const stage: StageSize =
|
||||
rootAspect < 1 ? { ...MOBILE_STAGE } : { ...DESKTOP_STAGE };
|
||||
let stage = selectStage(args.root.clientWidth, args.root.clientHeight);
|
||||
const fittedCssScale = Math.min(
|
||||
args.root.clientWidth / stage.width,
|
||||
args.root.clientHeight / stage.height,
|
||||
@@ -238,10 +454,10 @@ export function attachRealtimeBattlePhaser(
|
||||
let paused = false;
|
||||
let speed = 1;
|
||||
let destroyed = false;
|
||||
const formationPositions = projectFormation(
|
||||
currentSnapshot.entities,
|
||||
stage,
|
||||
);
|
||||
const reduceMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches;
|
||||
let formationPositions = projectFormation(currentSnapshot.entities, stage);
|
||||
|
||||
const registerScene = (nextScene: RealtimeBattleScene) => {
|
||||
scene = nextScene;
|
||||
@@ -257,13 +473,23 @@ export function attachRealtimeBattlePhaser(
|
||||
private lockedUnitIds = new Set<string>();
|
||||
private actorUnitId: string | undefined;
|
||||
private commandSubmitting = false;
|
||||
private activeTimelineActionIds = new Set<string>();
|
||||
private pendingStage?: StageSize;
|
||||
private arenaBackdrop?: Phaser.GameObjects.Image;
|
||||
private formation?: Phaser.GameObjects.Graphics;
|
||||
|
||||
preload() {
|
||||
for (const asset of Object.values(REALTIME_BATTLE_ASSETS)) {
|
||||
this.load.image(asset.key, asset.url);
|
||||
}
|
||||
}
|
||||
|
||||
create() {
|
||||
registerScene(this);
|
||||
this.cameras.main
|
||||
.setZoom(renderScale)
|
||||
.centerOn(stage.width / 2, stage.height / 2);
|
||||
this.createPaperField();
|
||||
this.createArenaBackdrop();
|
||||
this.createFormationInk();
|
||||
for (const entity of currentSnapshot.entities) {
|
||||
this.createEntity(entity);
|
||||
@@ -297,9 +523,68 @@ export function attachRealtimeBattlePhaser(
|
||||
this.renderSnapshot(currentSnapshot);
|
||||
}
|
||||
|
||||
relayout(nextStage: StageSize) {
|
||||
stage = nextStage;
|
||||
formationPositions = projectFormation(currentSnapshot.entities, stage);
|
||||
this.scale.setGameSize(
|
||||
Math.round(stage.width * renderScale),
|
||||
Math.round(stage.height * renderScale),
|
||||
);
|
||||
this.cameras.main
|
||||
.setZoom(renderScale)
|
||||
.centerOn(stage.width / 2, stage.height / 2);
|
||||
this.createArenaBackdrop();
|
||||
this.createFormationInk();
|
||||
|
||||
const teamSizes = new Map<RealtimeBattleTeam, number>();
|
||||
for (const team of ['allies', 'enemies'] as const) {
|
||||
teamSizes.set(
|
||||
team,
|
||||
currentSnapshot.entities.filter(
|
||||
(entity) => entity.team === team && entity.kind === 'cultivator',
|
||||
).length,
|
||||
);
|
||||
}
|
||||
for (const entity of currentSnapshot.entities) {
|
||||
const visual = this.visuals.get(entity.id);
|
||||
const position = formationPositions.get(entity.id);
|
||||
if (!visual || !position) continue;
|
||||
const radius = formationRadius(
|
||||
entity,
|
||||
stage,
|
||||
teamSizes.get(entity.team) ?? 1,
|
||||
);
|
||||
const presentationScale = radius / visual.baseRadius;
|
||||
visual.radius = radius;
|
||||
visual.container
|
||||
.setPosition(position.x, position.y)
|
||||
.setScale(presentationScale);
|
||||
visual.combatResourceDom
|
||||
.setScale(presentationScale)
|
||||
.setPosition(
|
||||
position.x,
|
||||
position.y + (visual.isPet ? 12 : 20) * presentationScale,
|
||||
);
|
||||
}
|
||||
this.renderSnapshot(currentSnapshot);
|
||||
}
|
||||
|
||||
requestRelayout(nextStage: StageSize) {
|
||||
if (this.activeTimelineActionIds.size > 0) {
|
||||
this.pendingStage = nextStage;
|
||||
return;
|
||||
}
|
||||
this.relayout(nextStage);
|
||||
}
|
||||
|
||||
playTimeline(timeline: CombatVisualTimeline, offsetMs = 0) {
|
||||
for (const command of timeline.commands) {
|
||||
if (command.at + command.duration <= offsetMs) continue;
|
||||
const pendingCommands = timeline.commands.filter(
|
||||
(command) => command.at + command.duration > offsetMs,
|
||||
);
|
||||
if (pendingCommands.some((command) => command.kind === 'settle')) {
|
||||
this.activeTimelineActionIds.add(timeline.action.id);
|
||||
}
|
||||
for (const command of pendingCommands) {
|
||||
this.time.delayedCall(Math.max(0, command.at - offsetMs), () => {
|
||||
if (!this.sys.isActive()) return;
|
||||
if (command.kind === 'cast') this.playCast(timeline.action);
|
||||
@@ -332,9 +617,16 @@ export function attachRealtimeBattlePhaser(
|
||||
const existing = this.castLabels.get(action.id);
|
||||
if (existing?.active) existing.destroy();
|
||||
const label = this.add
|
||||
.text(0, -source.radius - 72, action.ability.name, {
|
||||
.text(0, -source.baseRadius - 72, action.ability.name, {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: source.isPet ? '16px' : '20px',
|
||||
fontSize:
|
||||
stage.profile === 'portrait'
|
||||
? source.isPet
|
||||
? '26px'
|
||||
: '34px'
|
||||
: source.isPet
|
||||
? '18px'
|
||||
: '24px',
|
||||
fontStyle: 'bold',
|
||||
color: colorHex(color),
|
||||
...outlinedText(source.isPet ? 3 : 4),
|
||||
@@ -683,6 +975,7 @@ export function attachRealtimeBattlePhaser(
|
||||
this.castLabels.delete(action.id);
|
||||
if (!label?.active) {
|
||||
if (source?.container.active) source.container.setDepth(3);
|
||||
this.completeTimelineAction(action.id);
|
||||
return;
|
||||
}
|
||||
this.tweens.add({
|
||||
@@ -694,61 +987,58 @@ export function attachRealtimeBattlePhaser(
|
||||
onComplete: () => {
|
||||
label.destroy();
|
||||
if (source?.container.active) source.container.setDepth(3);
|
||||
this.completeTimelineAction(action.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private createPaperField() {
|
||||
const paper = this.add.graphics().setDepth(0);
|
||||
paper.fillStyle(0xeee7d6, 1).fillRect(0, 0, stage.width, stage.height);
|
||||
paper.fillStyle(0x2a2018, 0.035);
|
||||
for (let index = 0; index < 150; index += 1) {
|
||||
const x = (index * 83) % stage.width;
|
||||
const y = (index * 137) % stage.height;
|
||||
const radius = 0.7 + (index % 4) * 0.45;
|
||||
paper.fillCircle(x, y, radius);
|
||||
}
|
||||
private completeTimelineAction(actionId: string) {
|
||||
this.activeTimelineActionIds.delete(actionId);
|
||||
if (this.activeTimelineActionIds.size > 0 || !this.pendingStage) return;
|
||||
const pendingStage = this.pendingStage;
|
||||
this.pendingStage = undefined;
|
||||
this.relayout(pendingStage);
|
||||
}
|
||||
|
||||
private createArenaBackdrop() {
|
||||
const asset =
|
||||
stage.profile === 'portrait'
|
||||
? REALTIME_BATTLE_ASSETS.arenaPortrait
|
||||
: REALTIME_BATTLE_ASSETS.arenaLandscape;
|
||||
const backdrop =
|
||||
this.arenaBackdrop ??
|
||||
this.add.image(stage.width / 2, stage.height / 2, asset.key).setDepth(0);
|
||||
this.arenaBackdrop = backdrop;
|
||||
backdrop
|
||||
.setTexture(asset.key)
|
||||
.setPosition(stage.width / 2, stage.height / 2);
|
||||
const coverScale = Math.max(
|
||||
stage.width / Math.max(backdrop.width, 1),
|
||||
stage.height / Math.max(backdrop.height, 1),
|
||||
);
|
||||
backdrop.setScale(coverScale);
|
||||
}
|
||||
|
||||
private createFormationInk() {
|
||||
const formation = this.add.graphics().setDepth(0.5);
|
||||
const centerRadius = Math.min(stage.width, stage.height) * 0.19;
|
||||
formation.lineStyle(1, 0x4f4338, 0.12);
|
||||
formation.strokeCircle(stage.width / 2, stage.height / 2, centerRadius);
|
||||
formation.strokeCircle(
|
||||
stage.width / 2,
|
||||
stage.height / 2,
|
||||
centerRadius * 1.42,
|
||||
);
|
||||
formation.lineBetween(
|
||||
96,
|
||||
stage.height / 2,
|
||||
stage.width - 96,
|
||||
stage.height / 2,
|
||||
);
|
||||
formation.lineStyle(1, 0x8b2832, 0.13);
|
||||
formation.lineBetween(
|
||||
stage.width / 2,
|
||||
92,
|
||||
stage.width / 2,
|
||||
stage.height - 82,
|
||||
);
|
||||
formation.lineStyle(2, 0x75474a, 0.1);
|
||||
const formation = this.formation ?? this.add.graphics().setDepth(0.5);
|
||||
this.formation = formation;
|
||||
formation.clear();
|
||||
const enemyPetOffset = formationPetOffset('enemies', stage);
|
||||
const allyPetOffset = formationPetOffset('allies', stage);
|
||||
const formationHeight =
|
||||
Math.abs(enemyPetOffset.y) + (stage.width < 900 ? 150 : 180);
|
||||
Math.abs(enemyPetOffset.y) + (stage.profile === 'portrait' ? 150 : 176);
|
||||
formation.lineStyle(2, 0x75474a, 0.075);
|
||||
formation.strokeEllipse(
|
||||
stage.width / 2,
|
||||
formationBackY('enemies', stage) + enemyPetOffset.y / 2,
|
||||
stage.width * 0.78,
|
||||
stage.width * 0.64,
|
||||
formationHeight,
|
||||
);
|
||||
formation.lineStyle(2, 0x475b50, 0.1);
|
||||
formation.lineStyle(2, 0x475b50, 0.075);
|
||||
formation.strokeEllipse(
|
||||
stage.width / 2,
|
||||
formationBackY('allies', stage) + allyPetOffset.y / 2,
|
||||
stage.width * 0.78,
|
||||
stage.width * 0.64,
|
||||
formationHeight,
|
||||
);
|
||||
}
|
||||
@@ -759,10 +1049,14 @@ export function attachRealtimeBattlePhaser(
|
||||
y: stage.height / 2,
|
||||
};
|
||||
const isPet = entity.kind === 'spirit-pet';
|
||||
const compact = stage.width < 900;
|
||||
const compact = stage.profile === 'portrait';
|
||||
const teamColor = entity.team === 'allies' ? 0x3f6b56 : 0x8e3039;
|
||||
const textColor = entity.team === 'allies' ? '#243d33' : '#55252a';
|
||||
const radius = formationRadius(entity, stage);
|
||||
const teamSize = currentSnapshot.entities.filter(
|
||||
(candidate) =>
|
||||
candidate.team === entity.team && candidate.kind === 'cultivator',
|
||||
).length;
|
||||
const radius = formationRadius(entity, stage, teamSize);
|
||||
const resourceRings = this.add.graphics();
|
||||
const resourceLeaders = this.add.graphics();
|
||||
const nameControlFx = this.add.graphics().setAlpha(0);
|
||||
@@ -778,24 +1072,26 @@ export function attachRealtimeBattlePhaser(
|
||||
.circle(0, 0, radius + 24, teamColor, 0.035)
|
||||
.setStrokeStyle(4, teamColor, 0)
|
||||
.setAlpha(0);
|
||||
this.tweens.add({
|
||||
targets: targetSelection,
|
||||
scale: 1.08,
|
||||
duration: 680,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.InOut',
|
||||
});
|
||||
if (!reduceMotion) {
|
||||
this.tweens.add({
|
||||
targets: targetSelection,
|
||||
scale: 1.08,
|
||||
duration: 680,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'Sine.InOut',
|
||||
});
|
||||
}
|
||||
const name = this.add
|
||||
.text(0, isPet ? -9 : -12, entity.name, {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: isPet
|
||||
? compact
|
||||
? '15px'
|
||||
: '18px'
|
||||
: compact
|
||||
? '22px'
|
||||
: '27px',
|
||||
: '20px'
|
||||
: compact
|
||||
? '29px'
|
||||
: '30px',
|
||||
color: textColor,
|
||||
fontStyle: 'bold',
|
||||
...outlinedText(isPet ? 3 : 4),
|
||||
@@ -806,7 +1102,13 @@ export function attachRealtimeBattlePhaser(
|
||||
const hpValue = this.add
|
||||
.text(-radius * 0.5, -radius - 12, '', {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: compact ? '9px' : isPet ? '11px' : '13px',
|
||||
fontSize: compact
|
||||
? isPet
|
||||
? '17px'
|
||||
: '20px'
|
||||
: isPet
|
||||
? '13px'
|
||||
: '16px',
|
||||
color: '#90323c',
|
||||
...outlinedText(compact ? 2 : 3),
|
||||
})
|
||||
@@ -815,7 +1117,13 @@ export function attachRealtimeBattlePhaser(
|
||||
const qiValue = this.add
|
||||
.text(radius * 0.5, -radius - 12, '', {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: compact ? '9px' : isPet ? '11px' : '13px',
|
||||
fontSize: compact
|
||||
? isPet
|
||||
? '17px'
|
||||
: '20px'
|
||||
: isPet
|
||||
? '13px'
|
||||
: '16px',
|
||||
color: '#276d83',
|
||||
...outlinedText(compact ? 2 : 3),
|
||||
})
|
||||
@@ -824,7 +1132,13 @@ export function attachRealtimeBattlePhaser(
|
||||
const shieldValue = this.add
|
||||
.text(0, radius + 10, '', {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: compact ? '9px' : isPet ? '11px' : '13px',
|
||||
fontSize: compact
|
||||
? isPet
|
||||
? '17px'
|
||||
: '20px'
|
||||
: isPet
|
||||
? '13px'
|
||||
: '16px',
|
||||
color: '#946718',
|
||||
...outlinedText(compact ? 2 : 3),
|
||||
})
|
||||
@@ -839,7 +1153,7 @@ export function attachRealtimeBattlePhaser(
|
||||
combatResourceNode.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(combatResourceNode.style, {
|
||||
color: '#695037',
|
||||
fontSize: compact ? (isPet ? '11px' : '13px') : isPet ? '13px' : '16px',
|
||||
fontSize: compact ? (isPet ? '16px' : '18px') : isPet ? '14px' : '17px',
|
||||
lineHeight: '1',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -886,7 +1200,13 @@ export function attachRealtimeBattlePhaser(
|
||||
const actionStateText = this.add
|
||||
.text(0, radius + 30, '', {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: compact ? '8px' : isPet ? '9px' : '11px',
|
||||
fontSize: compact
|
||||
? isPet
|
||||
? '14px'
|
||||
: '16px'
|
||||
: isPet
|
||||
? '11px'
|
||||
: '14px',
|
||||
color: '#735080',
|
||||
...outlinedText(3),
|
||||
letterSpacing: 1,
|
||||
@@ -896,7 +1216,13 @@ export function attachRealtimeBattlePhaser(
|
||||
const buffText = this.add
|
||||
.text(0, radius + 64, '', {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: compact ? '9px' : isPet ? '10px' : '12px',
|
||||
fontSize: compact
|
||||
? isPet
|
||||
? '14px'
|
||||
: '16px'
|
||||
: isPet
|
||||
? '11px'
|
||||
: '14px',
|
||||
color: '#357257',
|
||||
...outlinedText(3),
|
||||
letterSpacing: 1,
|
||||
@@ -906,7 +1232,13 @@ export function attachRealtimeBattlePhaser(
|
||||
const debuffText = this.add
|
||||
.text(0, radius + 47, '', {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: compact ? '9px' : isPet ? '10px' : '12px',
|
||||
fontSize: compact
|
||||
? isPet
|
||||
? '14px'
|
||||
: '16px'
|
||||
: isPet
|
||||
? '11px'
|
||||
: '14px',
|
||||
color: '#a32d3b',
|
||||
...outlinedText(3),
|
||||
letterSpacing: 1,
|
||||
@@ -916,7 +1248,7 @@ export function attachRealtimeBattlePhaser(
|
||||
const commandStateText = this.add
|
||||
.text(0, -radius - 38, '', {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: compact ? '10px' : '12px',
|
||||
fontSize: compact ? '18px' : '15px',
|
||||
fontStyle: 'bold',
|
||||
color: '#3f6b56',
|
||||
...outlinedText(3),
|
||||
@@ -946,7 +1278,10 @@ export function attachRealtimeBattlePhaser(
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(3);
|
||||
container.on('pointerdown', () => {
|
||||
if (this.legalTargetIds.size > 0 && !this.legalTargetIds.has(entity.id)) {
|
||||
if (
|
||||
this.legalTargetIds.size > 0 &&
|
||||
!this.legalTargetIds.has(entity.id)
|
||||
) {
|
||||
args.onFocus(entity.id);
|
||||
return;
|
||||
}
|
||||
@@ -979,6 +1314,7 @@ export function attachRealtimeBattlePhaser(
|
||||
debuffText,
|
||||
nameControlFx,
|
||||
isPet,
|
||||
baseRadius: radius,
|
||||
radius,
|
||||
});
|
||||
}
|
||||
@@ -1009,7 +1345,8 @@ export function attachRealtimeBattlePhaser(
|
||||
visual.combatResourceDom
|
||||
.setPosition(
|
||||
visual.container.x,
|
||||
visual.container.y + (visual.isPet ? 12 : 20),
|
||||
visual.container.y +
|
||||
(visual.isPet ? 12 : 20) * (visual.radius / visual.baseRadius),
|
||||
)
|
||||
.setVisible(
|
||||
entity.alive &&
|
||||
@@ -1021,32 +1358,42 @@ export function attachRealtimeBattlePhaser(
|
||||
.slice(-2);
|
||||
const localStates = entity.actionStates
|
||||
.map((state) => state.label)
|
||||
.slice(-2);
|
||||
.slice(stage.profile === 'portrait' ? -1 : -2);
|
||||
visual.actionStateText.setText(
|
||||
entity.alive ? localStates.join(' · ') : '',
|
||||
);
|
||||
const buffs = entity.effects
|
||||
.filter((effect) => effect.tone === 'buff')
|
||||
const buffEffects = entity.effects.filter(
|
||||
(effect) => effect.tone === 'buff',
|
||||
);
|
||||
const buffs = buffEffects
|
||||
.map(
|
||||
(effect) =>
|
||||
`${effect.label}${effect.layers > 1 ? ` ×${effect.layers}` : ''}`,
|
||||
)
|
||||
.slice(-2);
|
||||
const debuffs = entity.effects
|
||||
.filter(
|
||||
(effect) =>
|
||||
effect.tone === 'debuff' && effect.statusType !== 'control',
|
||||
)
|
||||
.slice(-1);
|
||||
const debuffEffects = entity.effects.filter(
|
||||
(effect) =>
|
||||
effect.tone === 'debuff' && effect.statusType !== 'control',
|
||||
);
|
||||
const debuffs = debuffEffects
|
||||
.map(
|
||||
(effect) =>
|
||||
`${effect.label}${effect.layers > 1 ? ` ×${effect.layers}` : ''}`,
|
||||
)
|
||||
.slice(-2);
|
||||
.slice(-1);
|
||||
visual.buffText.setText(
|
||||
entity.alive && buffs.length > 0 ? buffs.join(' / ') : '',
|
||||
entity.alive && buffs.length > 0
|
||||
? stage.profile === 'portrait'
|
||||
? `益 · ${buffEffects.length}`
|
||||
: buffs.join(' / ')
|
||||
: '',
|
||||
);
|
||||
visual.debuffText.setText(
|
||||
entity.alive && debuffs.length > 0 ? debuffs.join(' / ') : '',
|
||||
entity.alive && debuffs.length > 0
|
||||
? stage.profile === 'portrait'
|
||||
? `损 · ${debuffEffects.length}`
|
||||
: debuffs.join(' / ')
|
||||
: '',
|
||||
);
|
||||
this.renderNameControlFx(
|
||||
visual,
|
||||
@@ -1108,7 +1455,7 @@ export function attachRealtimeBattlePhaser(
|
||||
) {
|
||||
const graphics = visual.resourceRings;
|
||||
const leaders = visual.resourceLeaders;
|
||||
const radius = visual.radius;
|
||||
const radius = visual.baseRadius;
|
||||
const start = -Math.PI / 2;
|
||||
const hpRatio = Phaser.Math.Clamp(entity.hp / entity.maxHp, 0, 1);
|
||||
const qiRatio = Phaser.Math.Clamp(entity.qi / entity.maxQi, 0, 1);
|
||||
@@ -1172,9 +1519,16 @@ export function attachRealtimeBattlePhaser(
|
||||
if (!source) return;
|
||||
const color = visualColor(action.visual);
|
||||
const label = this.add
|
||||
.text(0, -source.radius - 48, fact.reaction.label, {
|
||||
.text(0, -source.baseRadius - 48, fact.reaction.label, {
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: source.isPet ? '14px' : '17px',
|
||||
fontSize:
|
||||
stage.profile === 'portrait'
|
||||
? source.isPet
|
||||
? '24px'
|
||||
: '29px'
|
||||
: source.isPet
|
||||
? '15px'
|
||||
: '19px',
|
||||
fontStyle: 'bold',
|
||||
color: colorHex(color),
|
||||
...outlinedText(source.isPet ? 3 : 4),
|
||||
@@ -1262,8 +1616,9 @@ export function attachRealtimeBattlePhaser(
|
||||
fact: Extract<CombatVisualFact, { kind: 'resource' }>,
|
||||
) {
|
||||
const visual = this.visuals.get(entityId);
|
||||
const entity = currentSnapshot
|
||||
.entities.find((entry) => entry.id === entityId);
|
||||
const entity = currentSnapshot.entities.find(
|
||||
(entry) => entry.id === entityId,
|
||||
);
|
||||
const resource = entity?.combatResources.find(
|
||||
(entry) => entry.id === fact.resourceId,
|
||||
);
|
||||
@@ -1352,11 +1707,18 @@ export function attachRealtimeBattlePhaser(
|
||||
|
||||
let mainLabel: string;
|
||||
let mainColor: number;
|
||||
let fontSize = 22;
|
||||
let fontSize = stage.profile === 'portrait' ? 38 : 24;
|
||||
if (cue.kind === 'damage') {
|
||||
mainLabel = `-${Math.round(cue.amount)}${cue.critical ? '!' : ''}`;
|
||||
mainColor = damageColor(cue.damageType);
|
||||
fontSize = cue.critical ? 27 : 23;
|
||||
fontSize =
|
||||
stage.profile === 'portrait'
|
||||
? cue.critical
|
||||
? 46
|
||||
: 40
|
||||
: cue.critical
|
||||
? 30
|
||||
: 25;
|
||||
} else if (cue.kind === 'recovery') {
|
||||
mainLabel = `+${Math.round(cue.amount)}`;
|
||||
mainColor = 0x357257;
|
||||
@@ -1368,7 +1730,7 @@ export function attachRealtimeBattlePhaser(
|
||||
: cue.tone === 'defense'
|
||||
? 0x665795
|
||||
: 0x5e5750;
|
||||
fontSize = 20;
|
||||
fontSize = stage.profile === 'portrait' ? 34 : 22;
|
||||
}
|
||||
|
||||
const mainText = this.add
|
||||
@@ -1702,6 +2064,22 @@ export function attachRealtimeBattlePhaser(
|
||||
scene: RealtimeBattleScene,
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (destroyed) return;
|
||||
const nextStage = selectStage(
|
||||
args.root.clientWidth,
|
||||
args.root.clientHeight,
|
||||
);
|
||||
if (nextStage.profile === stage.profile) return;
|
||||
if (scene) {
|
||||
scene.requestRelayout(nextStage);
|
||||
return;
|
||||
}
|
||||
stage = nextStage;
|
||||
formationPositions = projectFormation(currentSnapshot.entities, stage);
|
||||
});
|
||||
resizeObserver.observe(args.root);
|
||||
|
||||
return {
|
||||
syncSnapshot: (snapshot) => {
|
||||
currentSnapshot = snapshot;
|
||||
@@ -1712,7 +2090,8 @@ export function attachRealtimeBattlePhaser(
|
||||
scene?.playTimeline(timeline, offsetMs);
|
||||
},
|
||||
focus: (entityId) => {
|
||||
if (!currentSnapshot.entities.some((entity) => entity.id === entityId)) return;
|
||||
if (!currentSnapshot.entities.some((entity) => entity.id === entityId))
|
||||
return;
|
||||
currentSnapshot = { ...currentSnapshot, focusedEntityId: entityId };
|
||||
scene?.renderSnapshot(currentSnapshot);
|
||||
args.onFocus(entityId);
|
||||
@@ -1732,6 +2111,7 @@ export function attachRealtimeBattlePhaser(
|
||||
destroy: () => {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
resizeObserver.disconnect();
|
||||
scene = undefined;
|
||||
game.destroy(true);
|
||||
},
|
||||
|
||||
@@ -188,6 +188,196 @@ body {
|
||||
border-top: 1px dashed var(--color-battle-rule-strong);
|
||||
}
|
||||
|
||||
.battle-round-seal {
|
||||
--battle-round-accent: #3f6b56;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.battle-round-seal::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: conic-gradient(
|
||||
var(--battle-round-accent) 0 var(--battle-round-progress),
|
||||
rgba(44, 24, 16, 0.12) var(--battle-round-progress) 360deg
|
||||
);
|
||||
content: '';
|
||||
transition: background 180ms ease-out;
|
||||
}
|
||||
|
||||
.battle-round-seal::after {
|
||||
position: absolute;
|
||||
inset: -0.45rem;
|
||||
border: 2px solid var(--battle-round-accent);
|
||||
border-radius: 9999px;
|
||||
content: '';
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.battle-round-seal--warning {
|
||||
--battle-round-accent: #946718;
|
||||
}
|
||||
|
||||
.battle-round-seal--warning::after {
|
||||
animation: battle-round-warning-pulse 620ms ease-out;
|
||||
}
|
||||
|
||||
.battle-round-seal--danger {
|
||||
--battle-round-accent: #8f2433;
|
||||
}
|
||||
|
||||
.battle-round-seal--danger::after {
|
||||
animation: battle-round-danger-pulse 540ms ease-out;
|
||||
}
|
||||
|
||||
.battle-round-seal--neutral {
|
||||
--battle-round-accent: #735080;
|
||||
}
|
||||
|
||||
.battle-round-seal__mark {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
place-items: center;
|
||||
color: #2c1810;
|
||||
font-family: var(--font-lxgwwenkai), serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.battle-round-seal__mark--numeric {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
.battle-round-seal__mark--phase {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.battle-round-seal__mark--numeric {
|
||||
font-size: 3.3rem;
|
||||
}
|
||||
|
||||
.battle-round-seal__mark--phase {
|
||||
font-size: 2.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.battle-round-caption {
|
||||
animation: battle-round-caption-in 300ms ease-out;
|
||||
}
|
||||
|
||||
.battle-target-prompt {
|
||||
animation: battle-target-prompt-in 220ms ease-out;
|
||||
}
|
||||
|
||||
.battle-unit-inspector {
|
||||
animation: battle-overlay-in 220ms ease-out;
|
||||
}
|
||||
|
||||
.battle-status-notice {
|
||||
animation: battle-status-notice-in 220ms ease-out;
|
||||
}
|
||||
|
||||
.battle-command-dock[data-expanded='true'] .battle-command-dock__surface {
|
||||
animation: battle-command-ready 280ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes battle-round-warning-pulse {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
45% {
|
||||
opacity: 0.42;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes battle-round-danger-pulse {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.78);
|
||||
}
|
||||
42% {
|
||||
opacity: 0.62;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.16);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes battle-round-caption-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.35rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes battle-target-prompt-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.7rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes battle-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(0.45rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes battle-status-notice-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes battle-command-ready {
|
||||
from {
|
||||
box-shadow: 0 -4px 18px rgba(44, 24, 16, 0.08);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 -12px 40px rgba(44, 24, 16, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.battle-round-seal::after,
|
||||
.battle-round-caption,
|
||||
.battle-target-prompt,
|
||||
.battle-status-notice,
|
||||
.battle-unit-inspector,
|
||||
.battle-command-dock__surface {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bgi-map {
|
||||
background-image: url('https://lxd-waimao.oss-cn-hangzhou.aliyuncs.com/daoyou/map/map.webp');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@ import {
|
||||
unavailableAbilityLabel,
|
||||
} from '@app/components/feature/battle/realtime/battleAbilityLabels';
|
||||
import { BattlePresentationDirector } from '@app/components/feature/battle/realtime/BattlePresentationDirector';
|
||||
import {
|
||||
BattleCommandDock,
|
||||
BattleRoundHud,
|
||||
BattleStatusNotice,
|
||||
BattleTargetPrompt,
|
||||
BattleUnitInspector,
|
||||
BattleUtilityHud,
|
||||
type RealtimeBattleRoundPhase,
|
||||
} from '@app/components/feature/battle/realtime/RealtimeBattleHud';
|
||||
import {
|
||||
attachRealtimeBattlePhaser,
|
||||
type RealtimeBattlePhaserController,
|
||||
@@ -25,12 +34,19 @@ import {
|
||||
createBattlePresentationSnapshot,
|
||||
createBattlePresentationSnapshotFromPublic,
|
||||
} from '@shared/online-battle/BattlePresentation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
|
||||
function formatRemaining(deadlineAt: number | undefined, now: number) {
|
||||
if (!deadlineAt) return '—';
|
||||
return `${Math.max(0, Math.ceil((deadlineAt - now) / 1000))}s`;
|
||||
function remainingSeconds(deadlineAt: number | undefined, now: number) {
|
||||
if (!deadlineAt) return undefined;
|
||||
return Math.max(0, Math.ceil((deadlineAt - now) / 1000));
|
||||
}
|
||||
|
||||
interface PlanningCommandChoice {
|
||||
@@ -150,13 +166,11 @@ export default function LiveBattleMatchPage() {
|
||||
view.status === 'planning' &&
|
||||
!presentationActive,
|
||||
);
|
||||
const isCommitted = Boolean(
|
||||
view?.committedPlayerIds.includes(view.playerId ?? ''),
|
||||
);
|
||||
const isCommitted = view?.ownCommitted ?? false;
|
||||
const isResolving = view?.status === 'resolving';
|
||||
const inspectedUnit = view?.publicSnapshot.units.find(
|
||||
(unit) => unit.unitId === (inspectedUnitId ?? resolvedActiveUnitId),
|
||||
);
|
||||
const inspectedUnit = inspectedUnitId
|
||||
? view?.publicSnapshot.units.find((unit) => unit.unitId === inspectedUnitId)
|
||||
: undefined;
|
||||
const quickbarScope = `${view?.playerId ?? ''}:${resolvedActiveUnitId ?? ''}`;
|
||||
const quickbar =
|
||||
quickbarOverrides[quickbarScope] ??
|
||||
@@ -185,6 +199,23 @@ export default function LiveBattleMatchPage() {
|
||||
: resolvedActiveUnitId
|
||||
? 'select_ability'
|
||||
: 'select_unit';
|
||||
const roundRemainingSeconds = remainingSeconds(view?.deadlineAt, serverNow);
|
||||
const roundPhase: RealtimeBattleRoundPhase = presentationActive
|
||||
? 'presenting'
|
||||
: view?.status === 'finished' || view?.status === 'cancelled'
|
||||
? 'finished'
|
||||
: isResolving
|
||||
? 'resolving'
|
||||
: isCommitted
|
||||
? 'committed'
|
||||
: !view
|
||||
? 'connecting'
|
||||
: !allPlayersReady
|
||||
? 'waiting'
|
||||
: 'planning';
|
||||
const commandDockExpanded = Boolean(
|
||||
isPlanning && !isCommitted && !allLivingUnitsLocked,
|
||||
);
|
||||
const presentationSnapshot = useMemo(
|
||||
() =>
|
||||
view
|
||||
@@ -657,116 +688,80 @@ export default function LiveBattleMatchPage() {
|
||||
viewReceivedAt,
|
||||
]);
|
||||
|
||||
const commandDockStyle = {
|
||||
'--battle-command-safe': commandDockExpanded
|
||||
? 'clamp(10.75rem, 25dvh, 13.5rem)'
|
||||
: 'clamp(4.8rem, 10dvh, 5.5rem)',
|
||||
} as CSSProperties;
|
||||
const statusNotice = view?.resolutionFailure
|
||||
? {
|
||||
tone: 'danger' as const,
|
||||
message: '本回合结算失败,战局已安全冻结;已锁定指令不会丢失。',
|
||||
}
|
||||
: connectionStatus === 'disconnected'
|
||||
? {
|
||||
tone: 'warning' as const,
|
||||
message: '连接已中断,正在恢复战局;恢复前不能提交新指令。',
|
||||
}
|
||||
: error
|
||||
? { tone: 'danger' as const, message: error }
|
||||
: actionError
|
||||
? { tone: 'danger' as const, message: actionError }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh flex-col overflow-hidden bg-[#eee7d6] text-[#2c1810]">
|
||||
<header className="relative z-20 flex min-h-16 items-center justify-between gap-3 border-b border-[#2c1810]/20 bg-[#eee7d6]/95 px-4 py-2 backdrop-blur md:px-8">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Link
|
||||
to="/game/battle/history"
|
||||
className="border-b border-dashed border-[#2c1810]/35 px-1 py-1 text-xs text-[#2c1810]/65"
|
||||
>
|
||||
[离阵]
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[0.65rem] tracking-[0.22em] text-[#2c1810]/55">
|
||||
实时同步战局
|
||||
</p>
|
||||
<h1 className="truncate text-base font-semibold tracking-[0.12em]">
|
||||
{matchId ?? '对局'}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-xs text-[#2c1810]/70">
|
||||
<strong className="block tracking-[0.12em]">
|
||||
第{' '}
|
||||
{presentationActive
|
||||
? view?.presentation?.plan.round
|
||||
: (view?.round ?? '—')}{' '}
|
||||
回合 ·{' '}
|
||||
{presentationActive ? '行动演算' : (view?.status ?? '连接中')}
|
||||
</strong>
|
||||
<span className="mt-1 block">
|
||||
{presentationActive
|
||||
? '按出手顺序播放中'
|
||||
: allPlayersReady
|
||||
? `剩余 ${formatRemaining(view?.deadlineAt, serverNow)}`
|
||||
: '等待玩家接受邀请'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[0.65rem] text-[#3f6b56]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDebugOpen((current) => !current)}
|
||||
className="border-b border-dashed border-[#2c1810]/35 text-[#2c1810]/60"
|
||||
>
|
||||
{debugOpen ? '关闭日志' : '战斗日志'}
|
||||
</button>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${connectionStatus === 'connected' ? 'bg-current' : connectionStatus === 'disconnected' ? 'bg-[#8f2433]' : 'bg-[#946718]'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="hidden sm:inline">
|
||||
{connectionStatus === 'connected'
|
||||
? '已连接'
|
||||
: connectionStatus === 'disconnected'
|
||||
? '正在重连'
|
||||
: '连接中'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<section className="relative z-20 mx-auto w-full max-w-6xl border-b border-[#8f2433]/30 bg-[#8f2433]/5 px-4 py-3 text-sm text-[#8f2433]">
|
||||
{error}
|
||||
</section>
|
||||
)}
|
||||
{actionError && (
|
||||
<section className="relative z-20 mx-auto w-full max-w-6xl border-b border-[#8f2433]/30 bg-[#8f2433]/5 px-4 py-2 text-center text-xs text-[#8f2433]">
|
||||
{actionError}
|
||||
</section>
|
||||
)}
|
||||
{view?.resolutionFailure && (
|
||||
<section className="relative z-20 mx-auto w-full max-w-6xl border-b border-[#8f2433]/30 bg-[#8f2433]/10 px-4 py-2 text-center text-xs text-[#8f2433]">
|
||||
本回合结算失败,战局已被安全冻结并等待服务恢复;你的已锁定指令不会丢失。
|
||||
</section>
|
||||
)}
|
||||
|
||||
{connectionStatus === 'disconnected' && (
|
||||
<section className="relative z-20 border-b border-[#946718]/30 bg-[#946718]/5 px-4 py-2 text-center text-xs text-[#694d1d]">
|
||||
连接已中断,正在恢复战局;恢复前不能提交新指令。
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="relative min-h-0 flex-1 overflow-hidden px-3 py-3 md:px-8 md:py-5">
|
||||
<div className="relative mx-auto flex h-full min-h-[24rem] max-w-7xl items-center justify-center border border-[#2c1810]/15 bg-[#e8dfca]/55 p-3 shadow-inner md:p-8">
|
||||
<div className="pointer-events-none absolute inset-x-8 top-1/2 border-t border-dashed border-[#2c1810]/15" />
|
||||
<div className="pointer-events-none absolute inset-y-8 left-1/2 border-l border-dashed border-[#8f2433]/15" />
|
||||
<div
|
||||
ref={phaserRootRef}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
aria-label="多人实时战斗场景"
|
||||
/>
|
||||
{!view && (
|
||||
<p className="relative z-10 py-20 text-center text-sm text-[#2c1810]/50">
|
||||
正在建立战斗服务连接…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{inspectedUnit && (
|
||||
<aside className="absolute right-3 bottom-3 max-w-[15rem] border-r-2 border-[#8f2433] bg-[#eee7d6]/90 px-3 py-2 text-right shadow-sm backdrop-blur md:right-5 md:bottom-5">
|
||||
<strong className="block text-sm">{inspectedUnit.name}</strong>
|
||||
<span className="mt-1 block text-[0.68rem] text-[#2c1810]/60">
|
||||
{inspectedUnit.alive ? '在阵' : '已离阵'} · 护盾{' '}
|
||||
{inspectedUnit.shield}
|
||||
</span>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
<main
|
||||
className="relative h-[100dvh] min-h-0 overflow-hidden bg-[#eee7d6] text-[#2c1810]"
|
||||
style={commandDockStyle}
|
||||
>
|
||||
<section
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
aria-label="实时多人战局"
|
||||
>
|
||||
<div
|
||||
ref={phaserRootRef}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
aria-label="多人实时战斗场景"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-36 bg-gradient-to-b from-[#eee7d6]/72 via-[#eee7d6]/24 to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-56 bg-gradient-to-t from-[#d8ccb2]/60 via-[#eee7d6]/18 to-transparent" />
|
||||
{!view && (
|
||||
<p className="absolute inset-0 z-20 grid place-items-center text-sm text-[#2c1810]/50">
|
||||
正在建立战斗服务连接…
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<BattleRoundHud
|
||||
round={
|
||||
presentationActive ? view?.presentation?.plan.round : view?.round
|
||||
}
|
||||
phase={roundPhase}
|
||||
remainingSeconds={roundRemainingSeconds}
|
||||
/>
|
||||
<BattleUtilityHud
|
||||
connectionStatus={connectionStatus}
|
||||
debugOpen={debugOpen}
|
||||
onToggleDebug={() => setDebugOpen((current) => !current)}
|
||||
/>
|
||||
|
||||
{statusNotice && (
|
||||
<BattleStatusNotice tone={statusNotice.tone}>
|
||||
{statusNotice.message}
|
||||
</BattleStatusNotice>
|
||||
)}
|
||||
|
||||
{inspectedUnit && view && (
|
||||
<BattleUnitInspector
|
||||
unit={inspectedUnit}
|
||||
isAlly={inspectedUnit.teamId === view.teamId}
|
||||
onClose={() => setInspectedUnitId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{debugOpen && (
|
||||
<aside
|
||||
className="fixed inset-y-16 right-0 z-40 flex w-full max-w-md flex-col border-l border-[#2c1810]/20 bg-[#eee7d6]/98 p-4 shadow-2xl backdrop-blur sm:w-[26rem]"
|
||||
className="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col border-l border-[#2c1810]/20 bg-[#eee7d6]/98 p-4 pt-[calc(env(safe-area-inset-top)+1rem)] pb-[calc(env(safe-area-inset-bottom)+1rem)] shadow-2xl backdrop-blur sm:w-[26rem]"
|
||||
aria-label="Debug 战斗日志"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-3 border-b border-[#2c1810]/15 pb-3 text-[0.65rem] text-[#2c1810]/55">
|
||||
@@ -774,6 +769,7 @@ export default function LiveBattleMatchPage() {
|
||||
<strong className="block text-xs tracking-[0.12em] text-[#2c1810]">
|
||||
引擎事实 / 动画对照
|
||||
</strong>
|
||||
<span className="mt-1 block">对局 {matchId ?? '—'}</span>
|
||||
<span className="mt-1 block">
|
||||
指令集{' '}
|
||||
{view?.presentation?.commandSetId ??
|
||||
@@ -803,26 +799,55 @@ export default function LiveBattleMatchPage() {
|
||||
)}
|
||||
|
||||
{targetAbility && (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-28 z-30 flex justify-center px-4">
|
||||
<div className="pointer-events-auto flex items-center gap-3 border border-[#8f2433]/40 bg-[#eee7d6]/95 px-4 py-2 text-xs shadow-lg backdrop-blur">
|
||||
<span>
|
||||
<strong>「{targetAbility.name}」</strong> · 点击战场中脉动高亮的
|
||||
{abilityTargetLabel(targetAbility)}目标
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="border-b border-dashed border-[#2c1810]/40 px-1 text-[#2c1810]/65"
|
||||
onClick={clearActiveDraft}
|
||||
>
|
||||
换招
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<BattleTargetPrompt
|
||||
abilityName={targetAbility.name}
|
||||
targetLabel={abilityTargetLabel(targetAbility)}
|
||||
onCancel={clearActiveDraft}
|
||||
/>
|
||||
)}
|
||||
|
||||
<footer className="relative z-20 border-t border-[#2c1810]/20 bg-[#eee7d6]/97 px-3 pt-2 pb-[calc(env(safe-area-inset-bottom)+0.65rem)] backdrop-blur md:px-8">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 gap-2 overflow-x-auto">
|
||||
<BattleCommandDock
|
||||
expanded={commandDockExpanded}
|
||||
summary={
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<strong className="block truncate text-sm text-[#3f6b56]">
|
||||
{isCommitted
|
||||
? '本方行动已锁定'
|
||||
: commitPending
|
||||
? '正在封存本方行动'
|
||||
: allLivingUnitsLocked
|
||||
? '本方单位均已选定'
|
||||
: activeUnit && view
|
||||
? `轮到 ${unitName(view, activeUnit.unitId)} 选择行动`
|
||||
: '等待战局指令'}
|
||||
</strong>
|
||||
<span className="mt-0.5 block truncate text-xs text-[#2c1810]/55">
|
||||
{presentationActive
|
||||
? '正在按出手顺序播放本回合'
|
||||
: isResolving
|
||||
? '双方指令已封存,正在统一演算'
|
||||
: isCommitted
|
||||
? '等待其他玩家完成本回合'
|
||||
: `${Object.keys(plannedIntents).length}/${ownUnits.filter((unit) => unit.alive).length} 个单位已定`}
|
||||
</span>
|
||||
</div>
|
||||
{allLivingUnitsLocked &&
|
||||
actionError &&
|
||||
!commitPending &&
|
||||
!isCommitted ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-full border border-[#8f2433]/35 px-3 py-1.5 text-xs text-[#8f2433]"
|
||||
onClick={() => submitLockedIntents(plannedIntents)}
|
||||
>
|
||||
重新提交
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
@@ -835,14 +860,14 @@ export default function LiveBattleMatchPage() {
|
||||
: !activeUnit.basicAttack?.ready)
|
||||
}
|
||||
onClick={chooseBasicAttack}
|
||||
className={`min-w-24 border px-3 py-2 text-left disabled:cursor-not-allowed disabled:opacity-40 ${activeUnit?.forcedAction ? 'border-[#8f2433]/55 text-[#8f2433]' : 'border-[#2c1810]/30'}`}
|
||||
className={`min-h-14 min-w-28 rounded-lg border bg-white/25 px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${activeUnit?.forcedAction ? 'border-[#8f2433]/55 text-[#8f2433]' : 'border-[#2c1810]/25 hover:border-[#2c1810]/45'}`}
|
||||
>
|
||||
<strong className="block truncate text-xs">
|
||||
<strong className="block truncate text-sm">
|
||||
{activeUnit?.forcedAction
|
||||
? `${activeUnit.forcedAction.abilityName}(强制)`
|
||||
: (activeUnit?.basicAttack?.name ?? '普通攻击')}
|
||||
</strong>
|
||||
<span className="mt-1 block text-[0.6rem] text-[#2c1810]/55">
|
||||
<span className="mt-1 block text-xs text-[#2c1810]/55">
|
||||
{activeUnit?.forcedAction ? '蓄势已成 · 选择目标' : '敌方单体'}
|
||||
</span>
|
||||
</button>
|
||||
@@ -858,12 +883,12 @@ export default function LiveBattleMatchPage() {
|
||||
Boolean(activeUnit && plannedIntents[activeUnit.unitId])
|
||||
}
|
||||
onClick={() => chooseAbility(ability)}
|
||||
className="min-w-24 border border-[#3f6b56]/40 px-3 py-2 text-left disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="min-h-14 min-w-28 rounded-lg border border-[#3f6b56]/35 bg-white/25 px-3 py-2 text-left transition-colors hover:border-[#3f6b56]/60 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<strong className="block truncate text-xs text-[#3f6b56]">
|
||||
<strong className="block truncate text-sm text-[#3f6b56]">
|
||||
{ability.name}
|
||||
</strong>
|
||||
<span className="mt-1 block text-[0.6rem] text-[#2c1810]/55">
|
||||
<span className="mt-1 block text-xs text-[#2c1810]/55">
|
||||
{ability.ready
|
||||
? abilityTargetLabel(ability)
|
||||
: unavailableAbilityLabel(ability)}
|
||||
@@ -879,85 +904,58 @@ export default function LiveBattleMatchPage() {
|
||||
Boolean(activeUnit && plannedIntents[activeUnit.unitId])
|
||||
}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="min-w-24 border border-dashed border-[#2c1810]/30 px-3 py-2 text-left text-xs text-[#2c1810]/65 disabled:opacity-40"
|
||||
className="min-h-14 min-w-28 rounded-lg border border-dashed border-[#2c1810]/30 bg-white/15 px-3 py-2 text-left text-sm text-[#2c1810]/65 transition-colors hover:border-[#2c1810]/50 disabled:opacity-40"
|
||||
>
|
||||
全部术法
|
||||
<br />
|
||||
<span className="text-[0.6rem]">
|
||||
<span className="text-xs">
|
||||
{activeAbilities.length} 项 · {BATTLE_QUICKBAR_MAX_SLOTS}{' '}
|
||||
槽快捷栏
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="shrink-0 text-right text-[0.68rem] text-[#2c1810]/60"
|
||||
aria-live="polite"
|
||||
>
|
||||
<strong className="block text-xs text-[#3f6b56]">
|
||||
{isCommitted
|
||||
? '本方已提交'
|
||||
: commitPending
|
||||
? '正在提交'
|
||||
: allLivingUnitsLocked
|
||||
? '本方已选定'
|
||||
: `${Object.keys(plannedIntents).length}/${ownUnits.filter((unit) => unit.alive).length} 已定`}
|
||||
</strong>
|
||||
{allLivingUnitsLocked &&
|
||||
actionError &&
|
||||
!commitPending &&
|
||||
!isCommitted ? (
|
||||
</>
|
||||
}
|
||||
units={
|
||||
<div className="flex min-w-max gap-1.5" aria-label="受控单位">
|
||||
{ownUnits.map((unit) => (
|
||||
<button
|
||||
key={unit.unitId}
|
||||
type="button"
|
||||
className="mt-1 border-b border-dashed border-[#8f2433]/45 text-[#8f2433]"
|
||||
onClick={() => submitLockedIntents(plannedIntents)}
|
||||
disabled={
|
||||
Boolean(plannedIntents[unit.unitId]) ||
|
||||
isCommitted ||
|
||||
Boolean(targetAbility && unit.unitId !== resolvedActiveUnitId)
|
||||
}
|
||||
onClick={() => setActiveUnitId(unit.unitId)}
|
||||
className={`min-h-9 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors disabled:cursor-default ${plannedIntents[unit.unitId] ? 'border-[#735080]/30 bg-[#735080]/8 text-[#735080]' : unit.unitId === resolvedActiveUnitId ? 'border-[#8f2433]/45 bg-[#8f2433]/7 text-[#8f2433]' : 'border-transparent text-[#2c1810]/55 hover:border-[#2c1810]/20'}`}
|
||||
>
|
||||
重新提交
|
||||
{unitName(view!, unit.unitId)} ·{' '}
|
||||
{commandDrafts[unit.unitId]
|
||||
? '选择目标'
|
||||
: submittedIntentLabel(unit.unitId)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="mt-1 block">选定后不可修改</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mx-auto mt-2 flex max-w-7xl gap-2 overflow-x-auto"
|
||||
aria-label="受控单位"
|
||||
>
|
||||
{ownUnits.map((unit) => (
|
||||
<button
|
||||
key={unit.unitId}
|
||||
type="button"
|
||||
disabled={
|
||||
Boolean(plannedIntents[unit.unitId]) ||
|
||||
isCommitted ||
|
||||
Boolean(targetAbility && unit.unitId !== resolvedActiveUnitId)
|
||||
}
|
||||
onClick={() => setActiveUnitId(unit.unitId)}
|
||||
className={`border-b px-2 py-1 text-[0.68rem] whitespace-nowrap disabled:cursor-default ${plannedIntents[unit.unitId] ? 'border-[#735080]/45 text-[#735080]' : unit.unitId === resolvedActiveUnitId ? 'border-[#8f2433] text-[#8f2433]' : 'border-transparent text-[#2c1810]/55'}`}
|
||||
>
|
||||
{unitName(view!, unit.unitId)} ·{' '}
|
||||
{commandDrafts[unit.unitId]
|
||||
? '选择目标'
|
||||
: submittedIntentLabel(unit.unitId)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto mt-1 max-w-7xl text-[0.62rem] text-[#2c1810]/55">
|
||||
{commandMode === 'select_ability' &&
|
||||
(activeUnit?.forcedAction
|
||||
? `蓄势已成:本回合必须施放《${activeUnit.forcedAction.abilityName}》,请选择目标。`
|
||||
: `为 ${activeUnit ? unitName(view!, activeUnit.unitId) : '当前单位'} 选择招式;目标点下后立即锁定。`)}
|
||||
{commandMode === 'locked' &&
|
||||
(commitPending
|
||||
? '全部单位已选定,正在一次性提交本方操作。'
|
||||
: '全部单位已选定,等待服务端确认。')}
|
||||
{commandMode === 'committed' &&
|
||||
(isResolving
|
||||
? '双方指令已封存,服务端正在统一结算。'
|
||||
: '本方出招已确认,等待其他玩家;确认后不可修改。')}
|
||||
{commandMode === 'presenting' &&
|
||||
'本回合已统一结算,正在按出手顺序播放。'}
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
guidance={
|
||||
<>
|
||||
{commandMode === 'select_ability' &&
|
||||
(activeUnit?.forcedAction
|
||||
? `蓄势已成:本回合必须施放《${activeUnit.forcedAction.abilityName}》,请选择目标。`
|
||||
: `为 ${activeUnit ? unitName(view!, activeUnit.unitId) : '当前单位'} 选择招式;目标点下后立即锁定。`)}
|
||||
{commandMode === 'locked' &&
|
||||
(commitPending
|
||||
? '全部单位已选定,正在一次性提交本方操作。'
|
||||
: '全部单位已选定,等待服务端确认。')}
|
||||
{commandMode === 'committed' &&
|
||||
(isResolving
|
||||
? '双方指令已封存,服务端正在统一结算。'
|
||||
: '本方出招已确认,等待其他玩家;确认后不可修改。')}
|
||||
{commandMode === 'presenting' &&
|
||||
'本回合已统一结算,正在按出手顺序播放。'}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<BattleAbilityDrawer
|
||||
isOpen={drawerOpen}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { GameplayTags } from '@shared/engine/shared/tag-domain';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BattleResolutionError } from '../core/BattleResolutionError';
|
||||
import { BattleRoster } from '../core/BattleRoster';
|
||||
import { AttributeType } from '../core/types';
|
||||
import { setQueuedAction } from '../core/runtimeState';
|
||||
import { AbilityType, AttributeType, DamageType } from '../core/types';
|
||||
import {
|
||||
captureBattleCheckpoint,
|
||||
createBattleBlueprint,
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
} from './BattleMatchStateMachine';
|
||||
import type { BattleControllerV1, BattleMatchStateV1 } from './types';
|
||||
|
||||
function save(): BattleSaveV1 {
|
||||
function save(options: { queuedUnitId?: string } = {}): BattleSaveV1 {
|
||||
const runtime = new BattleRuntime();
|
||||
const units = [
|
||||
new Unit(
|
||||
@@ -33,6 +35,34 @@ function save(): BattleSaveV1 {
|
||||
new Unit('b0', 'b0', {}, { runtime, teamId: 'b', slot: 0 }),
|
||||
new Unit('b1', 'b1', {}, { runtime, teamId: 'b', slot: 1 }),
|
||||
];
|
||||
const queuedUnit = units.find((unit) => unit.id === options.queuedUnitId);
|
||||
if (queuedUnit) {
|
||||
setQueuedAction(
|
||||
queuedUnit,
|
||||
{
|
||||
slug: 'queued-strike',
|
||||
name: '蓄势一击',
|
||||
type: AbilityType.ACTIVE_SKILL,
|
||||
tags: [
|
||||
GameplayTags.ABILITY.KIND.SKILL,
|
||||
GameplayTags.ABILITY.FUNCTION.DAMAGE,
|
||||
GameplayTags.ABILITY.CHANNEL.TRUE,
|
||||
],
|
||||
targetPolicy: { team: 'enemy', scope: 'single' },
|
||||
effects: [
|
||||
{
|
||||
type: 'damage',
|
||||
params: {
|
||||
value: { base: 10, coefficient: 0 },
|
||||
damageType: DamageType.TRUE,
|
||||
canCrit: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ interruptPolicy: 'uninterruptible', hitPolicy: 'guaranteed' },
|
||||
);
|
||||
}
|
||||
const roster = new BattleRoster(units);
|
||||
const blueprint = createBattleBlueprint('match-test', roster);
|
||||
return {
|
||||
@@ -140,6 +170,83 @@ describe('BattleMatchStateMachine', () => {
|
||||
).toThrow('every living controlled unit exactly once');
|
||||
});
|
||||
|
||||
it('validates only the submitting player and seals queued actions regardless of commit order', () => {
|
||||
let state = createBattleMatchState({
|
||||
matchId: 'match-test',
|
||||
battle: save({ queuedUnitId: 'a0' }),
|
||||
controllers,
|
||||
now: 1_000,
|
||||
});
|
||||
|
||||
state = transitionBattleMatch(
|
||||
state,
|
||||
{
|
||||
type: 'commit_player_intents',
|
||||
matchId: 'match-test',
|
||||
...commandBase(state, 'commit-b-first'),
|
||||
playerId: 'p-b',
|
||||
intents: basicIntents('p-b'),
|
||||
},
|
||||
1_001,
|
||||
).state;
|
||||
|
||||
expect(state.status).toBe('planning');
|
||||
expect(Object.keys(state.planning?.submissions ?? {}).sort()).toEqual([
|
||||
'b0',
|
||||
'b1',
|
||||
]);
|
||||
const alphaView = createBattleMatchPlayerView(state, 'p-a', 1_002);
|
||||
const betaView = createBattleMatchPlayerView(state, 'p-b', 1_002);
|
||||
expect(alphaView.ownCommitted).toBe(false);
|
||||
expect(alphaView.ownSubmissions).toEqual({});
|
||||
expect(betaView.ownCommitted).toBe(true);
|
||||
expect(betaView).not.toHaveProperty('committedPlayerIds');
|
||||
|
||||
state = transitionBattleMatch(
|
||||
state,
|
||||
{
|
||||
type: 'commit_player_intents',
|
||||
matchId: 'match-test',
|
||||
...commandBase(state, 'commit-a-second'),
|
||||
playerId: 'p-a',
|
||||
intents: basicIntents('p-a'),
|
||||
},
|
||||
1_003,
|
||||
).state;
|
||||
|
||||
expect(state.status).toBe('resolving');
|
||||
expect(state.resolving?.commandSet.intents.a0).toEqual({
|
||||
kind: 'basic_attack',
|
||||
targetUnitId: 'b0',
|
||||
submittedBy: 'player',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an illegal intent from the submitting player before other players commit', () => {
|
||||
const state = createBattleMatchState({
|
||||
matchId: 'match-test',
|
||||
battle: save(),
|
||||
controllers,
|
||||
now: 1_000,
|
||||
});
|
||||
expect(() =>
|
||||
transitionBattleMatch(
|
||||
state,
|
||||
{
|
||||
type: 'commit_player_intents',
|
||||
matchId: 'match-test',
|
||||
...commandBase(state, 'illegal-local-intent'),
|
||||
playerId: 'p-a',
|
||||
intents: {
|
||||
a0: { kind: 'ability', abilityId: 'unknown', targetUnitId: 'b0' },
|
||||
a1: { kind: 'basic_attack', targetUnitId: 'b0' },
|
||||
},
|
||||
},
|
||||
1_001,
|
||||
),
|
||||
).toThrow('cannot use ability unknown');
|
||||
});
|
||||
|
||||
it('rejects cross-player intents and supports request idempotency', () => {
|
||||
const state = createBattleMatchState({
|
||||
matchId: 'match-test',
|
||||
@@ -228,6 +335,30 @@ describe('BattleMatchStateMachine', () => {
|
||||
expect(JSON.stringify(view)).not.toContain('battle_save_v1');
|
||||
});
|
||||
|
||||
it('fills a missing queued action only when the trusted deadline is reached', () => {
|
||||
const state = createBattleMatchState({
|
||||
matchId: 'match-test',
|
||||
battle: save({ queuedUnitId: 'a0' }),
|
||||
controllers,
|
||||
now: 1_000,
|
||||
});
|
||||
const result = transitionBattleMatch(
|
||||
state,
|
||||
{
|
||||
type: 'resolve_planning_timeout',
|
||||
matchId: 'match-test',
|
||||
...commandBase(state, 'queued-timeout'),
|
||||
},
|
||||
31_000,
|
||||
);
|
||||
expect(result.state.status).toBe('resolving');
|
||||
expect(result.state.resolving?.commandSet.intents.a0).toEqual({
|
||||
kind: 'basic_attack',
|
||||
targetUnitId: 'b0',
|
||||
submittedBy: 'timeout',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns to planning after applying a non-terminal resolution', () => {
|
||||
let state = createBattleMatchState({
|
||||
matchId: 'match-test',
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { ActiveSkill } from '../abilities/ActiveSkill';
|
||||
import { peekQueuedAction } from '../core/runtimeState';
|
||||
import { AbilityFactory } from '../factories/AbilityFactory';
|
||||
import {
|
||||
restoreBattleSave,
|
||||
validateBattleSave,
|
||||
} from '../persistence/BattleStateCodec';
|
||||
import type { BattleSaveV1 } from '../persistence/types';
|
||||
import { resolveLegalBasicAttack } from '../round/BasicAttackResolver';
|
||||
import { createBattlePlanningView } from '../round/BattlePlanningView';
|
||||
import { sealRoundCommandSet } from '../round/BattleRoundResolver';
|
||||
import {
|
||||
sealRoundCommandSet,
|
||||
validateBattleIntents,
|
||||
} from '../round/BattleRoundResolver';
|
||||
import { resolveLegalQueuedAction } from '../round/QueuedActionResolver';
|
||||
import type { BattleActionIntentV1, RoundCommandSetV1 } from '../round/types';
|
||||
import { ROUND_PLANNING_TIMEOUT_MS } from '../round/types';
|
||||
import { TargetSelectionSystem } from '../systems/TargetSelectionSystem';
|
||||
import { resolveLegalBasicAttack } from '../round/BasicAttackResolver';
|
||||
import { createBattlePublicSnapshot } from './BattlePublicSnapshot';
|
||||
import type {
|
||||
BattleControllerV1,
|
||||
@@ -123,6 +124,7 @@ export function transitionBattleMatch(
|
||||
normalizeClientIntent(command.intents[unitId]),
|
||||
]),
|
||||
);
|
||||
validateBattleIntents(current.battle, normalized);
|
||||
const submissions = {
|
||||
...current.planning.submissions,
|
||||
...normalized,
|
||||
@@ -139,10 +141,6 @@ export function transitionBattleMatch(
|
||||
committedPlayerIds: committed,
|
||||
},
|
||||
};
|
||||
// Validate the complete prospective round now, filling other players with
|
||||
// deterministic timeout attacks. Invalid targets never remain latent until
|
||||
// the final player commits.
|
||||
sealRoundCommandSet(candidate.battle, buildCommandSet(candidate));
|
||||
return transition(
|
||||
current,
|
||||
{
|
||||
@@ -160,7 +158,7 @@ export function transitionBattleMatch(
|
||||
const committedPlayerIds = current.controllers.map(
|
||||
(controller) => controller.playerId,
|
||||
);
|
||||
const submissions = fillTimeouts(current);
|
||||
const submissions = completeMissingIntentsAtDeadline(current);
|
||||
return transition(
|
||||
current,
|
||||
{
|
||||
@@ -331,7 +329,7 @@ export function createBattleMatchPlayerView(
|
||||
.filter((unitId) => planning?.submissions[unitId])
|
||||
.map((unitId) => [unitId, planning!.submissions[unitId]]),
|
||||
),
|
||||
committedPlayerIds: planning?.committedPlayerIds ?? [],
|
||||
ownCommitted: planning?.committedPlayerIds.includes(playerId) ?? false,
|
||||
latestResolution: state.latestResolution,
|
||||
resolutionFailure: state.resolving?.failure
|
||||
? {
|
||||
@@ -370,7 +368,7 @@ function transition(
|
||||
});
|
||||
const planning = next.planning;
|
||||
if (planning && allControllersCommitted(next)) {
|
||||
const commandSet = buildCommandSet(next);
|
||||
const commandSet = buildCompleteCommandSet(next);
|
||||
const sealed = sealRoundCommandSet(next.battle, commandSet);
|
||||
next = clone({
|
||||
...next,
|
||||
@@ -384,18 +382,18 @@ function transition(
|
||||
return { state: next, changed: true, duplicateRequest: false };
|
||||
}
|
||||
|
||||
function buildCommandSet(state: BattleMatchStateV1): RoundCommandSetV1 {
|
||||
const submissions = fillTimeouts(state);
|
||||
function buildCompleteCommandSet(state: BattleMatchStateV1): RoundCommandSetV1 {
|
||||
return {
|
||||
version: 'round_command_set_v1',
|
||||
commandSetId: `${state.matchId}:${state.planning!.round}:${state.planning!.checkpointRevision}`,
|
||||
round: state.planning!.round,
|
||||
checkpointRevision: state.planning!.checkpointRevision,
|
||||
intents: submissions,
|
||||
intents: { ...state.planning!.submissions },
|
||||
};
|
||||
}
|
||||
|
||||
function fillTimeouts(
|
||||
/** Completes only genuinely missing actions after the trusted deadline fires. */
|
||||
function completeMissingIntentsAtDeadline(
|
||||
state: BattleMatchStateV1,
|
||||
): Record<string, BattleActionIntentV1> {
|
||||
const restored = restoreBattleSave(state.battle);
|
||||
@@ -404,17 +402,11 @@ function fillTimeouts(
|
||||
...state.planning!.submissions,
|
||||
};
|
||||
const allUnits = restored.roster.getAllUnits();
|
||||
const targetSystem = new TargetSelectionSystem();
|
||||
for (const unit of restored.roster.getLivingUnits()) {
|
||||
if (result[unit.id]) continue;
|
||||
const queued = peekQueuedAction(unit);
|
||||
const ability = queued ? AbilityFactory.create(queued.ability) : null;
|
||||
const target = ability instanceof ActiveSkill
|
||||
? targetSystem
|
||||
.getTargetCandidates(unit, ability.targetPolicy, allUnits)
|
||||
.find((candidate) =>
|
||||
ability.canTrigger({ caster: unit, target: candidate }),
|
||||
)
|
||||
const target = queued
|
||||
? resolveLegalQueuedAction(unit, allUnits)?.target
|
||||
: resolveLegalBasicAttack(unit, allUnits)?.target;
|
||||
if (!target)
|
||||
throw new Error(`Unit ${unit.id} has no legal timeout attack target`);
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface BattleMatchPlayerViewV1 {
|
||||
readonly publicSnapshot: BattlePublicSnapshotV1;
|
||||
readonly latestResolution?: BattleRoundResolutionPublicV1;
|
||||
readonly ownSubmissions: Readonly<Record<UnitId, BattleActionIntentV1>>;
|
||||
readonly committedPlayerIds: readonly PlayerId[];
|
||||
readonly ownCommitted: boolean;
|
||||
readonly commandReceipt?: BattleCommandReceiptV1;
|
||||
readonly resolutionFailure?: BattleResolutionFailurePublicV1;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { GameplayTags } from '@shared/engine/shared/tag-domain';
|
||||
import type { AbilitySelectionStrategy } from '../abilities/AbilitySelectionStrategy';
|
||||
import { ActiveSkill } from '../abilities/ActiveSkill';
|
||||
import { AbilityFactory } from '../factories/AbilityFactory';
|
||||
import { BattleRoster } from '../core/BattleRoster';
|
||||
import { peekQueuedAction } from '../core/runtimeState';
|
||||
import { restoreBattleSave } from '../persistence/BattleStateCodec';
|
||||
@@ -15,13 +14,11 @@ import type {
|
||||
} from '../systems/TeamVictorySystem';
|
||||
import type { Unit } from '../units/Unit';
|
||||
import type { BattleStateTimelineV3, CombatSequenceV3 } from '../v3/types';
|
||||
import { initializeBattle } from './BattleLifecycleResolver';
|
||||
import { resolveLegalBasicAttack } from './BasicAttackResolver';
|
||||
import { initializeBattle } from './BattleLifecycleResolver';
|
||||
import { resolveBattleRound, sealRoundCommandSet } from './BattleRoundResolver';
|
||||
import type {
|
||||
BattleActionIntentV1,
|
||||
RoundCommandSetV1,
|
||||
} from './types';
|
||||
import { resolveLegalQueuedAction } from './QueuedActionResolver';
|
||||
import type { BattleActionIntentV1, RoundCommandSetV1 } from './types';
|
||||
|
||||
export interface AutomaticBattleResolutionV1 {
|
||||
readonly outcome: TerminalTeamVictoryResult;
|
||||
@@ -173,21 +170,14 @@ function createAutomaticIntent(
|
||||
allUnits: Unit[],
|
||||
): BattleActionIntentV1 {
|
||||
const targetSystem = new TargetSelectionSystem();
|
||||
const queued = peekQueuedAction(unit);
|
||||
if (queued) {
|
||||
const queuedAbility = AbilityFactory.create(queued.ability);
|
||||
if (!(queuedAbility instanceof ActiveSkill)) {
|
||||
throw new Error(`Queued action ${queued.ability.slug} is not active`);
|
||||
if (peekQueuedAction(unit)) {
|
||||
const queuedAction = resolveLegalQueuedAction(unit, allUnits);
|
||||
if (!queuedAction) {
|
||||
throw new Error(`Queued action has no legal target for ${unit.id}`);
|
||||
}
|
||||
const target = targetSystem.getTargetCandidates(
|
||||
unit,
|
||||
queuedAbility.targetPolicy,
|
||||
allUnits,
|
||||
)[0];
|
||||
if (!target) throw new Error(`Queued action has no target for ${unit.id}`);
|
||||
return {
|
||||
kind: 'basic_attack',
|
||||
targetUnitId: target.id,
|
||||
targetUnitId: queuedAction.target.id,
|
||||
submittedBy: 'timeout',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { ActiveSkill } from '../abilities/ActiveSkill';
|
||||
import type { BattleRoster } from '../core/BattleRoster';
|
||||
import { peekQueuedAction } from '../core/runtimeState';
|
||||
import type { TeamId } from '../core/types';
|
||||
import { TargetSelectionSystem } from '../systems/TargetSelectionSystem';
|
||||
import { AbilityFactory } from '../factories/AbilityFactory';
|
||||
import { peekQueuedAction } from '../core/runtimeState';
|
||||
import { resolveLegalBasicAttack } from './BasicAttackResolver';
|
||||
import type {
|
||||
BattlePlanningViewV1,
|
||||
PlanningAbilityViewV1,
|
||||
} from './types';
|
||||
import { resolveLegalQueuedAction } from './QueuedActionResolver';
|
||||
import type { BattlePlanningViewV1, PlanningAbilityViewV1 } from './types';
|
||||
|
||||
export function createBattlePlanningView(input: {
|
||||
roster: BattleRoster;
|
||||
@@ -22,10 +19,9 @@ export function createBattlePlanningView(input: {
|
||||
.filter((unit) => !input.teamId || unit.teamId === input.teamId)
|
||||
.map((unit) => {
|
||||
const queued = unit.isAlive() ? peekQueuedAction(unit) : undefined;
|
||||
const queuedAbility = queued ? AbilityFactory.create(queued.ability) : undefined;
|
||||
const forcedTargets = queuedAbility instanceof ActiveSkill
|
||||
? targetSystem.getTargetCandidates(unit, queuedAbility.targetPolicy, allUnits)
|
||||
: [];
|
||||
const queuedAction = queued
|
||||
? resolveLegalQueuedAction(unit, allUnits)
|
||||
: null;
|
||||
const defaultAttack = unit.isAlive()
|
||||
? unit.abilities.getDefaultAttack()
|
||||
: undefined;
|
||||
@@ -48,12 +44,14 @@ export function createBattlePlanningView(input: {
|
||||
basicAttack?.legalTargets.map((target) => target.id) ?? [],
|
||||
}
|
||||
: undefined,
|
||||
forcedAction: queuedAbility instanceof ActiveSkill
|
||||
forcedAction: queuedAction
|
||||
? {
|
||||
kind: 'queued_action_target' as const,
|
||||
abilityId: queuedAbility.id,
|
||||
abilityName: queuedAbility.name,
|
||||
legalTargetIds: forcedTargets.map((target) => target.id),
|
||||
abilityId: queuedAction.ability.id,
|
||||
abilityName: queuedAction.ability.name,
|
||||
legalTargetIds: queuedAction.legalTargets.map(
|
||||
(target) => target.id,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
abilities: unit.isAlive() && !queued
|
||||
|
||||
@@ -40,9 +40,10 @@ import type { Unit } from '../units/Unit';
|
||||
import { toBattleStateTimelineV3 } from '../v3/BattleRecordV3';
|
||||
import { CombatMechanicCodeV3 } from '../v3/mechanics';
|
||||
import { CombatSystemSourceV3 } from '../v3/origin';
|
||||
import { BattleResolutionContext } from './BattleResolutionContext';
|
||||
import { recordBattleEnd } from './BattleLifecycleResolver';
|
||||
import { resolveLegalBasicAttack } from './BasicAttackResolver';
|
||||
import { recordBattleEnd } from './BattleLifecycleResolver';
|
||||
import { BattleResolutionContext } from './BattleResolutionContext';
|
||||
import { resolveLegalQueuedAction } from './QueuedActionResolver';
|
||||
import type {
|
||||
BattleActionIntentV1,
|
||||
BattleRoundResolutionV1,
|
||||
@@ -66,6 +67,27 @@ export function sealRoundCommandSet(
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates a partial set of player intents against one immutable checkpoint. */
|
||||
export function validateBattleIntents(
|
||||
save: BattleSaveV1,
|
||||
intents: Readonly<Record<string, BattleActionIntentV1>>,
|
||||
): void {
|
||||
const restored = restoreBattleSave(save);
|
||||
try {
|
||||
const allUnits = restored.roster.getAllUnits();
|
||||
const targetSystem = new TargetSelectionSystem();
|
||||
for (const [unitId, intent] of Object.entries(intents)) {
|
||||
const actor = restored.roster.units.get(unitId);
|
||||
if (!actor || !actor.isAlive()) {
|
||||
throw new Error(`Intent references unavailable unit ${unitId}`);
|
||||
}
|
||||
validateUnitIntent(actor, allUnits, intent, targetSystem);
|
||||
}
|
||||
} finally {
|
||||
restored.runtime.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBattleRound(
|
||||
save: BattleSaveV1,
|
||||
commandSet: RoundCommandSetV1,
|
||||
@@ -534,69 +556,68 @@ function validateAllIntents(
|
||||
const targetSystem = new TargetSelectionSystem();
|
||||
for (const actor of livingUnits) {
|
||||
const intent = commandSet.intents[actor.id];
|
||||
const queued = peekQueuedAction(actor);
|
||||
if (queued) {
|
||||
const ability = AbilityFactory.create(queued.ability);
|
||||
if (!(ability instanceof ActiveSkill) || intent.kind !== 'basic_attack') {
|
||||
throw new Error(
|
||||
`Unit ${actor.id} must select a target for its queued action`,
|
||||
);
|
||||
}
|
||||
const candidates = targetSystem.getTargetCandidates(
|
||||
actor,
|
||||
ability.targetPolicy,
|
||||
allUnits,
|
||||
);
|
||||
if (
|
||||
!candidates.some((candidate) => candidate.id === intent.targetUnitId)
|
||||
) {
|
||||
throw new Error(
|
||||
`Queued action target is not legal for unit ${actor.id}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (intent.kind === 'basic_attack') {
|
||||
const resolved = resolveLegalBasicAttack(
|
||||
actor,
|
||||
allUnits,
|
||||
intent.targetUnitId,
|
||||
);
|
||||
if (!resolved || resolved.target.id !== intent.targetUnitId) {
|
||||
throw new Error(`Basic attack is not legal for unit ${actor.id}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const ability = actor.abilities.getAbility(intent.abilityId);
|
||||
if (!(ability instanceof ActiveSkill)) {
|
||||
throw new Error(
|
||||
`Unit ${actor.id} cannot use ability ${intent.abilityId}`,
|
||||
);
|
||||
}
|
||||
const candidates = targetSystem.getTargetCandidates(
|
||||
validateUnitIntent(actor, allUnits, intent, targetSystem);
|
||||
}
|
||||
}
|
||||
|
||||
function validateUnitIntent(
|
||||
actor: Unit,
|
||||
allUnits: Unit[],
|
||||
intent: BattleActionIntentV1,
|
||||
targetSystem: TargetSelectionSystem,
|
||||
): void {
|
||||
if (peekQueuedAction(actor)) {
|
||||
const queuedAction = resolveLegalQueuedAction(
|
||||
actor,
|
||||
ability.targetPolicy,
|
||||
allUnits,
|
||||
intent.targetUnitId,
|
||||
);
|
||||
const target = intent.targetUnitId
|
||||
? candidates.find((candidate) => candidate.id === intent.targetUnitId)
|
||||
: candidates[0];
|
||||
if (
|
||||
ability.targetPolicy.scope === 'single' &&
|
||||
ability.targetPolicy.team !== 'self' &&
|
||||
!intent.targetUnitId
|
||||
) {
|
||||
throw new Error(`Ability target is required for unit ${actor.id}`);
|
||||
}
|
||||
if (
|
||||
!target ||
|
||||
(intent.targetUnitId && !candidates.includes(target)) ||
|
||||
!ability.canTrigger({ caster: actor, target })
|
||||
) {
|
||||
if (!queuedAction || intent.kind !== 'basic_attack') {
|
||||
throw new Error(
|
||||
`Ability ${ability.id} is not legal for unit ${actor.id}`,
|
||||
`Unit ${actor.id} must select a legal target for its queued action`,
|
||||
);
|
||||
}
|
||||
if (queuedAction.target.id !== intent.targetUnitId) {
|
||||
throw new Error(`Queued action target is not legal for unit ${actor.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (intent.kind === 'basic_attack') {
|
||||
const resolved = resolveLegalBasicAttack(
|
||||
actor,
|
||||
allUnits,
|
||||
intent.targetUnitId,
|
||||
);
|
||||
if (!resolved || resolved.target.id !== intent.targetUnitId) {
|
||||
throw new Error(`Basic attack is not legal for unit ${actor.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const ability = actor.abilities.getAbility(intent.abilityId);
|
||||
if (!(ability instanceof ActiveSkill)) {
|
||||
throw new Error(`Unit ${actor.id} cannot use ability ${intent.abilityId}`);
|
||||
}
|
||||
const candidates = targetSystem.getTargetCandidates(
|
||||
actor,
|
||||
ability.targetPolicy,
|
||||
allUnits,
|
||||
);
|
||||
const target = intent.targetUnitId
|
||||
? candidates.find((candidate) => candidate.id === intent.targetUnitId)
|
||||
: candidates[0];
|
||||
if (
|
||||
ability.targetPolicy.scope === 'single' &&
|
||||
ability.targetPolicy.team !== 'self' &&
|
||||
!intent.targetUnitId
|
||||
) {
|
||||
throw new Error(`Ability target is required for unit ${actor.id}`);
|
||||
}
|
||||
if (
|
||||
!target ||
|
||||
(intent.targetUnitId && !candidates.includes(target)) ||
|
||||
!ability.canTrigger({ caster: actor, target })
|
||||
) {
|
||||
throw new Error(`Ability ${ability.id} is not legal for unit ${actor.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ActiveSkill } from '../abilities/ActiveSkill';
|
||||
import { peekQueuedAction } from '../core/runtimeState';
|
||||
import { AbilityFactory } from '../factories/AbilityFactory';
|
||||
import { TargetSelectionSystem } from '../systems/TargetSelectionSystem';
|
||||
import type { Unit } from '../units/Unit';
|
||||
|
||||
export interface LegalQueuedActionV1 {
|
||||
ability: ActiveSkill;
|
||||
target: Unit;
|
||||
legalTargets: Unit[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a queued action against the immutable planning checkpoint.
|
||||
* Queued actions are mandatory and already paid for, so only target legality
|
||||
* is evaluated here; ordinary cooldown, resource and cast conditions do not
|
||||
* get a second chance to cancel the release.
|
||||
*/
|
||||
export function resolveLegalQueuedAction(
|
||||
actor: Unit,
|
||||
allUnits: Unit[],
|
||||
preferredTargetId?: string,
|
||||
): LegalQueuedActionV1 | null {
|
||||
const queued = peekQueuedAction(actor);
|
||||
if (!queued) return null;
|
||||
const ability = AbilityFactory.create(queued.ability);
|
||||
if (!(ability instanceof ActiveSkill)) return null;
|
||||
const legalTargets = new TargetSelectionSystem().getTargetCandidates(
|
||||
actor,
|
||||
ability.targetPolicy,
|
||||
allUnits,
|
||||
);
|
||||
const target =
|
||||
legalTargets.find((candidate) => candidate.id === preferredTargetId) ??
|
||||
legalTargets[0];
|
||||
return target ? { ability, target, legalTargets } : null;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ function view(): BattleMatchPlayerViewV1 {
|
||||
],
|
||||
},
|
||||
ownSubmissions: {},
|
||||
committedPlayerIds: [],
|
||||
ownCommitted: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user