Files
Genarrative/src/services/jump-hop/jumpHopRuntimeModel.ts

480 lines
12 KiB
TypeScript

import type {
JumpHopPath,
JumpHopPlatform,
JumpHopRunStatus,
JumpHopRuntimeRunSnapshotResponse,
JumpHopTileAsset,
JumpHopTileType,
} from '../../../packages/shared/src/contracts/jumpHop';
export type JumpHopVisiblePlatform = {
platform: JumpHopPlatform;
index: number;
screenX: number;
screenY: number;
sceneX: number;
sceneY: number;
sceneZ: number;
scale: number;
asset: JumpHopTileAsset | null;
};
export type JumpHopCharacterVisualPosition = {
screenX: number;
screenY: number;
sceneX: number;
sceneY: number;
sceneZ: number;
isMiss: boolean;
};
export type JumpHopCanvasSize = {
width: number;
height: number;
};
export type JumpHopPlatformVisualSize = {
width: number;
height: number;
};
export type JumpHopLandingAssistVisualPosition = {
screenX: number;
screenY: number;
targetPlatformIndex: number;
};
export type JumpHopBackendDragVector = {
dragVectorX: number;
dragVectorY: number;
};
const VISIBLE_PLATFORM_COUNT = 3;
const JUMP_HOP_STAGE_WORLD_SCALE = 4.2;
const JUMP_HOP_STAGE_FORWARD_SCALE = 3;
const JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y = [78, 50, 22] as const;
const JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER = 2;
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 16 * 0.96;
const tileToneByType: Record<JumpHopTileType, string> = {
accent: '#e0f2fe',
bonus: '#fef3c7',
finish: '#dcfce7',
normal: '#f8fafc',
start: '#e0f2fe',
target: '#fee2e2',
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function hashJumpHopString(value: string) {
let hash = 0x811c9dc5;
for (const character of value) {
hash ^= character.codePointAt(0) ?? 0;
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function selectJumpHopTileAsset(
tileAssets: JumpHopTileAsset[] | null | undefined,
seedText: string | null | undefined,
platformIndex: number,
platformId: string,
) {
const pool = (tileAssets ?? []).filter(Boolean);
if (pool.length === 0) {
return null;
}
const normalizedSeed = seedText?.trim() || 'jump-hop';
const signature = `${normalizedSeed}:${platformIndex}:${platformId}`;
const selectedIndex = hashJumpHopString(signature) % pool.length;
return pool[selectedIndex] ?? null;
}
export function buildJumpHopVisiblePlatforms(
path: JumpHopPath | null | undefined,
currentPlatformIndex: number,
tileAssets: JumpHopTileAsset[] | null | undefined,
) {
const platforms = path?.platforms ?? [];
const current = platforms[currentPlatformIndex] ?? platforms[0];
if (!current) {
return [];
}
const start = Math.max(0, currentPlatformIndex);
const end = Math.min(platforms.length, currentPlatformIndex + VISIBLE_PLATFORM_COUNT);
const visible = platforms.slice(start, end);
const worldScale = 0.96;
return visible.map((platform, offset): JumpHopVisiblePlatform => {
const index = start + offset;
const dx = platform.x - current.x;
const dy = platform.y - current.y;
const depth = index - currentPlatformIndex;
const asset = selectJumpHopTileAsset(
tileAssets,
path?.seed ?? null,
index,
platform.platformId,
);
const screenY =
depth <= 0
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[0]
: depth === 1
? JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[1]
: JUMP_HOP_VISIBLE_PLATFORM_SCREEN_Y[2];
const screenX = clamp(50 + dx * 16 * worldScale, 14, 86);
return {
platform,
index,
screenX,
screenY,
sceneX: dx * JUMP_HOP_STAGE_WORLD_SCALE,
sceneY: 0,
sceneZ: dy * JUMP_HOP_STAGE_FORWARD_SCALE,
scale: clamp(1.08 - Math.max(0, depth) * 0.12, 0.8, 1.1),
asset,
};
});
}
export function getJumpHopPlatformVisualSize(
platform: JumpHopPlatform,
scale: number,
): JumpHopPlatformVisualSize {
return {
width:
clamp(platform.width * 0.96, 58, 118) *
scale *
JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER,
height:
clamp(platform.height * 0.78, 48, 92) *
scale *
JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER,
};
}
function getJumpHopCurrentTargetPlatforms(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: JumpHopVisiblePlatform[],
) {
if (!run) {
return null;
}
const currentIndex = run.currentPlatformIndex;
const currentPlatform =
platforms.find((item) => item.index === currentIndex) ??
platforms[0] ??
null;
const targetPlatform =
platforms.find((item) => item.index === currentIndex + 1) ??
platforms[1] ??
null;
if (!currentPlatform || !targetPlatform) {
return null;
}
return {
currentPlatform,
targetPlatform,
};
}
function getJumpHopCanvasPosition(
platform: JumpHopVisiblePlatform,
stageSize: JumpHopCanvasSize,
) {
return {
x: (platform.screenX / 100) * stageSize.width,
y: (platform.screenY / 100) * stageSize.height,
};
}
function getJumpHopScreenWorldScales(
currentPlatform: JumpHopVisiblePlatform,
targetPlatform: JumpHopVisiblePlatform,
stageSize: JumpHopCanvasSize,
) {
const currentCanvasPosition = getJumpHopCanvasPosition(
currentPlatform,
stageSize,
);
const targetCanvasPosition = getJumpHopCanvasPosition(
targetPlatform,
stageSize,
);
const targetWorldDeltaX =
targetPlatform.platform.x - currentPlatform.platform.x;
const targetWorldDeltaY =
targetPlatform.platform.y - currentPlatform.platform.y;
const targetScreenDeltaX = targetCanvasPosition.x - currentCanvasPosition.x;
const targetScreenDeltaY = targetCanvasPosition.y - currentCanvasPosition.y;
const targetWorldDistance = Math.hypot(targetWorldDeltaX, targetWorldDeltaY);
const targetScreenDistance = Math.hypot(
targetScreenDeltaX,
targetScreenDeltaY,
);
const fallbackPixelsPerWorldUnit =
targetWorldDistance > 0.0001 && targetScreenDistance > 0.0001
? targetScreenDistance / targetWorldDistance
: stageSize.height * 0.18;
const xPixelsPerWorldUnit =
Math.abs(targetWorldDeltaX) > 0.0001 &&
Math.abs(targetScreenDeltaX) > 0.0001
? Math.abs(targetScreenDeltaX / targetWorldDeltaX)
: Math.max(stageSize.width * (JUMP_HOP_SCREEN_X_WORLD_PERCENT / 100), 1);
const yPixelsPerWorldUnit =
Math.abs(targetWorldDeltaY) > 0.0001 &&
Math.abs(targetScreenDeltaY) > 0.0001
? Math.abs(targetScreenDeltaY / targetWorldDeltaY)
: fallbackPixelsPerWorldUnit;
const signedXScreenPerWorld =
Math.abs(targetWorldDeltaX) > 0.0001 &&
Math.abs(targetScreenDeltaX) > 0.0001
? targetScreenDeltaX / targetWorldDeltaX
: xPixelsPerWorldUnit;
const signedYScreenPerWorld =
Math.abs(targetWorldDeltaY) > 0.0001 &&
Math.abs(targetScreenDeltaY) > 0.0001
? targetScreenDeltaY / targetWorldDeltaY
: -yPixelsPerWorldUnit;
return {
currentCanvasPosition,
targetPlatform,
xPixelsPerWorldUnit,
yPixelsPerWorldUnit: Math.max(yPixelsPerWorldUnit, 1),
signedXScreenPerWorld,
signedYScreenPerWorld,
};
}
export function getJumpHopBackendDragVector(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: JumpHopVisiblePlatform[],
stageSize: JumpHopCanvasSize,
dragVectorX: number,
dragVectorY: number,
): JumpHopBackendDragVector {
const pair = getJumpHopCurrentTargetPlatforms(run, platforms);
if (!pair || stageSize.width <= 0 || stageSize.height <= 0) {
return {
dragVectorX,
dragVectorY,
};
}
const scales = getJumpHopScreenWorldScales(
pair.currentPlatform,
pair.targetPlatform,
stageSize,
);
return {
dragVectorX: dragVectorX / scales.xPixelsPerWorldUnit,
dragVectorY: dragVectorY / scales.yPixelsPerWorldUnit,
};
}
export function getJumpHopLandingAssistVisualPosition(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: JumpHopVisiblePlatform[],
characterPosition: JumpHopCharacterVisualPosition | null,
stageSize: JumpHopCanvasSize,
dragDistance: number,
dragVectorX: number | null,
dragVectorY: number | null,
) {
if (
!run ||
run.status !== 'playing' ||
!characterPosition ||
stageSize.width <= 0 ||
stageSize.height <= 0 ||
dragDistance <= 0
) {
return null;
}
const pair = getJumpHopCurrentTargetPlatforms(run, platforms);
if (!pair) {
return null;
}
const { currentPlatform, targetPlatform } = pair;
const dragX = dragVectorX ?? 0;
const dragY = dragVectorY ?? 0;
const dragLength = Math.hypot(dragX, dragY);
if (dragLength < 0.0001) {
return null;
}
const scales = getJumpHopScreenWorldScales(
currentPlatform,
targetPlatform,
stageSize,
);
const backendDragVector = getJumpHopBackendDragVector(
run,
platforms,
stageSize,
dragX,
dragY,
);
const jumpWorldX = -backendDragVector.dragVectorX;
const jumpWorldY = backendDragVector.dragVectorY;
const jumpWorldLength = Math.hypot(jumpWorldX, jumpWorldY);
if (jumpWorldLength < 0.0001) {
return null;
}
const maxDragDistance =
run.path.scoring.maxChargeMs > 0 ? run.path.scoring.maxChargeMs : 180;
const chargeToDistanceRatio =
run.path.scoring.chargeToDistanceRatio > 0
? run.path.scoring.chargeToDistanceRatio
: 0.008;
const projectedWorldDistance =
clamp(dragDistance, 0, maxDragDistance) * chargeToDistanceRatio;
const landedWorldDeltaX =
(jumpWorldX / jumpWorldLength) * projectedWorldDistance;
const landedWorldDeltaY =
(jumpWorldY / jumpWorldLength) * projectedWorldDistance;
const landedPixelX =
scales.currentCanvasPosition.x +
landedWorldDeltaX * scales.signedXScreenPerWorld;
const landedPixelY =
scales.currentCanvasPosition.y +
landedWorldDeltaY * scales.signedYScreenPerWorld;
return {
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
screenY: clamp((landedPixelY / stageSize.height) * 100, 10, 92),
targetPlatformIndex: targetPlatform.index,
};
}
export function resolveJumpHopCharacterCanvasPosition(
characterPosition: JumpHopCharacterVisualPosition | null,
size: JumpHopCanvasSize,
) {
if (!characterPosition) {
return null;
}
return {
x: (characterPosition.screenX / 100) * size.width,
y: (characterPosition.screenY / 100) * size.height,
};
}
export function getJumpHopCharacterVisualPosition(
run: JumpHopRuntimeRunSnapshotResponse | null,
platforms: JumpHopVisiblePlatform[],
) {
if (!run) {
return null;
}
const landedPlatform = platforms.find(
(item) => item.index === run.currentPlatformIndex,
);
if (landedPlatform) {
return {
screenX: landedPlatform.screenX,
screenY: landedPlatform.screenY - 3,
sceneX: landedPlatform.sceneX,
sceneY: landedPlatform.sceneY + 0.84,
sceneZ: landedPlatform.sceneZ,
isMiss: false,
};
}
const lastJump = run.lastJump;
if (lastJump && run.status === 'failed') {
const targetPlatform = platforms.find(
(item) => item.index === lastJump.targetPlatformIndex,
);
if (targetPlatform) {
return {
screenX: targetPlatform.screenX + 8,
screenY: targetPlatform.screenY - 2,
sceneX: targetPlatform.sceneX + 0.7,
sceneY: targetPlatform.sceneY + 0.48,
sceneZ: targetPlatform.sceneZ - 0.4,
isMiss: true,
};
}
}
return null;
}
export function getJumpHopRunDurationMs(
run: JumpHopRuntimeRunSnapshotResponse | null,
nowMs: number,
) {
if (!run) {
return 0;
}
if (run.status === 'playing' && run.startedAtMs > 0) {
return Math.max(0, nowMs - run.startedAtMs);
}
return run.durationMs;
}
export function formatJumpHopDurationLabel(durationMs: number) {
const safeDuration = Math.max(0, Math.floor(durationMs));
const totalSeconds = Math.floor(safeDuration / 1000);
const minutes = Math.floor(totalSeconds / 60)
.toString()
.padStart(2, '0');
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`;
}
export function getJumpHopStatusLabel(
status: JumpHopRunStatus | undefined,
) {
if (status === 'cleared') {
return '结束';
}
if (status === 'failed') {
return '失败';
}
return '进行中';
}
export function getJumpHopJumpFeedbackLabel(
run: JumpHopRuntimeRunSnapshotResponse | null,
) {
const result = run?.lastJump?.result;
if (result === 'perfect') {
return '落地';
}
if (result === 'finish') {
return '落地';
}
if (result === 'hit') {
return '落地';
}
if (result === 'miss') {
return '落空';
}
return null;
}
export function getJumpHopTileTone(tileType: JumpHopTileType) {
return tileToneByType[tileType];
}