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

680 lines
18 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;
isOnTargetPlatform: boolean;
};
export type JumpHopBackendDragVector = {
dragVectorX: number;
dragVectorY: number;
};
const JUMP_HOP_DEFAULT_CHARGE_TO_DISTANCE_RATIO = 0.004;
const JUMP_HOP_DEFAULT_STAGE_SIZE: JumpHopCanvasSize = {
width: 320,
height: 568,
};
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 = [64, 47, 30] as const;
const JUMP_HOP_PLATFORM_VISUAL_SIZE_MULTIPLIER = 2;
const JUMP_HOP_SCREEN_X_WORLD_PERCENT = 11.2;
const JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO = 0.72;
const JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO = 0.52;
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 * JUMP_HOP_SCREEN_X_WORLD_PERCENT, 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 getJumpHopCharacterVisualPositionFromPlatform(
platform: JumpHopVisiblePlatform,
isMiss = false,
): JumpHopCharacterVisualPosition {
if (isMiss) {
return {
screenX: platform.screenX + 8,
screenY: platform.screenY - 2,
sceneX: platform.sceneX + 0.7,
sceneY: platform.sceneY + 0.48,
sceneZ: platform.sceneZ - 0.4,
isMiss: true,
};
}
return {
screenX: platform.screenX,
screenY: platform.screenY - 3,
sceneX: platform.sceneX,
sceneY: platform.sceneY + 0.84,
sceneZ: platform.sceneZ,
isMiss: false,
};
}
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 getJumpHopWorldLandingVisualPosition(
originPlatform: JumpHopVisiblePlatform | null | undefined,
scalePlatform: JumpHopVisiblePlatform | null | undefined,
stageSize: JumpHopCanvasSize,
landedX: number,
landedY: number,
isMiss = false,
): JumpHopCharacterVisualPosition | null {
if (
!originPlatform ||
!scalePlatform ||
stageSize.width <= 0 ||
stageSize.height <= 0 ||
!Number.isFinite(landedX) ||
!Number.isFinite(landedY)
) {
return null;
}
const scales = getJumpHopScreenWorldScales(
originPlatform,
scalePlatform,
stageSize,
);
const worldDeltaX = landedX - originPlatform.platform.x;
const worldDeltaY = landedY - originPlatform.platform.y;
const landedPixelX =
scales.currentCanvasPosition.x +
worldDeltaX * scales.signedXScreenPerWorld;
const landedPixelY =
scales.currentCanvasPosition.y +
worldDeltaY * scales.signedYScreenPerWorld;
const sceneDeltaX =
(landedX - originPlatform.platform.x) * JUMP_HOP_STAGE_WORLD_SCALE;
const sceneDeltaZ =
(landedY - originPlatform.platform.y) * JUMP_HOP_STAGE_FORWARD_SCALE;
return {
screenX: clamp((landedPixelX / stageSize.width) * 100, 6, 94),
screenY: clamp((landedPixelY / stageSize.height) * 100 - 3, 10, 92),
sceneX: originPlatform.sceneX + sceneDeltaX,
sceneY: originPlatform.sceneY + (isMiss ? 0.48 : 0.84),
sceneZ: originPlatform.sceneZ + sceneDeltaZ,
isMiss,
};
}
export function isJumpHopLandingInsidePlatformFootprint(
platform: JumpHopPlatform | null | undefined,
landedX: number,
landedY: number,
) {
if (
!platform ||
!Number.isFinite(landedX) ||
!Number.isFinite(landedY)
) {
return false;
}
const halfWidth = Math.max(
0,
platform.width * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_WIDTH_RATIO,
);
const halfHeight = Math.max(
0,
platform.height * 0.5 * JUMP_HOP_TOP_FACE_HITBOX_HEIGHT_RATIO,
);
return (
Math.abs(landedX - platform.x) <= halfWidth &&
Math.abs(landedY - platform.y) <= halfHeight
);
}
function getJumpHopSuccessfulLandingVisualPosition(
run: JumpHopRuntimeRunSnapshotResponse,
platforms: JumpHopVisiblePlatform[],
stageSize: JumpHopCanvasSize,
) {
const lastJump = run.lastJump;
if (!lastJump) {
return null;
}
const landedPlatform =
platforms.find((item) => item.index === run.currentPlatformIndex) ??
platforms.find((item) => item.index === lastJump.targetPlatformIndex) ??
null;
if (!landedPlatform) {
return null;
}
const previousPlatformIndex = Math.max(0, lastJump.targetPlatformIndex - 1);
const previousWindowPlatforms = buildJumpHopVisiblePlatforms(
run.path,
previousPlatformIndex,
[],
);
const previousPlatform =
previousWindowPlatforms.find(
(item) => item.index === previousPlatformIndex,
) ?? null;
const targetPlatformInPreviousWindow =
previousWindowPlatforms.find(
(item) => item.index === lastJump.targetPlatformIndex,
) ?? null;
const landingInPreviousWindow = getJumpHopWorldLandingVisualPosition(
previousPlatform,
targetPlatformInPreviousWindow,
stageSize,
lastJump.landedX,
lastJump.landedY,
false,
);
if (!landingInPreviousWindow || !targetPlatformInPreviousWindow) {
return null;
}
const targetCenterInPreviousWindow =
getJumpHopCharacterVisualPositionFromPlatform(
targetPlatformInPreviousWindow,
);
const landedPlatformCenter =
getJumpHopCharacterVisualPositionFromPlatform(landedPlatform);
const worldDeltaX = lastJump.landedX - landedPlatform.platform.x;
const worldDeltaY = lastJump.landedY - landedPlatform.platform.y;
return {
screenX: clamp(
landedPlatformCenter.screenX +
landingInPreviousWindow.screenX -
targetCenterInPreviousWindow.screenX,
6,
94,
),
screenY: clamp(
landedPlatformCenter.screenY +
landingInPreviousWindow.screenY -
targetCenterInPreviousWindow.screenY,
10,
92,
),
sceneX: landedPlatform.sceneX + worldDeltaX * JUMP_HOP_STAGE_WORLD_SCALE,
sceneY: landedPlatform.sceneY + 0.84,
sceneZ: landedPlatform.sceneZ + worldDeltaY * JUMP_HOP_STAGE_FORWARD_SCALE,
isMiss: false,
};
}
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 scales = getJumpHopScreenWorldScales(
currentPlatform,
targetPlatform,
stageSize,
);
const jumpWorldX = targetPlatform.platform.x - currentPlatform.platform.x;
const jumpWorldY = targetPlatform.platform.y - currentPlatform.platform.y;
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
: JUMP_HOP_DEFAULT_CHARGE_TO_DISTANCE_RATIO;
const projectedWorldDistance =
clamp(dragDistance, 0, maxDragDistance) * chargeToDistanceRatio;
const landedWorldDeltaX =
(jumpWorldX / jumpWorldLength) * projectedWorldDistance;
const landedWorldDeltaY =
(jumpWorldY / jumpWorldLength) * projectedWorldDistance;
const landedWorldX = currentPlatform.platform.x + landedWorldDeltaX;
const landedWorldY = currentPlatform.platform.y + landedWorldDeltaY;
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 - 3, 10, 92),
targetPlatformIndex: targetPlatform.index,
isOnTargetPlatform: isJumpHopLandingInsidePlatformFootprint(
targetPlatform.platform,
landedWorldX,
landedWorldY,
),
};
}
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[],
stageSize: JumpHopCanvasSize = JUMP_HOP_DEFAULT_STAGE_SIZE,
) {
if (!run) {
return null;
}
const lastJump = run.lastJump;
if (lastJump) {
const isMiss = lastJump.result === 'miss';
if (!isMiss) {
const landedPosition = getJumpHopSuccessfulLandingVisualPosition(
run,
platforms,
stageSize,
);
if (landedPosition) {
return landedPosition;
}
}
const originPlatform =
platforms.find((item) => item.index === run.currentPlatformIndex) ??
platforms[0] ??
null;
const scalePlatform =
platforms.find((item) =>
isMiss
? item.index === lastJump.targetPlatformIndex
: item.index === run.currentPlatformIndex + 1,
) ??
platforms.find((item) => item.index === lastJump.targetPlatformIndex) ??
originPlatform;
const landedPosition = getJumpHopWorldLandingVisualPosition(
originPlatform,
scalePlatform,
stageSize,
lastJump.landedX,
lastJump.landedY,
isMiss,
);
if (landedPosition) {
return landedPosition;
}
}
const landedPlatform = platforms.find(
(item) => item.index === run.currentPlatformIndex,
);
if (landedPlatform) {
return getJumpHopCharacterVisualPositionFromPlatform(landedPlatform);
}
if (lastJump && run.status === 'failed') {
const targetPlatform = platforms.find(
(item) => item.index === lastJump.targetPlatformIndex,
);
if (targetPlatform) {
return getJumpHopCharacterVisualPositionFromPlatform(targetPlatform, 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];
}