feat(jump-hop): redesign sling platform gameplay
This commit is contained in:
479
src/services/jump-hop/jumpHopRuntimeModel.ts
Normal file
479
src/services/jump-hop/jumpHopRuntimeModel.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
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];
|
||||
}
|
||||
Reference in New Issue
Block a user