优化:战斗UI

This commit is contained in:
桃树夭 committed 2026-08-10 15:04:51 +08:00
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

+2 -1
View File
@@ -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']
+2
View File
@@ -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);
},
+190
View File
@@ -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');
}
+204 -206
View File
@@ -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`);
+1 -1
View File
@@ -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,
};
}