480 lines
12 KiB
TypeScript
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];
|
|
}
|