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 ?? '—'} 回合 +

+
+
+ + {mark} + +
+
+

+ {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)}` - : '等待玩家接受邀请'} - -
-
- -
-
- - {error && ( -
- {error} -
- )} - {actionError && ( -
- {actionError} -
- )} - {view?.resolutionFailure && ( -
- 本回合结算失败,战局已被安全冻结并等待服务恢复;你的已锁定指令不会丢失。 -
- )} - - {connectionStatus === 'disconnected' && ( -
- 连接已中断,正在恢复战局;恢复前不能提交新指令。 -
- )} - -
-
-
-
-
- {!view && ( -

- 正在建立战斗服务连接… -

- )} - - {inspectedUnit && ( - - )} -
+
+
+
+
+
+ {!view && ( +

+ 正在建立战斗服务连接… +

+ )}
+ + setDebugOpen((current) => !current)} + /> + + {statusNotice && ( + + {statusNotice.message} + + )} + + {inspectedUnit && view && ( + setInspectedUnitId(null)} + /> + )} + {debugOpen && (