diff --git a/public/assets/battle/realtime/arena-landscape.webp b/public/assets/battle/realtime/arena-landscape.webp
new file mode 100644
index 00000000..4cceb39a
Binary files /dev/null and b/public/assets/battle/realtime/arena-landscape.webp differ
diff --git a/public/assets/battle/realtime/arena-portrait.webp b/public/assets/battle/realtime/arena-portrait.webp
new file mode 100644
index 00000000..fc607790
Binary files /dev/null and b/public/assets/battle/realtime/arena-portrait.webp differ
diff --git a/scripts/docker-compose.nats.yml b/scripts/docker-compose.nats.yml
index e7ba82bb..593375ef 100644
--- a/scripts/docker-compose.nats.yml
+++ b/scripts/docker-compose.nats.yml
@@ -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']
diff --git a/scripts/nats-server.conf b/scripts/nats-server.conf
new file mode 100644
index 00000000..54f13526
--- /dev/null
+++ b/scripts/nats-server.conf
@@ -0,0 +1,2 @@
+# NATS server settings that are not exposed as command-line flags in NATS 2.11.
+max_payload: 8MB
diff --git a/src/react-app/components/feature/battle/realtime/RealtimeBattleHud.tsx b/src/react-app/components/feature/battle/realtime/RealtimeBattleHud.tsx
new file mode 100644
index 00000000..124f372a
--- /dev/null
+++ b/src/react-app/components/feature/battle/realtime/RealtimeBattleHud.tsx
@@ -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 (
+
+
+ 第 {round ?? '—'} 回合
+
+
+
+ {phaseLabel(phase)}
+
+
+ );
+}
+
+interface BattleUtilityHudProps {
+ connectionStatus: RealtimeBattleConnectionStatus;
+ debugOpen: boolean;
+ onToggleDebug: () => void;
+}
+
+export function BattleUtilityHud({
+ connectionStatus,
+ debugOpen,
+ onToggleDebug,
+}: BattleUtilityHudProps) {
+ const connectionLabel =
+ connectionStatus === 'connected'
+ ? '战局连接正常'
+ : connectionStatus === 'disconnected'
+ ? '连接中断,正在重连'
+ : '正在连接战局';
+
+ return (
+ <>
+
+
+ 离阵
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+interface BattleCommandDockProps {
+ expanded: boolean;
+ actions: ReactNode;
+ units: ReactNode;
+ summary: ReactNode;
+ guidance?: ReactNode;
+}
+
+export function BattleCommandDock({
+ expanded,
+ actions,
+ units,
+ summary,
+ guidance,
+}: BattleCommandDockProps) {
+ return (
+
+
+
+
{summary}
+ {!expanded && (
+
+ 指令台已收起
+
+ )}
+
+
+
+
+ {actions}
+
+
+ {units}
+
+ {guidance ? (
+
+ {guidance}
+
+ ) : null}
+
+
+
+
+ );
+}
+
+interface BattleTargetPromptProps {
+ abilityName: string;
+ targetLabel: string;
+ onCancel: () => void;
+}
+
+export function BattleTargetPrompt({
+ abilityName,
+ targetLabel,
+ onCancel,
+}: BattleTargetPromptProps) {
+ return (
+
+
+
+ 「{abilityName}」 · 请选择{targetLabel}目标
+
+
+
+
+ );
+}
+
+interface BattleStatusNoticeProps {
+ tone: 'danger' | 'warning';
+ children: ReactNode;
+}
+
+export function BattleStatusNotice({
+ tone,
+ children,
+}: BattleStatusNoticeProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface BattleUnitInspectorProps {
+ unit: BattlePublicUnitStateV1;
+ isAlly: boolean;
+ onClose: () => void;
+}
+
+export function BattleUnitInspector({
+ unit,
+ isAlly,
+ onClose,
+}: BattleUnitInspectorProps) {
+ const visibleEffects = unit.effects.slice(-4);
+ return (
+
+ );
+}
diff --git a/src/react-app/components/feature/battle/realtime/RealtimeBattlePhaserRuntime.ts b/src/react-app/components/feature/battle/realtime/RealtimeBattlePhaserRuntime.ts
index 867a0574..dad6a131 100644
--- a/src/react-app/components/feature/battle/realtime/RealtimeBattlePhaserRuntime.ts
+++ b/src/react-app/components/feature/battle/realtime/RealtimeBattlePhaserRuntime.ts
@@ -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();
+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,
+ stage: StageSize,
+) {
+ const safe = stageSafeBounds(stage);
+ const teamSizes = new Map();
+ const rectsByTeam = new Map();
+ 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();
+ 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();
private actorUnitId: string | undefined;
private commandSubmitting = false;
+ private activeTimelineActionIds = new Set();
+ 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();
+ 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,
) {
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);
},
diff --git a/src/react-app/index.css b/src/react-app/index.css
index cadc8a24..95049009 100644
--- a/src/react-app/index.css
+++ b/src/react-app/index.css
@@ -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');
}
diff --git a/src/react-app/routes/game/battle/live/route.tsx b/src/react-app/routes/game/battle/live/route.tsx
index b02bdf72..49199f44 100644
--- a/src/react-app/routes/game/battle/live/route.tsx
+++ b/src/react-app/routes/game/battle/live/route.tsx
@@ -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 (
-
-
-
-
- [离阵]
-
-
-
- 实时同步战局
-
-
- {matchId ?? '对局'}
-
-
-
-
-
- 第{' '}
- {presentationActive
- ? view?.presentation?.plan.round
- : (view?.round ?? '—')}{' '}
- 回合 ·{' '}
- {presentationActive ? '行动演算' : (view?.status ?? '连接中')}
-
-
- {presentationActive
- ? '按出手顺序播放中'
- : allPlayersReady
- ? `剩余 ${formatRemaining(view?.deadlineAt, serverNow)}`
- : '等待玩家接受邀请'}
-
-
-
-
-
-
- {connectionStatus === 'connected'
- ? '已连接'
- : connectionStatus === 'disconnected'
- ? '正在重连'
- : '连接中'}
-
-
-
-
- {error && (
-
- )}
- {actionError && (
-
- )}
- {view?.resolutionFailure && (
-
- 本回合结算失败,战局已被安全冻结并等待服务恢复;你的已锁定指令不会丢失。
-
- )}
-
- {connectionStatus === 'disconnected' && (
-
- 连接已中断,正在恢复战局;恢复前不能提交新指令。
-
- )}
-
-
-
-
-
-
- {!view && (
-
- 正在建立战斗服务连接…
-
- )}
-
- {inspectedUnit && (
-
- )}
-
+
+
+
+
+
+ {!view && (
+
+ 正在建立战斗服务连接…
+
+ )}
+
+ setDebugOpen((current) => !current)}
+ />
+
+ {statusNotice && (
+
+ {statusNotice.message}
+
+ )}
+
+ {inspectedUnit && view && (
+ setInspectedUnitId(null)}
+ />
+ )}
+
{debugOpen && (