Add generationStatus and match3d/runtime fixes

Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
This commit is contained in:
2026-05-16 22:59:02 +08:00
parent bb60ca91ef
commit a45e358e83
42 changed files with 3872 additions and 443 deletions

View File

@@ -45,6 +45,7 @@ test('creation agent action requests are not auto-retried by default', async ()
'执行失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 0 }),
timeoutMs: 1_000_000,
}),
);
});

View File

@@ -41,6 +41,7 @@ const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
const DEFAULT_CREATION_AGENT_ACTION_RETRY: ApiRetryOptions = {
maxRetries: 0,
};
const DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS = 1_000_000;
function buildJsonPostInit(payload: unknown): RequestInit {
return {
@@ -182,7 +183,8 @@ export function createCreationAgentClient<
messages.executeAction,
{
retry: executeActionRetry,
timeoutMs: executeActionTimeoutMs,
timeoutMs:
executeActionTimeoutMs ?? DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS,
},
);

View File

@@ -5,10 +5,16 @@ import type {
Match3DRunSnapshot,
Match3DTraySlot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import {
buildMatch3DTrayInsertionPlan,
compactMatch3DTraySlots,
syncMatch3DItemTraySlotIndexes,
} from './match3dTrayLayout';
const MATCH3D_TRAY_SLOT_COUNT = 7;
const MATCH3D_LOCAL_DURATION_MS = 600_000;
const MATCH3D_MAX_ITEM_TYPE_COUNT = 25;
const MATCH3D_ITEMS_PER_CLEAR = 3;
const MATCH3D_LOCAL_BASE_RADIUS = 0.072;
const MATCH3D_LOCAL_BOARD_CENTER = 0.5;
const MATCH3D_LOCAL_BOARD_RADIUS = 0.5;
@@ -373,10 +379,6 @@ function recomputeClickable(items: Match3DItemSnapshot[]) {
});
}
function findNextTrayIndex(traySlots: Match3DTraySlot[]) {
return traySlots.find((slot) => !slot.itemInstanceId)?.slotIndex ?? -1;
}
function countClearedItems(items: Match3DItemSnapshot[]) {
return items.filter((item) => item.state === 'Cleared').length;
}
@@ -410,27 +412,28 @@ function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot {
};
}
function settleMatchedTrayItems(run: Match3DRunSnapshot) {
const slotsByType = new Map<string, Match3DTraySlot[]>();
for (const slot of run.traySlots) {
if (!slot.itemTypeId || !slot.itemInstanceId) {
continue;
}
slotsByType.set(slot.itemTypeId, [
...(slotsByType.get(slot.itemTypeId) ?? []),
slot,
]);
}
const matchedSlots = [...slotsByType.values()].find(
(slots) => slots.length >= 3,
);
function settleMatchedTrayItems(
run: Match3DRunSnapshot,
itemTypeId: string,
) {
const matchedSlots = run.traySlots
.filter(
(slot) =>
slot.itemInstanceId && slot.itemTypeId && slot.itemTypeId === itemTypeId,
)
.slice(0, MATCH3D_ITEMS_PER_CLEAR);
if (!matchedSlots) {
return {
run,
clearedItemInstanceIds: [] as string[],
};
}
if (matchedSlots.length < MATCH3D_ITEMS_PER_CLEAR) {
return {
run,
clearedItemInstanceIds: [] as string[],
};
}
const clearedItemInstanceIds = matchedSlots
.slice(0, 3)
@@ -439,26 +442,36 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
Boolean(itemInstanceId),
);
const clearedSet = new Set(clearedItemInstanceIds);
const nextRun = {
...run,
traySlots: run.traySlots.map((slot) =>
const compactedTraySlots = compactMatch3DTraySlots(
run.traySlots.map((slot) =>
slot.itemInstanceId && clearedSet.has(slot.itemInstanceId)
? { slotIndex: slot.slotIndex }
: slot,
),
);
const nextRun = {
...run,
traySlots: compactedTraySlots,
items: run.items.map((item) =>
clearedSet.has(item.itemInstanceId)
? {
...item,
state: 'Cleared' as const,
clickable: false,
traySlotIndex: null,
}
: item,
),
};
return {
run: nextRun,
run: {
...nextRun,
items: syncMatch3DItemTraySlotIndexes(
nextRun.items,
nextRun.traySlots,
),
},
clearedItemInstanceIds,
};
}
@@ -509,31 +522,27 @@ export function buildLocalMatch3DOptimisticRun(
const targetItem = run.items.find(
(item) => item.itemInstanceId === itemInstanceId,
);
const nextTrayIndex = findNextTrayIndex(run.traySlots);
if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) {
if (!targetItem || targetItem.state !== 'InBoard') {
return run;
}
const insertion = buildMatch3DTrayInsertionPlan(run.traySlots, targetItem);
if (!insertion) {
return run;
}
const nextItems = run.items.map((item) =>
item.itemInstanceId === itemInstanceId
? {
...item,
state: 'Flying' as const,
clickable: false,
traySlotIndex: insertion.slotIndex,
}
: item,
);
return {
...run,
items: run.items.map((item) =>
item.itemInstanceId === itemInstanceId
? {
...item,
state: 'Flying' as const,
clickable: false,
}
: item,
),
traySlots: run.traySlots.map((slot) =>
slot.slotIndex === nextTrayIndex
? {
slotIndex: slot.slotIndex,
itemInstanceId: targetItem.itemInstanceId,
itemTypeId: targetItem.itemTypeId,
visualKey: targetItem.visualKey,
}
: slot,
),
items: syncMatch3DItemTraySlotIndexes(nextItems, insertion.traySlots),
traySlots: insertion.traySlots,
};
}
@@ -582,8 +591,8 @@ export async function confirmLocalMatch3DClick(
clearedItemInstanceIds: [],
};
}
const nextTrayIndex = findNextTrayIndex(run.traySlots);
if (nextTrayIndex < 0) {
const insertion = buildMatch3DTrayInsertionPlan(timedRun.traySlots, targetItem);
if (!insertion) {
const failedRun = {
...timedRun,
status: 'Failed' as const,
@@ -601,27 +610,22 @@ export async function confirmLocalMatch3DClick(
const movedRun: Match3DRunSnapshot = {
...timedRun,
snapshotVersion: run.snapshotVersion + 1,
items: timedRun.items.map((item) =>
item.itemInstanceId === targetItem.itemInstanceId
? {
...item,
state: 'InTray' as const,
clickable: false,
}
: item,
),
traySlots: timedRun.traySlots.map((slot) =>
slot.slotIndex === nextTrayIndex
? {
slotIndex: slot.slotIndex,
itemInstanceId: targetItem.itemInstanceId,
itemTypeId: targetItem.itemTypeId,
visualKey: targetItem.visualKey,
}
: slot,
items: syncMatch3DItemTraySlotIndexes(
timedRun.items.map((item) =>
item.itemInstanceId === targetItem.itemInstanceId
? {
...item,
state: 'InTray' as const,
clickable: false,
traySlotIndex: insertion.slotIndex,
}
: item,
),
insertion.traySlots,
),
traySlots: insertion.traySlots,
};
const settled = settleMatchedTrayItems(movedRun);
const settled = settleMatchedTrayItems(movedRun, targetItem.itemTypeId);
const nextRun = resolveRunStatus({
...settled.run,
items: recomputeClickable(settled.run.items),

View File

@@ -0,0 +1,167 @@
import { expect, test } from 'vitest';
import type {
Match3DItemSnapshot,
Match3DTraySlot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import { confirmLocalMatch3DClick } from './match3dLocalRuntime';
import {
buildMatch3DTrayInsertionPlan,
compactMatch3DTraySlots,
syncMatch3DItemTraySlotIndexes,
} from './match3dTrayLayout';
function slot(
slotIndex: number,
itemInstanceId?: string,
itemTypeId?: string,
): Match3DTraySlot {
return itemInstanceId && itemTypeId
? {
slotIndex,
itemInstanceId,
itemTypeId,
visualKey: itemTypeId,
}
: { slotIndex };
}
function item(
itemInstanceId: string,
itemTypeId: string,
traySlotIndex: number | null = null,
): Match3DItemSnapshot {
return {
itemInstanceId,
itemTypeId,
visualKey: itemTypeId,
x: 0.5,
y: 0.5,
radius: 0.08,
layer: 1,
state: traySlotIndex === null ? 'InBoard' : 'InTray',
clickable: traySlotIndex === null,
traySlotIndex,
};
}
test('抓大鹅托盘点击新物品会插入到同类后面并后移其它物品', () => {
const plan = buildMatch3DTrayInsertionPlan(
[
slot(0, 'apple-1', 'apple'),
slot(1, 'pear-1', 'pear'),
slot(2, 'apple-2', 'apple'),
slot(3, 'melon-1', 'melon'),
slot(4),
slot(5),
slot(6),
],
item('apple-3', 'apple'),
);
expect(plan?.slotIndex).toBe(3);
expect(plan?.traySlots.map((entry) => entry.itemInstanceId ?? null)).toEqual([
'apple-1',
'pear-1',
'apple-2',
'apple-3',
'melon-1',
null,
null,
]);
});
test('抓大鹅三消后托盘会向前补位并同步物品槽位索引', () => {
const traySlots = compactMatch3DTraySlots([
slot(0),
slot(1),
slot(2),
slot(3, 'melon-1', 'melon'),
slot(4, 'pear-1', 'pear'),
slot(5),
slot(6),
]);
const items = syncMatch3DItemTraySlotIndexes(
[
item('melon-1', 'melon', 3),
item('pear-1', 'pear', 4),
{ ...item('apple-1', 'apple', 0), state: 'Cleared' as const },
],
traySlots,
);
expect(traySlots.map((entry) => entry.itemInstanceId ?? null)).toEqual([
'melon-1',
'pear-1',
null,
null,
null,
null,
null,
]);
expect(items.find((entry) => entry.itemInstanceId === 'melon-1')?.traySlotIndex).toBe(
0,
);
expect(items.find((entry) => entry.itemInstanceId === 'pear-1')?.traySlotIndex).toBe(
1,
);
expect(items.find((entry) => entry.itemInstanceId === 'apple-1')?.traySlotIndex).toBeNull();
});
test('本地抓大鹅确认只清除本次点击类型的三连', async () => {
const run = {
runId: 'local-triple-run',
profileId: 'local-triple-profile',
status: 'Running' as const,
snapshotVersion: 1,
startedAtMs: Date.now(),
durationLimitMs: 600_000,
serverNowMs: Date.now(),
remainingMs: 600_000,
clearCount: 3,
totalItemCount: 7,
clearedItemCount: 0,
items: [
item('apple-1', 'apple', 0),
item('apple-2', 'apple', 1),
item('apple-3', 'apple', 2),
item('pear-1', 'pear', 3),
item('pear-2', 'pear', 4),
item('pear-3', 'pear', null),
item('melon-1', 'melon', 5),
],
traySlots: [
slot(0, 'apple-1', 'apple'),
slot(1, 'apple-2', 'apple'),
slot(2, 'apple-3', 'apple'),
slot(3, 'pear-1', 'pear'),
slot(4, 'pear-2', 'pear'),
slot(5, 'melon-1', 'melon'),
slot(6),
],
};
run.items[5]!.clickable = true;
run.items[5]!.state = 'InBoard';
const result = await confirmLocalMatch3DClick(run, {
runId: run.runId,
itemInstanceId: 'pear-3',
clientSnapshotVersion: run.snapshotVersion,
clientEventId: 'click-pear-3',
clickedAtMs: Date.now(),
});
expect(result.clearedItemInstanceIds).toEqual(['pear-1', 'pear-2', 'pear-3']);
expect(result.run.items.find((entry) => entry.itemInstanceId === 'apple-1')?.state).toBe(
'InTray',
);
expect(result.run.traySlots.map((entry) => entry.itemInstanceId ?? null)).toEqual([
'apple-1',
'apple-2',
'apple-3',
'melon-1',
null,
null,
null,
]);
});

View File

@@ -0,0 +1,134 @@
import type {
Match3DItemSnapshot,
Match3DTraySlot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
type Match3DTrayOccupant = {
itemInstanceId: string;
itemTypeId: string;
visualKey: string;
};
export type Match3DTrayInsertionPlan = {
slotIndex: number;
traySlots: Match3DTraySlot[];
};
function resolveMatch3DTraySlotOrder(traySlots: Match3DTraySlot[]) {
return [...traySlots].sort((left, right) => left.slotIndex - right.slotIndex);
}
function resolveMatch3DTrayOccupants(traySlots: Match3DTraySlot[]) {
return resolveMatch3DTraySlotOrder(traySlots).flatMap((slot) =>
slot.itemInstanceId && slot.itemTypeId && slot.visualKey
? [
{
itemInstanceId: slot.itemInstanceId,
itemTypeId: slot.itemTypeId,
visualKey: slot.visualKey,
},
]
: [],
);
}
function rebuildMatch3DTraySlots(
traySlots: Match3DTraySlot[],
occupants: Match3DTrayOccupant[],
) {
return resolveMatch3DTraySlotOrder(traySlots).map((slot, index) => {
const occupant = occupants[index];
return occupant
? {
slotIndex: slot.slotIndex,
itemInstanceId: occupant.itemInstanceId,
itemTypeId: occupant.itemTypeId,
visualKey: occupant.visualKey,
}
: { slotIndex: slot.slotIndex };
});
}
export function buildMatch3DTrayInsertionPlan(
traySlots: Match3DTraySlot[],
item: Pick<
Match3DItemSnapshot,
'itemInstanceId' | 'itemTypeId' | 'visualKey'
>,
): Match3DTrayInsertionPlan | null {
const orderedSlots = resolveMatch3DTraySlotOrder(traySlots);
const occupants = resolveMatch3DTrayOccupants(orderedSlots);
if (occupants.length >= orderedSlots.length) {
return null;
}
let lastSameTypeIndex = -1;
for (let index = occupants.length - 1; index >= 0; index -= 1) {
if (occupants[index]?.itemTypeId === item.itemTypeId) {
lastSameTypeIndex = index;
break;
}
}
const insertionIndex =
lastSameTypeIndex >= 0 ? lastSameTypeIndex + 1 : occupants.length;
occupants.splice(insertionIndex, 0, {
itemInstanceId: item.itemInstanceId,
itemTypeId: item.itemTypeId,
visualKey: item.visualKey,
});
return {
slotIndex: orderedSlots[insertionIndex]?.slotIndex ?? insertionIndex,
traySlots: rebuildMatch3DTraySlots(orderedSlots, occupants),
};
}
export function syncMatch3DItemTraySlotIndexes(
items: Match3DItemSnapshot[],
traySlots: Match3DTraySlot[],
) {
const slotByItemId = new Map(
traySlots.flatMap((slot) =>
slot.itemInstanceId
? [[slot.itemInstanceId, slot.slotIndex] as const]
: [],
),
);
return items.map((item) =>
item.state === 'InTray' && slotByItemId.has(item.itemInstanceId)
? {
...item,
traySlotIndex: slotByItemId.get(item.itemInstanceId),
}
: item.state === 'Cleared'
? {
...item,
traySlotIndex: null,
}
: item,
);
}
export function compactMatch3DTraySlots(traySlots: Match3DTraySlot[]) {
return rebuildMatch3DTraySlots(
resolveMatch3DTraySlotOrder(traySlots),
resolveMatch3DTrayOccupants(traySlots),
);
}
export function resolveMatch3DTrayItemIdToSlotIndexMap(
traySlots: Match3DTraySlot[],
) {
return new Map(
resolveMatch3DTraySlotOrder(traySlots).flatMap((slot) =>
slot.itemInstanceId ? [[slot.itemInstanceId, slot.slotIndex] as const] : [],
),
);
}
export function resolveMatch3DTraySlotRectIndexOrder(
traySlots: Match3DTraySlot[],
) {
return resolveMatch3DTraySlotOrder(traySlots).map((slot) => slot.slotIndex);
}

View File

@@ -99,6 +99,22 @@ describe('miniGameDraftGenerationProgress', () => {
);
});
test('finished draft generation keeps elapsed time pinned to completion time', () => {
const state: MiniGameDraftGenerationState = {
kind: 'puzzle',
phase: 'failed',
startedAtMs: 1_000,
finishedAtMs: 151_000,
completedAssetCount: 0,
totalAssetCount: 0,
error: 'VectorEngine 图片编辑请求超时',
};
const progress = buildMiniGameDraftGenerationProgress(state, 500_000);
expect(progress?.elapsedMs).toBe(150_000);
});
test('big fish draft generation exposes multiple draft steps', () => {
const state: MiniGameDraftGenerationState = {
kind: 'big-fish',

View File

@@ -59,6 +59,7 @@ export type MiniGameDraftGenerationState = {
kind: MiniGameDraftGenerationKind;
phase: MiniGameDraftGenerationPhase;
startedAtMs: number;
finishedAtMs?: number;
completedAssetCount: number;
totalAssetCount: number;
error: string | null;
@@ -445,7 +446,11 @@ export function buildMiniGameDraftGenerationProgress(
return null;
}
const elapsedMs = Math.max(0, nowMs - state.startedAtMs);
const effectiveNowMs =
typeof state.finishedAtMs === 'number' && Number.isFinite(state.finishedAtMs)
? state.finishedAtMs
: nowMs;
const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs);
const puzzleTimeline =
state.kind === 'puzzle' &&
state.phase !== 'failed' &&

View File

@@ -603,6 +603,53 @@ describe('puzzleLocalRuntime', () => {
);
});
test('本地试玩直达后续关卡时继承作品 UI 背景', () => {
const workWithLevels: PuzzleWorkSummary = {
...baseWork,
levels: [
{
levelId: 'puzzle-level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-1.png',
coverAssetId: null,
uiBackgroundImageSrc:
'/generated-puzzle-assets/session/ui/background.png',
uiBackgroundImageObjectKey:
'generated-puzzle-assets/session/ui/background.png',
backgroundMusic: null,
generationStatus: 'ready',
},
{
levelId: 'puzzle-level-2',
levelName: '第二关',
pictureDescription: '第二关画面',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/level-2.png',
coverAssetId: null,
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
backgroundMusic: null,
generationStatus: 'ready',
},
],
};
const run = startLocalPuzzleRun(workWithLevels, 'puzzle-level-2');
expect(run.currentLevel?.levelId).toBe('puzzle-level-2');
expect(run.currentLevel?.coverImageSrc).toBe('/level-2.png');
expect(run.currentLevel?.uiBackgroundImageSrc).toBe(
'/generated-puzzle-assets/session/ui/background.png',
);
expect(run.currentLevel?.uiBackgroundImageObjectKey).toBe(
'generated-puzzle-assets/session/ui/background.png',
);
});
test('暂停和冻结时间不会消耗本地倒计时', () => {
const run = startLocalPuzzleRun(baseWork);
const pausedRun = setLocalPuzzlePaused(

View File

@@ -12,7 +12,10 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { resolvePuzzleUiBackgroundSource } from './puzzleUiBackgroundSource';
import {
resolvePuzzleUiBackgroundFields,
resolvePuzzleUiBackgroundSource,
} from './puzzleUiBackgroundSource';
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
@@ -761,6 +764,15 @@ function resolveNextSameWorkLevel(
return levels[nextLevelIndex] ?? null;
}
function resolvePuzzleWorkUiBackgroundCarrier(
work: PuzzleWorkSummary | null | undefined,
) {
return (
work?.levels?.find((level) => resolvePuzzleUiBackgroundSource(level)) ??
null
);
}
function applyLocalNextLevelHandoff(
run: PuzzleRunSnapshot,
work: PuzzleWorkSummary | null | undefined,
@@ -803,11 +815,11 @@ function buildFallbackLocalLevel(
buildLocalLevelName(currentLevel.levelName, nextLevelIndex);
const nextCoverImageSrc =
nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc;
const nextUiBackgroundImageSrc =
resolvePuzzleUiBackgroundSource(nextLevel) ?? currentLevel.uiBackgroundImageSrc;
const nextUiBackgroundImageObjectKey = resolvePuzzleUiBackgroundSource(nextLevel)
? nextLevel?.uiBackgroundImageObjectKey?.trim() || null
: currentLevel.uiBackgroundImageObjectKey ?? null;
const nextUiBackground = resolvePuzzleUiBackgroundFields(
nextLevel,
resolvePuzzleWorkUiBackgroundCarrier(work),
currentLevel,
);
const nextBackgroundMusic =
nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic;
@@ -838,8 +850,8 @@ function buildFallbackLocalLevel(
clearedAtMs: null,
elapsedMs: null,
coverImageSrc: nextCoverImageSrc,
uiBackgroundImageSrc: nextUiBackgroundImageSrc,
uiBackgroundImageObjectKey: nextUiBackgroundImageObjectKey,
uiBackgroundImageSrc: nextUiBackground.uiBackgroundImageSrc,
uiBackgroundImageObjectKey: nextUiBackground.uiBackgroundImageObjectKey,
backgroundMusic: nextBackgroundMusic,
...buildLevelTimerFields(nextLevelIndex),
leaderboardEntries: [],
@@ -865,9 +877,10 @@ export function startLocalPuzzleRun(
const firstLevel = item.levels?.[currentLevelIndex] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
const firstUiBackgroundImageSrc = resolvePuzzleUiBackgroundSource(firstLevel);
const firstUiBackgroundImageObjectKey =
firstLevel?.uiBackgroundImageObjectKey?.trim() || null;
const firstUiBackground = resolvePuzzleUiBackgroundFields(
firstLevel,
resolvePuzzleWorkUiBackgroundCarrier(item),
);
const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null;
const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null;
return {
@@ -888,8 +901,8 @@ export function startLocalPuzzleRun(
authorDisplayName: item.authorDisplayName,
themeTags: item.themeTags,
coverImageSrc: firstCoverImageSrc,
uiBackgroundImageSrc: firstUiBackgroundImageSrc,
uiBackgroundImageObjectKey: firstUiBackgroundImageObjectKey,
uiBackgroundImageSrc: firstUiBackground.uiBackgroundImageSrc,
uiBackgroundImageObjectKey: firstUiBackground.uiBackgroundImageObjectKey,
backgroundMusic: firstBackgroundMusic,
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
status: 'playing',

View File

@@ -6,15 +6,27 @@ type PuzzleUiBackgroundFields = {
export function resolvePuzzleUiBackgroundSource(
level: PuzzleUiBackgroundFields | null | undefined,
) {
const imageSrc = level?.uiBackgroundImageSrc?.trim();
if (imageSrc) {
return imageSrc;
}
const objectKey = level?.uiBackgroundImageObjectKey?.trim().replace(/^\/+/u, '');
if (!objectKey) {
return null;
}
return `/${objectKey}`;
return resolvePuzzleUiBackgroundFields(level).uiBackgroundImageSrc;
}
export function resolvePuzzleUiBackgroundFields(
...sources: Array<PuzzleUiBackgroundFields | null | undefined>
) {
for (const source of sources) {
const imageSrc = source?.uiBackgroundImageSrc?.trim();
const objectKey = source?.uiBackgroundImageObjectKey
?.trim()
.replace(/^\/+/u, '');
if (imageSrc || objectKey) {
return {
uiBackgroundImageSrc: imageSrc || (objectKey ? `/${objectKey}` : null),
uiBackgroundImageObjectKey: objectKey || null,
};
}
}
return {
uiBackgroundImageSrc: null,
uiBackgroundImageObjectKey: null,
};
}

View File

@@ -1,6 +1,8 @@
export const DEFAULT_RUNTIME_CLICK_SOUND_SRC = '/audio/ui-click-soft.wav';
export const DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC =
'/audio/ui-level-clear.wav';
export const DEFAULT_RUNTIME_MERGE_SOUND_SRC =
DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC;
export const DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC =
'/audio/ui-countdown-warning.wav';
export const DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS = 5_000;
@@ -53,6 +55,10 @@ export function playRuntimeLevelClearSound(volume = 0.6) {
playRuntimeClickSound(DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC, volume);
}
export function playRuntimeMergeSound(volume = 0.6) {
playRuntimeClickSound(DEFAULT_RUNTIME_MERGE_SOUND_SRC, volume);
}
export function playRuntimeCountdownSound(volume = 0.6) {
playRuntimeClickSound(DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC, volume);
}