Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
npm run format:check
npm run typecheck
npm run lint
npm run test:dependencies
npm run test:unit
npm run test:runtime
npm run test:budget
Expand Down
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"output/**",
"next-env.d.ts",
"public/basis/**",
"third_party/braces/**",
"public/engines/fairy-stockfish-nnue/**"
],
"rules": {
Expand Down
9 changes: 6 additions & 3 deletions app/BoardViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function BoardViewer({
presentation,
quality = "high",
reducedMotion = false,
sceneActive = true,
status = "9 × 10 线位 · 双九宫 · 本机双人",
viewSide,
onViewSideChange,
Expand All @@ -39,6 +40,7 @@ export function BoardViewer({
presentation: PresentationStore;
quality?: QualityTier;
reducedMotion?: boolean;
sceneActive?: boolean;
status?: string;
viewSide: BoardViewSide;
onViewSideChange: (side: BoardViewSide) => void;
Expand Down Expand Up @@ -97,6 +99,7 @@ export function BoardViewer({
onError={handleSceneError}
>
<Canvas
key={quality === "low" ? "no-msaa" : "msaa"}
camera={{ fov: 37, near: 0.1, far: 140, position: [10.2, 8.7, 13.4] }}
dpr={[qualityProfile.dpr[0], qualityProfile.dpr[1]]}
fallback={<p className="viewer-fallback">此设备无法启动三维预览。</p>}
Expand All @@ -119,10 +122,10 @@ export function BoardViewer({
}}
>
<BoardScene
ambientMotion={!reducedMotion}
ambientMotion={!reducedMotion && autoTour && sceneActive}
animations={animations}
audio={audio}
autoTour={autoTour}
autoTour={autoTour && sceneActive}
drawCallsRef={drawCallsRef}
onEnvironmentStatusChange={handleEnvironmentStatusChange}
pieceLayer={pieceLayer}
Expand Down Expand Up @@ -170,7 +173,7 @@ export function BoardViewer({
<button
aria-pressed={autoTour}
className="viewer-control"
disabled={view === "overhead" || reducedMotion}
disabled={view === "overhead" || reducedMotion || !sceneActive}
type="button"
onClick={() => setAutoTour((enabled) => !enabled)}
>
Expand Down
55 changes: 33 additions & 22 deletions components/xiangqi/XiangqiGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,22 @@ function shouldAutoFocusKeyboardBoard(): boolean {
return !window.matchMedia("(max-width: 680px) and (pointer: coarse)").matches;
}

function audioMix(settings: GameSettings) {
return {
ambient: settings.ambientVolume,
master: settings.masterVolume,
music: settings.musicVolume,
sfx: settings.sfxVolume,
ui: settings.uiVolume,
voice: settings.voiceVolume,
};
}

export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
const [animations] = useState(() => new AnimationRegistry());
const [animateDieMatchId, setAnimateDieMatchId] = useState<string | null>(null);
const [audio] = useState(() => new AudioEngine());
const [audioUnlocked, setAudioUnlocked] = useState(false);
const [semanticAudio] = useState(() => new SemanticAudioDirector(audio));
const [confirmation, setConfirmation] = useState<Confirmation>(null);
const [match, setMatch] = useState<SavedMatch>(() => createLocalMatch());
Expand Down Expand Up @@ -316,14 +328,7 @@ export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
}, [audio, presentation, semanticAudio]);

useEffect(() => {
audio.setMix({
ambient: settings.ambientVolume,
master: settings.masterVolume,
music: settings.musicVolume,
sfx: settings.sfxVolume,
ui: settings.uiVolume,
voice: settings.voiceVolume,
});
audio.setMix(audioMix(settings));
audio.setMuted(settings.muted);
}, [audio, settings]);

Expand Down Expand Up @@ -988,6 +993,7 @@ export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
const current = matchRef.current;
if (phase !== "playing" || current.config.mode !== "computer") {
activatedMatchId.current = null;
if (current.config.mode !== "computer") opponent.deactivate();
return;
}
if (activatedMatchId.current === current.config.matchId) return;
Expand Down Expand Up @@ -1102,26 +1108,26 @@ export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
const unlockAudio = useCallback(async () => {
try {
await audio.unlock();
if (mounted.current && audio.state === "running") setAudioUnlocked(true);
audio.play("ui.confirm");
return true;
} catch {
setNotice("音频系统不可用,棋局仍可正常进行。");
return false;
}
}, [audio]);

const handleStart = async () => {
await unlockAudio();
audio.play("ui.confirm");
const handleStart = () => {
void unlockAudio();
if (resumableMatch || unsafeSavePresent) {
setConfirmation({ kind: "new-game", target: { mode: "local" } });
return;
}
startLocalGame();
};

const handleRollComputer = async (difficulty: ComputerDifficulty) => {
await unlockAudio();
audio.play("ui.confirm");
const handleRollComputer = (difficulty: ComputerDifficulty) => {
void unlockAudio();
if (resumableMatch || unsafeSavePresent) {
setConfirmation({ kind: "new-game", target: { mode: "computer", difficulty } });
return;
Expand All @@ -1130,8 +1136,7 @@ export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
};

const handleStartOnline = async (role: "host" | "guest") => {
await unlockAudio();
audio.play("ui.confirm");
void unlockAudio();
if (!ONLINE_RUNTIME_CONFIG.enabled) return;
if (resumableMatch || unsafeSavePresent) {
setConfirmation({ kind: "new-game", target: { mode: "online", role } });
Expand All @@ -1140,15 +1145,13 @@ export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
await beginOnlinePairing(role, "new", null);
};

const handleConfirmComputer = async () => {
await unlockAudio();
audio.play("ui.confirm");
const handleConfirmComputer = () => {
void unlockAudio();
startPreparedComputerGame();
};

const handleContinue = async () => {
await unlockAudio();
audio.play("ui.confirm");
void unlockAudio();
if (!resumableMatch) return;
const resumableConfig = resumableMatch.config;
if (resumableConfig.mode === "online") {
Expand Down Expand Up @@ -1265,6 +1268,10 @@ export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
};

const handleSettingsChange = (nextSettings: GameSettings) => {
settingsRef.current = nextSettings;
audio.setMix(audioMix(nextSettings));
audio.setMuted(nextSettings.muted);
if (!audioUnlocked && !nextSettings.muted && nextSettings.masterVolume > 0) void unlockAudio();
setSettings(nextSettings);
const storage = storageRef.current;
if (!storage) {
Expand Down Expand Up @@ -1381,7 +1388,7 @@ export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
return (
<div
className="xiangqi-game-shell"
data-audio-state={audio.state === "locked" ? "locked" : settings.muted ? "muted" : "running"}
data-audio-state={settings.muted ? "muted" : audioUnlocked ? "running" : "locked"}
data-game-revision={game.revision}
data-match-mode={match.config.mode}
data-quality={settings.quality}
Expand All @@ -1408,6 +1415,7 @@ export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
<BoardViewer
animations={animations}
audio={audio}
sceneActive={phase === "playing" && !confirmation}
overlay={
phase === "menu" ? (
onlineSetup ? (
Expand Down Expand Up @@ -1503,6 +1511,9 @@ export function XiangqiGame({ onAction }: { onAction?: GameActionHandler }) {
void applyCommand({ type: "undo", expectedRevision: game.revision });
}}
onSettingsChange={handleSettingsChange}
onVolumePreview={(key, value) => {
audio.setMix(audioMix({ ...settingsRef.current, [key]: value }));
}}
onSkip={() => presentation.skip("user-skip")}
permissions={hudPermissions}
presentationBusy={commandBusy}
Expand Down
26 changes: 15 additions & 11 deletions components/xiangqi/ai/MasterEngineAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
OpponentRequestV1,
} from "../../../lib/xiangqi/ai/index";
import { validateOpponentRequestPosition } from "../../../lib/xiangqi/ai/index";
import { GameReplayValidator } from "../../../lib/xiangqi/persistence";
import {
loadVerifiedMasterAssets,
type MasterCacheStorageLike,
Expand Down Expand Up @@ -201,8 +202,6 @@ type ActiveSearch = {
score: number;
deadlineTimer: unknown;
graceTimer: unknown;
stopPromise: Promise<void> | null;
stopResolve: (() => void) | null;
};

const browserTimers: MasterEngineTimers = {
Expand Down Expand Up @@ -282,6 +281,7 @@ export class MasterEngineAdapter implements OpponentProvider {
readonly #stopGraceMs: number;
readonly #timers: MasterEngineTimers;
readonly #workerFactory: MasterHostWorkerFactory;
readonly #replayValidator = new GameReplayValidator();
#worker: MasterHostWorkerLike | null = null;
#initialization: Promise<void> | null = null;
#initialized = false;
Expand All @@ -290,6 +290,7 @@ export class MasterEngineAdapter implements OpponentProvider {
#lineWaiter: LineWaiter | null = null;
#activeSearch: ActiveSearch | null = null;
#startingRequest: OpponentRequestV1 | null = null;
#requestSettled: Promise<void> = Promise.resolve();
#cancelledStartingRequest: OpponentRequestV1 | null = null;
#currentMatchId: string | null = null;
#advertisesVariant = false;
Expand Down Expand Up @@ -327,8 +328,16 @@ export class MasterEngineAdapter implements OpponentProvider {
return failure("invalid-request", "Master received a non-Master request.");

this.#startingRequest = request;
let settleRequest!: () => void;
this.#requestSettled = new Promise<void>((resolve) => {
settleRequest = resolve;
});
try {
const validated = await validateOpponentRequestPosition(request, sha256);
const validated = await validateOpponentRequestPosition(
request,
sha256,
this.#replayValidator,
);
if (this.#disposed) return failure("cancelled", "The Master engine adapter was disposed.");
if (this.#cancelledStartingRequest && sameIdentity(this.#cancelledStartingRequest, request)) {
return failure("cancelled", "The Master search was stopped before it began.");
Expand Down Expand Up @@ -374,8 +383,6 @@ export class MasterEngineAdapter implements OpponentProvider {
score: 0,
deadlineTimer,
graceTimer: null,
stopPromise: null,
stopResolve: null,
};
this.command(`position fen ${fen}`);
this.command(`go depth ${request.depthCeiling} nodes ${request.nodeBudget}`);
Expand All @@ -388,22 +395,20 @@ export class MasterEngineAdapter implements OpponentProvider {
} finally {
if (this.#startingRequest === request) this.#startingRequest = null;
if (this.#cancelledStartingRequest === request) this.#cancelledStartingRequest = null;
settleRequest();
}
}

stop(identity: OpponentIdentityV1): Promise<void> {
if (this.#disposed) return Promise.resolve();
const active = this.#activeSearch;
if (active && sameIdentity(active.request, identity)) {
if (active.stopPromise) return active.stopPromise;
active.stopPromise = new Promise<void>((resolve) => {
active.stopResolve = resolve;
});
this.beginStop("cancelled");
return active.stopPromise;
return this.#requestSettled;
}
if (this.#startingRequest && sameIdentity(this.#startingRequest, identity)) {
this.#cancelledStartingRequest = this.#startingRequest;
return this.#requestSettled;
}
return Promise.resolve();
}
Expand Down Expand Up @@ -630,7 +635,6 @@ export class MasterEngineAdapter implements OpponentProvider {
this.#activeSearch = null;
this.#timers.clearTimeout(active.deadlineTimer);
if (active.graceTimer !== null) this.#timers.clearTimeout(active.graceTimer);
active.stopResolve?.();
active.resolve(outcome);
}

Expand Down
31 changes: 29 additions & 2 deletions components/xiangqi/ai/OpponentCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,16 @@ export class OpponentCoordinator {

async activateMatch(activation: OpponentMatchActivation): Promise<void> {
if (this.#disposed) return;
const canReuse =
activation.tier === "fairy-master" &&
this.#match?.tier === activation.tier &&
!this.#pendingStop &&
this.#phase !== "failed";
this.#pendingStop = null;
const oldProvider = this.#provider;
// This activation owns cleanup until it restores the provider. A newer
// activation must not stop or dispose the same instance a second time.
this.#provider = null;
const oldIdentity = this.#activeRequest;
this.invalidateSynchronously();
this.#match = { ...activation, requestedTier: activation.tier };
Expand All @@ -249,9 +257,14 @@ export class OpponentCoordinator {
this.emit();

if (oldProvider) {
await this.stopWithinGrace(oldProvider, oldIdentity);
const cooperative = await this.stopWithinGrace(oldProvider, oldIdentity);
if (canReuse && cooperative && this.isCurrent(generation, activation.matchId)) {
this.#provider = oldProvider;
this.#phase = this.restingPhase();
this.emit();
return;
}
oldProvider.dispose();
if (this.#provider === oldProvider) this.#provider = null;
}
if (!this.isCurrent(generation, activation.matchId)) return;
await this.bootProvider(generation, activation.tier);
Expand Down Expand Up @@ -421,6 +434,20 @@ export class OpponentCoordinator {
}
}

deactivate(): void {
if (this.#disposed) return;
const provider = this.#provider;
this.invalidateSynchronously();
this.#pendingStop = null;
this.#provider = null;
this.#match = null;
this.#failure = null;
this.#terminal = false;
this.#phase = "booting";
provider?.dispose();
this.emit();
}

dispose(): void {
if (this.#disposed) return;
this.#pendingStop = null;
Expand Down
11 changes: 8 additions & 3 deletions components/xiangqi/animation/AnimationDirector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useFrame, useThree } from "@react-three/fiber";
import { useEffect } from "react";
import { useEffect, useRef } from "react";

import { PresentationStore } from "../presentation/PresentationStore";
import { AnimationRegistry } from "./AnimationRegistry";
Expand All @@ -14,6 +14,7 @@ export function AnimationDirector({
presentation: PresentationStore;
}) {
const invalidate = useThree((state) => state.invalidate);
const previousActionId = useRef<string | null>(null);

useEffect(() => presentation.subscribe(() => invalidate()), [invalidate, presentation]);

Expand All @@ -32,9 +33,13 @@ export function AnimationDirector({

// This is the only per-render-frame animation callback for every piece mixer.
useFrame((_, deltaSeconds) => {
const actionId = presentation.getSnapshot().active?.transition.actionId ?? null;
// A demand-rendered scene's first delta includes the time spent asleep.
const delta = actionId !== previousActionId.current ? 0 : deltaSeconds;
previousActionId.current = actionId;
try {
presentation.tick(Math.min(2_000, Math.max(0, deltaSeconds * 1_000)));
animations.update(deltaSeconds);
presentation.tick(Math.min(2_000, Math.max(0, delta * 1_000)));
animations.update(delta);
} catch {
presentation.skip("presentation-error");
animations.clearUrgentAnimations();
Expand Down
Loading
Loading