mirror of
https://github.com/sndrec/WebMonkeyBall.git
synced 2026-02-03 02:03:33 +00:00
determinism work
This commit is contained in:
111
src/camera.ts
111
src/camera.ts
@@ -1,5 +1,5 @@
|
||||
import { mat3, vec3 } from 'gl-matrix';
|
||||
import { MatrixStack, atan2S16, clamp, sqrt, sumSq2, sumSq3, rsqrt, toS16 } from './math.js';
|
||||
import { MatrixStack, atan2S16, atan2S16Detail, atan2S16Safe, clamp, sqrt, sumSq2, sumSq3, rsqrt, toS16 } from './math.js';
|
||||
import { smoothstep } from './animation.js';
|
||||
import { BALL_FLAGS, CAMERA_STATE, COLI_FLAGS } from './constants.js';
|
||||
|
||||
@@ -49,7 +49,14 @@ function applyMat3ToVec(out, mtx) {
|
||||
|
||||
function cameraFaceDirection(camera, lookDir) {
|
||||
camera.rotY = atan2S16(lookDir.x, lookDir.z) - 0x8000;
|
||||
camera.rotX = atan2S16(lookDir.y, sqrt(sumSq2(lookDir.x, lookDir.z)));
|
||||
const debug = (globalThis as any).__DETERMINISM_DEBUG__;
|
||||
if (debug) {
|
||||
debug.source = 'cameraRotX';
|
||||
}
|
||||
camera.rotX = atan2S16Safe(lookDir.y, sqrt(sumSq2(lookDir.x, lookDir.z)));
|
||||
if (debug) {
|
||||
debug.source = null;
|
||||
}
|
||||
camera.rotZ = 0;
|
||||
}
|
||||
|
||||
@@ -208,6 +215,7 @@ export class GameplayCamera {
|
||||
}
|
||||
|
||||
initReady(stageRuntime, startRotY, startPos, flyInFrames = 90) {
|
||||
const debug = (globalThis as any).__DETERMINISM_DEBUG__;
|
||||
if (stageRuntime?.stage?.format === 'smb2') {
|
||||
this.initReadySmb2(stageRuntime, startRotY, startPos, flyInFrames);
|
||||
return;
|
||||
@@ -232,7 +240,13 @@ export class GameplayCamera {
|
||||
tmpVec.y = this.unk54.y - tmpVec.y;
|
||||
tmpVec.z = this.unk54.z - tmpVec.z;
|
||||
this.unk6C = toS16(atan2S16(tmpVec.x, tmpVec.z) - 0x8000);
|
||||
this.unk68 = atan2S16(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = 'cameraUnk68';
|
||||
}
|
||||
this.unk68 = atan2S16Safe(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = null;
|
||||
}
|
||||
this.unk70 = 0;
|
||||
|
||||
this.unk74.x = startPos.x;
|
||||
@@ -248,7 +262,23 @@ export class GameplayCamera {
|
||||
tmpVec.y = this.unk74.y - tmpVec.y;
|
||||
tmpVec.z = this.unk74.z - tmpVec.z;
|
||||
this.unk8C = toS16(atan2S16(tmpVec.x, tmpVec.z) - 0x8000) + 0x10000;
|
||||
this.unk88 = atan2S16(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = 'cameraUnk88';
|
||||
}
|
||||
this.unk88 = atan2S16Safe(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = null;
|
||||
}
|
||||
if (debug?.cameraInit) {
|
||||
debug.cameraInit.push({
|
||||
mode: 'smb1',
|
||||
stageId: stageRuntime?.stage?.stageId ?? null,
|
||||
unk6C: this.unk6C,
|
||||
unk68: this.unk68,
|
||||
unk8C: this.unk8C,
|
||||
unk88: this.unk88,
|
||||
});
|
||||
}
|
||||
this.unk90 = 0;
|
||||
this.flags |= 1;
|
||||
this.timerCurr = flyInFrames;
|
||||
@@ -258,6 +288,7 @@ export class GameplayCamera {
|
||||
}
|
||||
|
||||
initReadySmb2(stageRuntime, startRotY, startPos, flyInFrames = 90) {
|
||||
const debug = (globalThis as any).__DETERMINISM_DEBUG__;
|
||||
this.reset();
|
||||
this.readyMode = 'smb2';
|
||||
const stageId = stageRuntime?.stage?.stageId ?? -1;
|
||||
@@ -288,7 +319,13 @@ export class GameplayCamera {
|
||||
tmpVec.y = this.unk54.y - tmpVec.y;
|
||||
tmpVec.z = this.unk54.z - tmpVec.z;
|
||||
this.unk6C = toS16(atan2S16(tmpVec.x, tmpVec.z) - 0x8000 + preset.yawOffset);
|
||||
this.unk68 = atan2S16(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = 'cameraUnk68';
|
||||
}
|
||||
this.unk68 = atan2S16Safe(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = null;
|
||||
}
|
||||
this.unk70 = 0;
|
||||
|
||||
const pivotYOffset = (stageId === 0x15a ? 0 : 0.18) + 0.8;
|
||||
@@ -302,6 +339,16 @@ export class GameplayCamera {
|
||||
this.timerCurr = flyInFrames;
|
||||
this.timerMax = flyInFrames;
|
||||
this.state = CAMERA_STATE.READY_MAIN;
|
||||
if (debug?.cameraInit) {
|
||||
debug.cameraInit.push({
|
||||
mode: 'smb2',
|
||||
stageId,
|
||||
unk6C: this.unk6C,
|
||||
unk68: this.unk68,
|
||||
unk8C: this.unk8C,
|
||||
unk88: this.unk88,
|
||||
});
|
||||
}
|
||||
this.updateReadyMain(false, false);
|
||||
}
|
||||
|
||||
@@ -338,6 +385,7 @@ export class GameplayCamera {
|
||||
}
|
||||
|
||||
updateLevelMain(ball, paused) {
|
||||
const debug = (globalThis as any).__DETERMINISM_DEBUG__;
|
||||
if (paused) {
|
||||
return;
|
||||
}
|
||||
@@ -373,7 +421,34 @@ export class GameplayCamera {
|
||||
|
||||
let pitch = 0;
|
||||
if (ball.unk80 >= 60) {
|
||||
pitch = atan2S16(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = 'cameraPitch';
|
||||
}
|
||||
pitch = atan2S16Safe(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = null;
|
||||
}
|
||||
}
|
||||
if (debug?.cameraPitch) {
|
||||
if (this._debugPrevPitch === undefined) {
|
||||
this._debugPrevPitch = pitch;
|
||||
} else {
|
||||
const delta = toS16(pitch - this._debugPrevPitch);
|
||||
if (Math.abs(delta) >= 128) {
|
||||
const denom = sqrt(sumSq2(tmpVec.x, tmpVec.z));
|
||||
const detail = atan2S16Detail(tmpVec.y, denom);
|
||||
debug.cameraPitch.push({
|
||||
tick: debug.tick ?? null,
|
||||
pitch,
|
||||
prev: this._debugPrevPitch,
|
||||
delta,
|
||||
tmpVec: { x: tmpVec.x, y: tmpVec.y, z: tmpVec.z },
|
||||
denom,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
this._debugPrevPitch = pitch;
|
||||
}
|
||||
}
|
||||
|
||||
let yaw = toS16(atan2S16(tmpVec.x, tmpVec.z) - 0x8000);
|
||||
@@ -431,6 +506,21 @@ export class GameplayCamera {
|
||||
this.unk10C = toS16(yaw - this.rotY);
|
||||
this.rotY = yaw;
|
||||
this.rotX = toS16(pitchSmoothed + 62208);
|
||||
if (debug?.cameraYaw) {
|
||||
if (this._debugYawCount === undefined) {
|
||||
this._debugYawCount = 0;
|
||||
}
|
||||
if (this._debugYawCount < 200) {
|
||||
debug.cameraYaw.push({
|
||||
tick: debug.tick ?? null,
|
||||
yaw,
|
||||
rotY: this.rotY,
|
||||
deltaYaw,
|
||||
tmpVec: { x: tmpVec.x, y: tmpVec.y, z: tmpVec.z },
|
||||
});
|
||||
this._debugYawCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
stack.fromTranslate(this.lookAt);
|
||||
stack.rotateY(this.rotY);
|
||||
@@ -450,6 +540,7 @@ export class GameplayCamera {
|
||||
}
|
||||
|
||||
updateLevelMainSmb2(ball, stageRuntime, paused) {
|
||||
const debug = (globalThis as any).__DETERMINISM_DEBUG__;
|
||||
if (paused) {
|
||||
return;
|
||||
}
|
||||
@@ -491,7 +582,13 @@ export class GameplayCamera {
|
||||
|
||||
let pitchRaw = 0;
|
||||
if (ball.unk80 >= 60) {
|
||||
pitchRaw = atan2S16(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = 'cameraPitchRaw';
|
||||
}
|
||||
pitchRaw = atan2S16Safe(tmpVec.y, sqrt(sumSq2(tmpVec.x, tmpVec.z)));
|
||||
if (debug) {
|
||||
debug.source = null;
|
||||
}
|
||||
}
|
||||
|
||||
let yaw = toS16(atan2S16(tmpVec.x, tmpVec.z) - 0x8000);
|
||||
|
||||
@@ -683,8 +683,24 @@ function collideBallWithTriFace(ball, tri) {
|
||||
function collideBallWithTriEdge(ball, ballPosTri, ballPrevPosTri, edge) {
|
||||
stack.push();
|
||||
stack.fromIdentity();
|
||||
const debug = (globalThis as any).__DETERMINISM_DEBUG__;
|
||||
if (debug?.edgeNormals) {
|
||||
const sum = Math.abs(edge.normal.x) + Math.abs(edge.normal.y);
|
||||
if (sum <= debug.edgeEps) {
|
||||
debug.edgeNormals.push({
|
||||
tick: debug.tick ?? null,
|
||||
normal: { x: edge.normal.x, y: edge.normal.y },
|
||||
edgeStart: { x: edge.start.x, y: edge.start.y },
|
||||
edgeEnd: { x: edge.end.x, y: edge.end.y },
|
||||
sum,
|
||||
});
|
||||
}
|
||||
}
|
||||
stack.translateXYZ(edge.start.x, edge.start.y, 0);
|
||||
stack.rotateZ(-atan2S16(edge.normal.x, edge.normal.y));
|
||||
const edgeNormalLenSq = sumSq2(edge.normal.x, edge.normal.y);
|
||||
if (edgeNormalLenSq > FLT_EPSILON) {
|
||||
stack.rotateZ(-atan2S16(edge.normal.x, edge.normal.y));
|
||||
}
|
||||
|
||||
stack.rigidInvTfPoint(ballPrevPosTri, triEdgeLocalPrevPos);
|
||||
stack.rigidInvTfPoint(ballPosTri, triEdgeLocalPos);
|
||||
@@ -697,7 +713,10 @@ function collideBallWithTriEdge(ball, ballPosTri, ballPrevPosTri, edge) {
|
||||
triEdgePlaneVec.x = 0;
|
||||
triEdgePlaneVec.y = triEdgeLocalPos.y - triEdgeLocalPrevPos.y;
|
||||
triEdgePlaneVec.z = triEdgeLocalPos.z - triEdgeLocalPrevPos.z;
|
||||
stack.rotateX(-atan2S16(triEdgePlaneVec.y, triEdgePlaneVec.z) - 0x8000);
|
||||
const planeLenSq = sumSq2(triEdgePlaneVec.y, triEdgePlaneVec.z);
|
||||
if (planeLenSq > FLT_EPSILON) {
|
||||
stack.rotateX(-atan2S16(triEdgePlaneVec.y, triEdgePlaneVec.z) - 0x8000);
|
||||
}
|
||||
stack.rigidInvTfPoint(ballPosTri, triEdgeLocalPos);
|
||||
stack.rigidInvTfPoint(ballPrevPosTri, triEdgeLocalPrevPos);
|
||||
|
||||
|
||||
31
src/determinism.ts
Normal file
31
src/determinism.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type QuantizedStick = { x: number; y: number };
|
||||
|
||||
export function quantizeStickAxis(value: number): number {
|
||||
const clamped = Math.max(-1, Math.min(1, value));
|
||||
const quantized = Math.round(clamped * 127);
|
||||
if (quantized < -127) {
|
||||
return -127;
|
||||
}
|
||||
if (quantized > 127) {
|
||||
return 127;
|
||||
}
|
||||
return quantized;
|
||||
}
|
||||
|
||||
export function dequantizeStickAxis(value: number): number {
|
||||
return value / 127;
|
||||
}
|
||||
|
||||
export function quantizeStick(stick: { x: number; y: number }): QuantizedStick {
|
||||
return {
|
||||
x: quantizeStickAxis(stick.x),
|
||||
y: quantizeStickAxis(stick.y),
|
||||
};
|
||||
}
|
||||
|
||||
export function dequantizeStick(stick: QuantizedStick): { x: number; y: number } {
|
||||
return {
|
||||
x: dequantizeStickAxis(stick.x),
|
||||
y: dequantizeStickAxis(stick.y),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { raycastStageDown } from './collision.js';
|
||||
import { atan2S16, sumSq2, toS16, vecDot } from './math.js';
|
||||
import { atan2S16, sqrt, sumSq2, toS16, vecDot } from './math.js';
|
||||
|
||||
const SPARK_GRAVITY_SCALE = 0.008;
|
||||
const SPARK_DAMP = 0.992;
|
||||
@@ -27,8 +27,8 @@ const LEVITATE_DRIFT = 0.0012;
|
||||
const LEVITATE_DAMP = 0.98;
|
||||
const STAR_SCALE_TARGET = 0.015;
|
||||
|
||||
const randFloat = () => Math.random();
|
||||
const randS16 = () => Math.trunc(Math.random() * 0x8000);
|
||||
const randFloat = (rng) => rng.nextFloat();
|
||||
const randS16 = (rng) => rng.nextS16();
|
||||
|
||||
type Vec3 = { x: number; y: number; z: number };
|
||||
|
||||
@@ -156,7 +156,7 @@ function updateEffectGround(
|
||||
effect.glowPos.y = effect.pos.y - (dist - 0.02) * hitNormal.y;
|
||||
effect.glowPos.z = effect.pos.z - (dist - 0.02) * hitNormal.z;
|
||||
const rotY = atan2S16(hitNormal.x, hitNormal.z) - 0x8000;
|
||||
const rotX = atan2S16(hitNormal.y, Math.sqrt(sumSq2(hitNormal.x, hitNormal.z)));
|
||||
const rotX = atan2S16(hitNormal.y, sqrt(sumSq2(hitNormal.x, hitNormal.z)));
|
||||
effect.glowRotY = rotY;
|
||||
effect.glowRotX = rotX;
|
||||
}
|
||||
@@ -167,6 +167,7 @@ function applyEffectGroundResponse(
|
||||
bounceScale: number,
|
||||
surfaceBlend: number,
|
||||
randomizeRot = false,
|
||||
rng = null,
|
||||
): void {
|
||||
const normal = effect.groundNormal;
|
||||
const dx = effect.pos.x - effect.groundPos.x;
|
||||
@@ -193,10 +194,10 @@ function applyEffectGroundResponse(
|
||||
effect.vel.x += (effect.groundVel.x - effect.vel.x) * surfaceBlend;
|
||||
effect.vel.y += (effect.groundVel.y - effect.vel.y) * surfaceBlend;
|
||||
effect.vel.z += (effect.groundVel.z - effect.vel.z) * surfaceBlend;
|
||||
if (randomizeRot) {
|
||||
effect.rotVelX = toS16(effect.rotVelX + impulse * (randFloat() - 0.5) * STAR_ROT_KICK_XZ);
|
||||
effect.rotVelY = toS16(effect.rotVelY + impulse * (randFloat() - 0.5) * STAR_ROT_KICK_Y);
|
||||
effect.rotVelZ = toS16(effect.rotVelZ + impulse * (randFloat() - 0.5) * STAR_ROT_KICK_XZ);
|
||||
if (randomizeRot && rng) {
|
||||
effect.rotVelX = toS16(effect.rotVelX + impulse * (randFloat(rng) - 0.5) * STAR_ROT_KICK_XZ);
|
||||
effect.rotVelY = toS16(effect.rotVelY + impulse * (randFloat(rng) - 0.5) * STAR_ROT_KICK_Y);
|
||||
effect.rotVelZ = toS16(effect.rotVelZ + impulse * (randFloat(rng) - 0.5) * STAR_ROT_KICK_XZ);
|
||||
}
|
||||
effect.vel.x += impulse * bounceScale * normal.x;
|
||||
effect.vel.y += impulse * bounceScale * normal.y;
|
||||
@@ -252,12 +253,12 @@ function updateEffectGlow(
|
||||
effect.glowPos.y = effect.pos.y - (dist - 0.02) * hit.normal.y;
|
||||
effect.glowPos.z = effect.pos.z - (dist - 0.02) * hit.normal.z;
|
||||
const rotY = atan2S16(hit.normal.x, hit.normal.z) - 0x8000;
|
||||
const rotX = atan2S16(hit.normal.y, Math.sqrt(sumSq2(hit.normal.x, hit.normal.z)));
|
||||
const rotX = atan2S16(hit.normal.y, sqrt(sumSq2(hit.normal.x, hit.normal.z)));
|
||||
effect.glowRotY = rotY;
|
||||
effect.glowRotX = rotX;
|
||||
}
|
||||
|
||||
export function updateBallEffects(effects: BallEffect[], gravity: Vec3, stageRuntime: any): void {
|
||||
export function updateBallEffects(effects: BallEffect[], gravity: Vec3, stageRuntime: any, rng: any): void {
|
||||
for (let i = effects.length - 1; i >= 0; i -= 1) {
|
||||
const effect = effects[i];
|
||||
effect.prevPos.x = effect.pos.x;
|
||||
@@ -291,7 +292,7 @@ export function updateBallEffects(effects: BallEffect[], gravity: Vec3, stageRun
|
||||
const nx = -normal.x;
|
||||
const nz = -normal.z;
|
||||
const rotY = atan2S16(nx, nz);
|
||||
const rotX = toS16(atan2S16(normal.y, Math.sqrt(sumSq2(nx, nz))) - 0x8000);
|
||||
const rotX = toS16(atan2S16(normal.y, sqrt(sumSq2(nx, nz))) - 0x8000);
|
||||
effect.rotY = rotY;
|
||||
effect.rotX = rotX;
|
||||
if (effect.life < 8) {
|
||||
@@ -334,6 +335,7 @@ export function updateBallEffects(effects: BallEffect[], gravity: Vec3, stageRun
|
||||
1.0,
|
||||
STAR_GROUND_BLEND,
|
||||
true,
|
||||
rng,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -361,11 +363,11 @@ export function updateBallEffects(effects: BallEffect[], gravity: Vec3, stageRun
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnMovementSparks(effects: BallEffect[], ball: any, onGround: boolean): void {
|
||||
export function spawnMovementSparks(effects: BallEffect[], ball: any, onGround: boolean, rng: any): void {
|
||||
if (!onGround) {
|
||||
return;
|
||||
}
|
||||
const speed = Math.sqrt(ball.vel.x * ball.vel.x + ball.vel.y * ball.vel.y + ball.vel.z * ball.vel.z);
|
||||
const speed = sqrt(ball.vel.x * ball.vel.x + ball.vel.y * ball.vel.y + ball.vel.z * ball.vel.z);
|
||||
const intensity = speed * 5.0;
|
||||
if (intensity <= 1.5) {
|
||||
return;
|
||||
@@ -390,12 +392,12 @@ export function spawnMovementSparks(effects: BallEffect[], ball: any, onGround:
|
||||
'coli',
|
||||
basePos,
|
||||
baseVel,
|
||||
Math.trunc(SPARK_LIFE_MIN + SPARK_LIFE_RANGE * randFloat()),
|
||||
Math.trunc(SPARK_LIFE_MIN + SPARK_LIFE_RANGE * randFloat(rng)),
|
||||
);
|
||||
const spread = randFloat() * intensity * 0.1;
|
||||
spark.vel.x += (normal.x + (randFloat() * 1.5 - 0.75)) * spread;
|
||||
spark.vel.y += (normal.y + (randFloat() * 1.5 - 0.75)) * spread;
|
||||
spark.vel.z += (normal.z + (randFloat() * 1.5 - 0.75)) * spread;
|
||||
const spread = randFloat(rng) * intensity * 0.1;
|
||||
spark.vel.x += (normal.x + (randFloat(rng) * 1.5 - 0.75)) * spread;
|
||||
spark.vel.y += (normal.y + (randFloat(rng) * 1.5 - 0.75)) * spread;
|
||||
spark.vel.z += (normal.z + (randFloat(rng) * 1.5 - 0.75)) * spread;
|
||||
spark.scale = 1.0;
|
||||
spark.scaleTarget = 1.0;
|
||||
spark.colorR = 1.1;
|
||||
@@ -410,6 +412,7 @@ export function spawnCollisionStars(
|
||||
effects: BallEffect[],
|
||||
ball: any,
|
||||
hardestColiSpeed: number,
|
||||
rng: any,
|
||||
): void {
|
||||
const surfaceNormal = { x: ball.unk114.x, y: ball.unk114.y, z: ball.unk114.z };
|
||||
const normal = { x: -surfaceNormal.x, y: -surfaceNormal.y, z: -surfaceNormal.z };
|
||||
@@ -428,7 +431,7 @@ export function spawnCollisionStars(
|
||||
let count = Math.min(32, Math.trunc(rawCount));
|
||||
const scaleBoost = Math.abs(hardestColiSpeed / 0.33) + 1.0;
|
||||
|
||||
const flashScale = Math.sqrt(Math.abs(hardestColiSpeed * 10.0));
|
||||
const flashScale = sqrt(Math.abs(hardestColiSpeed * 10.0));
|
||||
const flash = createEffect('coliflash', basePos, { x: 0, y: 0, z: 0 }, 12);
|
||||
flash.scale = flashScale * 0.25;
|
||||
flash.scaleTarget = flashScale;
|
||||
@@ -443,22 +446,22 @@ export function spawnCollisionStars(
|
||||
'colistar',
|
||||
basePos,
|
||||
baseVel,
|
||||
Math.trunc(STAR_LIFE_MIN + STAR_LIFE_RANGE * randFloat()),
|
||||
Math.trunc(STAR_LIFE_MIN + STAR_LIFE_RANGE * randFloat(rng)),
|
||||
);
|
||||
const jitter = {
|
||||
x: scaleBoost * (randFloat() * 0.05 - 0.025),
|
||||
y: scaleBoost * (randFloat() * 0.05 - 0.025),
|
||||
z: scaleBoost * (randFloat() * 0.05 - 0.025),
|
||||
x: scaleBoost * (randFloat(rng) * 0.05 - 0.025),
|
||||
y: scaleBoost * (randFloat(rng) * 0.05 - 0.025),
|
||||
z: scaleBoost * (randFloat(rng) * 0.05 - 0.025),
|
||||
};
|
||||
const push = scaleBoost * (randFloat() * 0.055 + 0.015);
|
||||
const push = scaleBoost * (randFloat(rng) * 0.055 + 0.015);
|
||||
star.vel.x += jitter.x + push * normal.x;
|
||||
star.vel.y += jitter.y + push * normal.y;
|
||||
star.vel.z += jitter.z + push * normal.z;
|
||||
star.rotX = 0;
|
||||
star.rotY = randS16();
|
||||
star.rotZ = randS16();
|
||||
star.rotY = randS16(rng);
|
||||
star.rotZ = randS16(rng);
|
||||
star.rotVelX = 0;
|
||||
star.rotVelY = (randS16() & 0xfff) + 0x1000;
|
||||
star.rotVelY = (randS16(rng) & 0xfff) + 0x1000;
|
||||
star.rotVelZ = 0;
|
||||
star.scale = 0;
|
||||
star.scaleTarget = STAR_SCALE_TARGET;
|
||||
@@ -472,14 +475,14 @@ export function spawnCollisionStars(
|
||||
'coli',
|
||||
basePos,
|
||||
{ x: baseVel.x * 0.5, y: baseVel.y * 0.5, z: baseVel.z * 0.5 },
|
||||
Math.trunc(SPARK_LIFE_MIN + SPARK_LIFE_RANGE * randFloat()),
|
||||
Math.trunc(SPARK_LIFE_MIN + SPARK_LIFE_RANGE * randFloat(rng)),
|
||||
);
|
||||
const jitter = {
|
||||
x: scaleBoost * (randFloat() * 0.05 - 0.025),
|
||||
y: scaleBoost * (randFloat() * 0.05 - 0.025),
|
||||
z: scaleBoost * (randFloat() * 0.05 - 0.025),
|
||||
x: scaleBoost * (randFloat(rng) * 0.05 - 0.025),
|
||||
y: scaleBoost * (randFloat(rng) * 0.05 - 0.025),
|
||||
z: scaleBoost * (randFloat(rng) * 0.05 - 0.025),
|
||||
};
|
||||
const push = scaleBoost * (randFloat() * 0.05 + 0.06);
|
||||
const push = scaleBoost * (randFloat(rng) * 0.05 + 0.06);
|
||||
spark.vel.x += jitter.x + push * normal.x;
|
||||
spark.vel.y += jitter.y + push * normal.y;
|
||||
spark.vel.z += jitter.z + push * normal.z;
|
||||
@@ -491,28 +494,28 @@ export function spawnCollisionStars(
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnPostGoalSparkle(effects: BallEffect[], ball: any): void {
|
||||
export function spawnPostGoalSparkle(effects: BallEffect[], ball: any, rng: any): void {
|
||||
const sparkle = createEffect(
|
||||
'levitate',
|
||||
ball.pos,
|
||||
{
|
||||
x: (randFloat() - 0.5) * LEVITATE_DRIFT,
|
||||
y: LEVITATE_START_VEL + randFloat() * LEVITATE_START_VEL,
|
||||
z: (randFloat() - 0.5) * LEVITATE_DRIFT,
|
||||
x: (randFloat(rng) - 0.5) * LEVITATE_DRIFT,
|
||||
y: LEVITATE_START_VEL + randFloat(rng) * LEVITATE_START_VEL,
|
||||
z: (randFloat(rng) - 0.5) * LEVITATE_DRIFT,
|
||||
},
|
||||
Math.trunc(LEVITATE_LIFE_MIN + LEVITATE_LIFE_RANGE * randFloat()),
|
||||
Math.trunc(LEVITATE_LIFE_MIN + LEVITATE_LIFE_RANGE * randFloat(rng)),
|
||||
);
|
||||
sparkle.pos.x += (randFloat() - 0.5) * LEVITATE_OFFSET_RADIUS;
|
||||
sparkle.pos.y += (randFloat() - 0.5) * LEVITATE_OFFSET_RADIUS;
|
||||
sparkle.pos.z += (randFloat() - 0.5) * LEVITATE_OFFSET_RADIUS;
|
||||
sparkle.pos.x += (randFloat(rng) - 0.5) * LEVITATE_OFFSET_RADIUS;
|
||||
sparkle.pos.y += (randFloat(rng) - 0.5) * LEVITATE_OFFSET_RADIUS;
|
||||
sparkle.pos.z += (randFloat(rng) - 0.5) * LEVITATE_OFFSET_RADIUS;
|
||||
sparkle.prevPos.x = sparkle.pos.x;
|
||||
sparkle.prevPos.y = sparkle.pos.y;
|
||||
sparkle.prevPos.z = sparkle.pos.z;
|
||||
sparkle.baseY = sparkle.pos.y;
|
||||
sparkle.scale = LEVITATE_SCALE;
|
||||
sparkle.scaleTarget = sparkle.scale;
|
||||
sparkle.rotX = randS16();
|
||||
sparkle.rotY = randS16();
|
||||
sparkle.rotZ = randS16();
|
||||
sparkle.rotX = randS16(rng);
|
||||
sparkle.rotY = randS16(rng);
|
||||
sparkle.rotZ = randS16(rng);
|
||||
effects.push(sparkle);
|
||||
}
|
||||
|
||||
58
src/game.ts
58
src/game.ts
@@ -14,8 +14,9 @@ import {
|
||||
type GameSource,
|
||||
} from './constants.js';
|
||||
import { intersectsMovingSpheres, tfPhysballToAnimGroupSpace } from './collision.js';
|
||||
import { MatrixStack, toS16 } from './math.js';
|
||||
import { MatrixStack, sqrt, toS16 } from './math.js';
|
||||
import { GameplayCamera } from './camera.js';
|
||||
import { dequantizeStick, quantizeStick, type QuantizedStick } from './determinism.js';
|
||||
import {
|
||||
checkBallEnteredGoal,
|
||||
createBallState,
|
||||
@@ -95,7 +96,7 @@ function nlerpQuat(out: { x: number; y: number; z: number; w: number }, a: any,
|
||||
out.y = a.y + (by - a.y) * t;
|
||||
out.z = a.z + (bz - a.z) * t;
|
||||
out.w = a.w + (bw - a.w) * t;
|
||||
const len = Math.sqrt((out.x * out.x) + (out.y * out.y) + (out.z * out.z) + (out.w * out.w));
|
||||
const len = sqrt((out.x * out.x) + (out.y * out.y) + (out.z * out.z) + (out.w * out.w));
|
||||
if (len > 0) {
|
||||
out.x /= len;
|
||||
out.y /= len;
|
||||
@@ -331,6 +332,10 @@ export class Game {
|
||||
tickMs: number;
|
||||
lastTickMs: number;
|
||||
};
|
||||
public simTick: number;
|
||||
public inputFeed: QuantizedStick[] | null;
|
||||
public inputFeedIndex: number;
|
||||
public inputRecord: QuantizedStick[] | null;
|
||||
|
||||
constructor({
|
||||
hud,
|
||||
@@ -420,6 +425,10 @@ export class Game {
|
||||
tickMs: 0,
|
||||
lastTickMs: 0,
|
||||
};
|
||||
this.simTick = 0;
|
||||
this.inputFeed = null;
|
||||
this.inputFeedIndex = 0;
|
||||
this.inputRecord = null;
|
||||
}
|
||||
|
||||
setGameSource(source: GameSource) {
|
||||
@@ -428,6 +437,21 @@ export class Game {
|
||||
this.audio?.stopMusic();
|
||||
}
|
||||
|
||||
setInputFeed(feed: QuantizedStick[] | null) {
|
||||
this.inputFeed = feed;
|
||||
this.inputFeedIndex = 0;
|
||||
}
|
||||
|
||||
startInputRecording() {
|
||||
this.inputRecord = [];
|
||||
}
|
||||
|
||||
stopInputRecording() {
|
||||
const record = this.inputRecord;
|
||||
this.inputRecord = null;
|
||||
return record;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.input = new Input();
|
||||
this.world = new World();
|
||||
@@ -759,7 +783,7 @@ export class Game {
|
||||
renderPoint.normal.x = lerp(point.prevNormal?.x ?? point.normal.x, point.normal.x, alpha);
|
||||
renderPoint.normal.y = lerp(point.prevNormal?.y ?? point.normal.y, point.normal.y, alpha);
|
||||
renderPoint.normal.z = lerp(point.prevNormal?.z ?? point.normal.z, point.normal.z, alpha);
|
||||
const normalLen = Math.sqrt(
|
||||
const normalLen = sqrt(
|
||||
(renderPoint.normal.x * renderPoint.normal.x)
|
||||
+ (renderPoint.normal.y * renderPoint.normal.y)
|
||||
+ (renderPoint.normal.z * renderPoint.normal.z)
|
||||
@@ -1193,6 +1217,8 @@ export class Game {
|
||||
this.stage = stage;
|
||||
this.stageAttempts = isRestart ? this.stageAttempts + 1 : 1;
|
||||
this.stageRuntime = new StageRuntime(stage);
|
||||
this.simTick = 0;
|
||||
this.inputFeedIndex = 0;
|
||||
this.animGroupTransforms = this.stageRuntime.animGroups.map((group) => group.transform);
|
||||
this.interpolatedAnimGroupTransforms = null;
|
||||
this.bananaGroups = new Array(this.stage.animGroupCount);
|
||||
@@ -1716,6 +1742,25 @@ export class Game {
|
||||
}
|
||||
}
|
||||
|
||||
private readDeterministicStick(inputEnabled: boolean) {
|
||||
let frame = null;
|
||||
if (this.inputFeed && this.inputFeedIndex < this.inputFeed.length) {
|
||||
frame = this.inputFeed[this.inputFeedIndex];
|
||||
this.inputFeedIndex += 1;
|
||||
}
|
||||
if (!inputEnabled) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
if (!frame) {
|
||||
const raw = this.input?.getStick?.() ?? { x: 0, y: 0 };
|
||||
frame = quantizeStick(raw);
|
||||
if (this.inputRecord) {
|
||||
this.inputRecord.push({ x: frame.x, y: frame.y });
|
||||
}
|
||||
}
|
||||
return dequantizeStick(frame);
|
||||
}
|
||||
|
||||
update(dtSeconds: number) {
|
||||
if (!this.running) {
|
||||
return;
|
||||
@@ -1741,6 +1786,10 @@ export class Game {
|
||||
}
|
||||
this.stageRuntime.goalHoldOpen = this.goalTimerFrames > 0;
|
||||
while (!this.pendingAdvance && this.accumulator >= this.fixedStep) {
|
||||
const debug = (globalThis as any).__DETERMINISM_DEBUG__;
|
||||
if (debug) {
|
||||
debug.tick = this.simTick;
|
||||
}
|
||||
const tickStart = this.simPerf.enabled ? nowMs() : 0;
|
||||
try {
|
||||
const ringoutActive = this.ringoutTimerFrames > 0;
|
||||
@@ -1763,7 +1812,7 @@ export class Game {
|
||||
? this.introTimerFrames
|
||||
: null;
|
||||
this.stageRuntime.switchesEnabled = switchesEnabled;
|
||||
const stick = inputEnabled ? this.input?.getStick?.() ?? { x: 0, y: 0 } : { x: 0, y: 0 };
|
||||
const stick = this.readDeterministicStick(inputEnabled);
|
||||
this.world.updateInput(stick, this.cameraController.rotY);
|
||||
const stagePaused = this.paused || timeoverActive;
|
||||
this.stageRuntime.advance(
|
||||
@@ -1911,6 +1960,7 @@ export class Game {
|
||||
this.simPerf.tickMs += tickMs;
|
||||
this.simPerf.tickCount += 1;
|
||||
}
|
||||
this.simTick += 1;
|
||||
}
|
||||
}
|
||||
if (this.simPerf.enabled && this.simPerf.tickCount >= this.simPerf.logEvery) {
|
||||
|
||||
285
src/math.ts
285
src/math.ts
@@ -1,9 +1,31 @@
|
||||
const PI = Math.PI;
|
||||
const ANGLE_SCALE = 0x8000 / PI;
|
||||
const ANGLE_INV_SCALE = PI / 0x8000;
|
||||
import { ATAN_TABLE, SIN_TABLE } from './math_lut.js';
|
||||
|
||||
export const EPSILON = 1e-7;
|
||||
|
||||
const f32View = new Float32Array(1);
|
||||
const u32View = new Uint32Array(f32View.buffer);
|
||||
|
||||
function f32(value) {
|
||||
return Math.fround(value);
|
||||
}
|
||||
|
||||
function f32Bits(value) {
|
||||
f32View[0] = value;
|
||||
return u32View[0];
|
||||
}
|
||||
|
||||
function roundToNearestEven(value) {
|
||||
const base = Math.floor(value);
|
||||
const frac = value - base;
|
||||
if (frac > 0.5) {
|
||||
return base + 1;
|
||||
}
|
||||
if (frac < 0.5) {
|
||||
return base;
|
||||
}
|
||||
return (base & 1) === 0 ? base : base + 1;
|
||||
}
|
||||
|
||||
export function toS16(value) {
|
||||
const v = Math.trunc(value);
|
||||
const wrapped = ((v + 0x8000) & 0xffff) - 0x8000;
|
||||
@@ -11,15 +33,215 @@ export function toS16(value) {
|
||||
}
|
||||
|
||||
export function sinS16(angle) {
|
||||
return Math.sin(angle * ANGLE_INV_SCALE);
|
||||
const a = angle & 0xffff;
|
||||
let index = a & 0x3fff;
|
||||
if (a & 0x4000) {
|
||||
index = 0x4000 - index;
|
||||
}
|
||||
const result = SIN_TABLE[index];
|
||||
return (a & 0x8000) ? -result : result;
|
||||
}
|
||||
|
||||
export function cosS16(angle) {
|
||||
return Math.cos(angle * ANGLE_INV_SCALE);
|
||||
return sinS16((angle + 0x4000) & 0xffff);
|
||||
}
|
||||
|
||||
function atanIndex(x) {
|
||||
const bits = f32Bits(x);
|
||||
let r5 = bits & 0x7fffff;
|
||||
r5 |= 0x00800000;
|
||||
const exp = (bits >>> 23) & 0xff;
|
||||
if (exp <= 0x67) {
|
||||
return 0;
|
||||
}
|
||||
let r4 = (0x87 - exp) & 0xffffffff;
|
||||
if ((r4 & ~0x1f) === 0) {
|
||||
r4 |= 0x20;
|
||||
}
|
||||
const shift = r4 & 31;
|
||||
const shifted = r5 >>> shift;
|
||||
const offset = shifted & 0x7ff8;
|
||||
return offset >>> 3;
|
||||
}
|
||||
|
||||
function atanS16(value) {
|
||||
if (value === 0) {
|
||||
return 0;
|
||||
}
|
||||
let sign = 1;
|
||||
let x = value;
|
||||
if (x < 0) {
|
||||
sign = -1;
|
||||
x = -x;
|
||||
}
|
||||
if (x === 1) {
|
||||
return sign * 0x2000;
|
||||
}
|
||||
let invert = false;
|
||||
if (x > 1) {
|
||||
x = 1 / x;
|
||||
invert = true;
|
||||
}
|
||||
x = f32(x);
|
||||
const index = atanIndex(x);
|
||||
const slope = ATAN_TABLE[index * 2];
|
||||
const intercept = ATAN_TABLE[index * 2 + 1];
|
||||
const angle = f32(f32(x * slope) + intercept);
|
||||
let result = roundToNearestEven(angle);
|
||||
if (invert) {
|
||||
result = 0x4000 - result;
|
||||
}
|
||||
return result * sign;
|
||||
}
|
||||
|
||||
function atanS16WithDetail(value) {
|
||||
if (value === 0) {
|
||||
return { angle: 0, index: 0, raw: 0 };
|
||||
}
|
||||
let sign = 1;
|
||||
let x = value;
|
||||
if (x < 0) {
|
||||
sign = -1;
|
||||
x = -x;
|
||||
}
|
||||
if (x === 1) {
|
||||
return { angle: sign * 0x2000, index: 0, raw: 0x2000 };
|
||||
}
|
||||
let invert = false;
|
||||
if (x > 1) {
|
||||
x = 1 / x;
|
||||
invert = true;
|
||||
}
|
||||
x = f32(x);
|
||||
const index = atanIndex(x);
|
||||
const slope = ATAN_TABLE[index * 2];
|
||||
const intercept = ATAN_TABLE[index * 2 + 1];
|
||||
const angle = f32(f32(x * slope) + intercept);
|
||||
let result = roundToNearestEven(angle);
|
||||
if (invert) {
|
||||
result = 0x4000 - result;
|
||||
}
|
||||
return { angle: result * sign, index, raw: angle };
|
||||
}
|
||||
|
||||
export function atan2S16(y, x) {
|
||||
return toS16(Math.atan2(y, x) * ANGLE_SCALE);
|
||||
const yy = f32(y);
|
||||
const xx = f32(x);
|
||||
if (yy === 0 && xx === 0) {
|
||||
return 0;
|
||||
}
|
||||
const ay = Math.abs(yy);
|
||||
const ax = Math.abs(xx);
|
||||
let angle;
|
||||
let ratio = 0;
|
||||
let atanIndexUsed = -1;
|
||||
let atanRaw = 0;
|
||||
if (ay === ax) {
|
||||
angle = ay === 0 ? 0 : 0x2000;
|
||||
} else if (ay > ax) {
|
||||
ratio = ax / ay;
|
||||
const atanDetail = atanS16WithDetail(ratio);
|
||||
atanIndexUsed = atanDetail.index;
|
||||
atanRaw = atanDetail.raw;
|
||||
angle = atanDetail.angle;
|
||||
angle = 0x4000 - angle;
|
||||
} else {
|
||||
ratio = ay / ax;
|
||||
const atanDetail = atanS16WithDetail(ratio);
|
||||
atanIndexUsed = atanDetail.index;
|
||||
atanRaw = atanDetail.raw;
|
||||
angle = atanDetail.angle;
|
||||
}
|
||||
if (xx < 0) {
|
||||
if (yy >= 0) {
|
||||
angle = 0x8000 - angle;
|
||||
} else {
|
||||
angle = angle - 0x8000;
|
||||
}
|
||||
} else if (yy < 0) {
|
||||
angle = -angle;
|
||||
}
|
||||
const result = toS16(angle);
|
||||
const debug = (globalThis as any).__DETERMINISM_DEBUG__;
|
||||
if (debug?.atan2 && debug.remaining > 0) {
|
||||
const sum = Math.abs(yy) + Math.abs(xx);
|
||||
if (sum <= (debug.eps ?? 0)) {
|
||||
debug.remaining -= 1;
|
||||
if (debug.records) {
|
||||
const stack = debug.stack ? new Error().stack : null;
|
||||
debug.records.push({
|
||||
tick: debug.tick ?? null,
|
||||
source: debug.source ?? null,
|
||||
x: xx,
|
||||
y: yy,
|
||||
angle: result,
|
||||
sum,
|
||||
ratio,
|
||||
atanIndexUsed,
|
||||
atanRaw,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function atan2S16Safe(y, x, eps = EPSILON) {
|
||||
if (Math.abs(y) + Math.abs(x) <= eps) {
|
||||
return 0;
|
||||
}
|
||||
return atan2S16(y, x);
|
||||
}
|
||||
|
||||
export function atan2S16Detail(y, x) {
|
||||
const yy = f32(y);
|
||||
const xx = f32(x);
|
||||
if (yy === 0 && xx === 0) {
|
||||
return {
|
||||
angle: 0,
|
||||
ratio: 0,
|
||||
atanIndexUsed: 0,
|
||||
atanRaw: 0,
|
||||
};
|
||||
}
|
||||
const ay = Math.abs(yy);
|
||||
const ax = Math.abs(xx);
|
||||
let angle;
|
||||
let ratio = 0;
|
||||
let atanIndexUsed = -1;
|
||||
let atanRaw = 0;
|
||||
if (ay === ax) {
|
||||
angle = ay === 0 ? 0 : 0x2000;
|
||||
} else if (ay > ax) {
|
||||
ratio = ax / ay;
|
||||
const atanDetail = atanS16WithDetail(ratio);
|
||||
atanIndexUsed = atanDetail.index;
|
||||
atanRaw = atanDetail.raw;
|
||||
angle = 0x4000 - atanDetail.angle;
|
||||
} else {
|
||||
ratio = ay / ax;
|
||||
const atanDetail = atanS16WithDetail(ratio);
|
||||
atanIndexUsed = atanDetail.index;
|
||||
atanRaw = atanDetail.raw;
|
||||
angle = atanDetail.angle;
|
||||
}
|
||||
|
||||
if (xx < 0) {
|
||||
if (yy >= 0) {
|
||||
angle = 0x8000 - angle;
|
||||
} else {
|
||||
angle = angle - 0x8000;
|
||||
}
|
||||
} else if (yy < 0) {
|
||||
angle = -angle;
|
||||
}
|
||||
return {
|
||||
angle: toS16(angle),
|
||||
ratio,
|
||||
atanIndexUsed,
|
||||
atanRaw,
|
||||
};
|
||||
}
|
||||
|
||||
export function sumSq2(x, y) {
|
||||
@@ -31,11 +253,46 @@ export function sumSq3(x, y, z) {
|
||||
}
|
||||
|
||||
export function sqrt(value) {
|
||||
return Math.sqrt(value);
|
||||
const v = f32(value);
|
||||
if (Number.isNaN(v)) {
|
||||
return NaN;
|
||||
}
|
||||
if (v <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (!Number.isFinite(v)) {
|
||||
return v;
|
||||
}
|
||||
const inv = rsqrtSmb1(v);
|
||||
return f32(v * inv);
|
||||
}
|
||||
|
||||
export function rsqrt(value) {
|
||||
return 1 / Math.sqrt(value);
|
||||
const v = f32(value);
|
||||
if (Number.isNaN(v)) {
|
||||
return NaN;
|
||||
}
|
||||
if (v <= 0) {
|
||||
return Infinity;
|
||||
}
|
||||
if (!Number.isFinite(v)) {
|
||||
return 0;
|
||||
}
|
||||
return rsqrtSmb1(v);
|
||||
}
|
||||
|
||||
function rsqrtSmb1(value) {
|
||||
const v = f32(value);
|
||||
// Deterministic approximation using fixed Newton iterations, mirroring SMB1 flow.
|
||||
let x = f32(1 / Math.sqrt(v));
|
||||
const half = f32(0.5);
|
||||
const onePointFive = f32(1.5);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const xx = f32(x * x);
|
||||
const term = f32(onePointFive - f32(half * f32(v * xx)));
|
||||
x = f32(x * term);
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
export function floor(value) {
|
||||
@@ -56,7 +313,7 @@ export function vecDotNormalized(a, b) {
|
||||
}
|
||||
|
||||
export function vecLen(a) {
|
||||
return Math.sqrt(sumSq3(a.x, a.y, a.z));
|
||||
return sqrt(sumSq3(a.x, a.y, a.z));
|
||||
}
|
||||
|
||||
export function vecNormalizeLen(a) {
|
||||
@@ -96,7 +353,7 @@ export function vecCross(a, b, out) {
|
||||
}
|
||||
|
||||
export function vecDistance(a, b) {
|
||||
return Math.sqrt(sumSq3(a.x - b.x, a.y - b.y, a.z - b.z));
|
||||
return sqrt(sumSq3(a.x - b.x, a.y - b.y, a.z - b.z));
|
||||
}
|
||||
|
||||
export function quatFromAxisAngle(axis, angleS16, out) {
|
||||
@@ -491,7 +748,7 @@ export class MatrixStack {
|
||||
const m = this.mtxA;
|
||||
const trace = m[0] + m[5] + m[10];
|
||||
if (trace > 0) {
|
||||
const s = Math.sqrt(trace + 1.0) * 2;
|
||||
const s = sqrt(trace + 1.0) * 2;
|
||||
out.w = 0.25 * s;
|
||||
out.x = (m[9] - m[6]) / s;
|
||||
out.y = (m[2] - m[8]) / s;
|
||||
@@ -499,7 +756,7 @@ export class MatrixStack {
|
||||
return;
|
||||
}
|
||||
if (m[0] > m[5] && m[0] > m[10]) {
|
||||
const s = Math.sqrt(1.0 + m[0] - m[5] - m[10]) * 2;
|
||||
const s = sqrt(1.0 + m[0] - m[5] - m[10]) * 2;
|
||||
out.w = (m[9] - m[6]) / s;
|
||||
out.x = 0.25 * s;
|
||||
out.y = (m[1] + m[4]) / s;
|
||||
@@ -507,14 +764,14 @@ export class MatrixStack {
|
||||
return;
|
||||
}
|
||||
if (m[5] > m[10]) {
|
||||
const s = Math.sqrt(1.0 + m[5] - m[0] - m[10]) * 2;
|
||||
const s = sqrt(1.0 + m[5] - m[0] - m[10]) * 2;
|
||||
out.w = (m[2] - m[8]) / s;
|
||||
out.x = (m[1] + m[4]) / s;
|
||||
out.y = 0.25 * s;
|
||||
out.z = (m[6] + m[9]) / s;
|
||||
return;
|
||||
}
|
||||
const s = Math.sqrt(1.0 + m[10] - m[0] - m[5]) * 2;
|
||||
const s = sqrt(1.0 + m[10] - m[0] - m[5]) * 2;
|
||||
out.w = (m[4] - m[1]) / s;
|
||||
out.x = (m[2] + m[8]) / s;
|
||||
out.y = (m[6] + m[9]) / s;
|
||||
|
||||
6857
src/math_lut.ts
Normal file
6857
src/math_lut.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -786,12 +786,13 @@ export function stepBall(ball, stageRuntime, world) {
|
||||
|
||||
if (stageRuntime.effects) {
|
||||
const onGround = (physBall.flags & COLI_FLAGS.OCCURRED) !== 0 && physBall.hardestColiPlane.normal.y > 0;
|
||||
spawnMovementSparks(stageRuntime.effects, ball, onGround);
|
||||
const rng = stageRuntime.visualRng;
|
||||
spawnMovementSparks(stageRuntime.effects, ball, onGround, rng);
|
||||
if ((physBall.flags & COLI_FLAGS.OCCURRED) && physBall.hardestColiSpeed < -0.06) {
|
||||
spawnCollisionStars(stageRuntime.effects, ball, physBall.hardestColiSpeed);
|
||||
spawnCollisionStars(stageRuntime.effects, ball, physBall.hardestColiSpeed, rng);
|
||||
}
|
||||
if (ball.state === BALL_STATES.GOAL_MAIN && (ball.unk80 & 1)) {
|
||||
spawnPostGoalSparkle(stageRuntime.effects, ball);
|
||||
spawnPostGoalSparkle(stageRuntime.effects, ball, rng);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
src/rng.ts
Normal file
25
src/rng.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class DeterministicRng {
|
||||
constructor(seed = 0) {
|
||||
this.state = seed >>> 0;
|
||||
if (this.state === 0) {
|
||||
this.state = 0x6d2b79f5;
|
||||
}
|
||||
}
|
||||
|
||||
nextU32() {
|
||||
let x = this.state >>> 0;
|
||||
x ^= (x << 13) >>> 0;
|
||||
x ^= x >>> 17;
|
||||
x ^= (x << 5) >>> 0;
|
||||
this.state = x >>> 0;
|
||||
return this.state;
|
||||
}
|
||||
|
||||
nextFloat() {
|
||||
return (this.nextU32() >>> 8) * (1 / 0x01000000);
|
||||
}
|
||||
|
||||
nextS16() {
|
||||
return (this.nextU32() >>> 17) & 0x7fff;
|
||||
}
|
||||
}
|
||||
113
src/sim_hash.ts
Normal file
113
src/sim_hash.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
const f32View = new Float32Array(1);
|
||||
const u32View = new Uint32Array(f32View.buffer);
|
||||
|
||||
function hashU32(hash, value) {
|
||||
let h = hash ^ (value >>> 0);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
function hashF32(hash, value) {
|
||||
f32View[0] = value;
|
||||
return hashU32(hash, u32View[0]);
|
||||
}
|
||||
|
||||
function hashVec3(hash, vec) {
|
||||
let h = hashF32(hash, vec.x);
|
||||
h = hashF32(h, vec.y);
|
||||
h = hashF32(h, vec.z);
|
||||
return h;
|
||||
}
|
||||
|
||||
function hashS16(hash, value) {
|
||||
return hashU32(hash, value & 0xffff);
|
||||
}
|
||||
|
||||
export function hashSimState(ball, world, stageRuntime, { includeVisual = false } = {}) {
|
||||
let h = 0x811c9dc5;
|
||||
if (!ball || !world || !stageRuntime) {
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
h = hashVec3(h, ball.pos);
|
||||
h = hashVec3(h, ball.vel);
|
||||
h = hashS16(h, ball.rotX);
|
||||
h = hashS16(h, ball.rotY);
|
||||
h = hashS16(h, ball.rotZ);
|
||||
h = hashU32(h, ball.state | 0);
|
||||
h = hashU32(h, ball.flags | 0);
|
||||
h = hashF32(h, ball.currRadius ?? 0);
|
||||
h = hashF32(h, ball.speed ?? 0);
|
||||
h = hashU32(h, ball.animGroupId | 0);
|
||||
h = hashS16(h, ball.apeYaw ?? 0);
|
||||
if (ball.orientation) {
|
||||
h = hashF32(h, ball.orientation.x);
|
||||
h = hashF32(h, ball.orientation.y);
|
||||
h = hashF32(h, ball.orientation.z);
|
||||
h = hashF32(h, ball.orientation.w);
|
||||
}
|
||||
|
||||
h = hashS16(h, world.xrot ?? 0);
|
||||
h = hashS16(h, world.zrot ?? 0);
|
||||
if (world.gravity) {
|
||||
h = hashVec3(h, world.gravity);
|
||||
}
|
||||
|
||||
h = hashU32(h, stageRuntime.timerFrames | 0);
|
||||
if (stageRuntime.animGroups) {
|
||||
h = hashU32(h, stageRuntime.animGroups.length | 0);
|
||||
for (const group of stageRuntime.animGroups) {
|
||||
h = hashVec3(h, group.pos);
|
||||
h = hashVec3(h, group.rot);
|
||||
}
|
||||
}
|
||||
if (stageRuntime.goalBags) {
|
||||
h = hashU32(h, stageRuntime.goalBags.length | 0);
|
||||
for (const bag of stageRuntime.goalBags) {
|
||||
h = hashU32(h, bag.state | 0);
|
||||
h = hashF32(h, bag.openness ?? 0);
|
||||
h = hashVec3(h, bag.localPos);
|
||||
h = hashVec3(h, bag.localVel);
|
||||
h = hashS16(h, bag.rotX ?? 0);
|
||||
h = hashS16(h, bag.rotY ?? 0);
|
||||
h = hashS16(h, bag.rotZ ?? 0);
|
||||
}
|
||||
}
|
||||
if (stageRuntime.bananas) {
|
||||
h = hashU32(h, stageRuntime.bananas.length | 0);
|
||||
for (const banana of stageRuntime.bananas) {
|
||||
h = hashU32(h, banana.state | 0);
|
||||
h = hashVec3(h, banana.localPos);
|
||||
h = hashVec3(h, banana.vel ?? { x: 0, y: 0, z: 0 });
|
||||
h = hashS16(h, banana.rotX ?? 0);
|
||||
h = hashS16(h, banana.rotY ?? 0);
|
||||
h = hashS16(h, banana.rotZ ?? 0);
|
||||
h = hashF32(h, banana.scale ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeVisual) {
|
||||
if (stageRuntime.confetti) {
|
||||
h = hashU32(h, stageRuntime.confetti.length | 0);
|
||||
for (const frag of stageRuntime.confetti) {
|
||||
h = hashVec3(h, frag.pos);
|
||||
h = hashVec3(h, frag.vel);
|
||||
h = hashS16(h, frag.rotX ?? 0);
|
||||
h = hashS16(h, frag.rotY ?? 0);
|
||||
h = hashS16(h, frag.rotZ ?? 0);
|
||||
}
|
||||
}
|
||||
if (stageRuntime.effects) {
|
||||
h = hashU32(h, stageRuntime.effects.length | 0);
|
||||
for (const fx of stageRuntime.effects) {
|
||||
h = hashVec3(h, fx.pos);
|
||||
h = hashVec3(h, fx.vel);
|
||||
h = hashS16(h, fx.rotX ?? 0);
|
||||
h = hashS16(h, fx.rotY ?? 0);
|
||||
h = hashS16(h, fx.rotZ ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return h >>> 0;
|
||||
}
|
||||
11
src/sim_runner.ts
Normal file
11
src/sim_runner.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { hashSimState } from './sim_hash.js';
|
||||
|
||||
export function runDeterminismTest(game, tickCount, inputFeed = null, { includeVisual = false } = {}) {
|
||||
game.setInputFeed(inputFeed);
|
||||
const hashes = [];
|
||||
for (let i = 0; i < tickCount; i += 1) {
|
||||
game.update(game.fixedStep);
|
||||
hashes.push(hashSimState(game.ball, game.world, game.stageRuntime, { includeVisual }));
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
66
src/stage.ts
66
src/stage.ts
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
MatrixStack,
|
||||
atan2S16,
|
||||
atan2S16Safe,
|
||||
floor,
|
||||
sinS16,
|
||||
sqrt,
|
||||
@@ -33,6 +34,7 @@ import { raycastStageDown } from './collision.js';
|
||||
import { updateBallEffects } from './effects.js';
|
||||
import { parseGma } from './gma.js';
|
||||
import { parseAVTpl } from './tpl.js';
|
||||
import { DeterministicRng } from './rng.js';
|
||||
|
||||
const TEXT_DECODER = new TextDecoder('utf-8');
|
||||
const STAGE_START_POS_SIZE = 0x14;
|
||||
@@ -65,7 +67,7 @@ const ANIM_LOOP = 0;
|
||||
const ANIM_PLAY_ONCE = 1;
|
||||
const ANIM_SEESAW = 2;
|
||||
const SMB2_STAGE_LOADIN_FRAMES = 0x168;
|
||||
const JAMABAR_BOUND_RADIUS = Math.sqrt((1.75 * 1.75) + (0.5 * 0.5) + (0.5 * 0.5));
|
||||
const JAMABAR_BOUND_RADIUS = sqrt((1.75 * 1.75) + (0.5 * 0.5) + (0.5 * 0.5));
|
||||
const GOAL_BAG_LOCAL_START = { x: 0, y: -1, z: 0.1 };
|
||||
const CONFETTI_GRAVITY_SCALE = 0.004;
|
||||
const CONFETTI_VEL_DAMP = 0.95;
|
||||
@@ -118,12 +120,12 @@ const SWITCH_MODEL_SUFFIXES = [
|
||||
'BUTTON_FR',
|
||||
];
|
||||
|
||||
function randS16() {
|
||||
return Math.trunc(Math.random() * 0x8000);
|
||||
function randS16(rng) {
|
||||
return rng.nextS16();
|
||||
}
|
||||
|
||||
function randFloat() {
|
||||
return Math.random();
|
||||
function randFloat(rng) {
|
||||
return rng.nextFloat();
|
||||
}
|
||||
|
||||
function formatStageId(stageId) {
|
||||
@@ -1179,7 +1181,7 @@ class StageParserSmb2 extends StageParser {
|
||||
}
|
||||
|
||||
export class StageRuntime {
|
||||
constructor(stage) {
|
||||
constructor(stage, seed = stage.stageId ?? 0) {
|
||||
this.stage = stage;
|
||||
this.format = stage.format ?? 'smb1';
|
||||
this.timerFrames = 0;
|
||||
@@ -1208,6 +1210,8 @@ export class StageRuntime {
|
||||
this.matrixStack = new MatrixStack();
|
||||
this.goalHoldOpen = false;
|
||||
this.switchesEnabled = true;
|
||||
this.simRng = new DeterministicRng(seed);
|
||||
this.visualRng = new DeterministicRng((seed ^ 0x9e3779b9) >>> 0);
|
||||
this.initAnimGroups();
|
||||
this.initObjects();
|
||||
}
|
||||
@@ -1769,10 +1773,10 @@ export class StageRuntime {
|
||||
updateGoalTape(tape, animGroups, gravity, stack);
|
||||
}
|
||||
for (const bag of this.goalBags) {
|
||||
updateGoalBag(bag, animGroups, gravity, this.goalHoldOpen, stack);
|
||||
updateGoalBag(bag, animGroups, gravity, this.goalHoldOpen, stack, this.simRng);
|
||||
}
|
||||
updateConfetti(this, gravity);
|
||||
updateBallEffects(this.effects, gravity, this);
|
||||
updateBallEffects(this.effects, gravity, this, this.visualRng);
|
||||
for (let i = 0; i < animGroups.length; i += 1) {
|
||||
const bumperStates = this.bumpers[i];
|
||||
for (const bumper of bumperStates) {
|
||||
@@ -2025,7 +2029,7 @@ export class StageRuntime {
|
||||
bag.openFrame = this.timerFrames;
|
||||
}
|
||||
updateGoalBagTransform(bag, this.matrixStack);
|
||||
spawnGoalBagConfetti(this, bag, ball);
|
||||
spawnGoalBagConfetti(this, bag, ball, this.visualRng);
|
||||
}
|
||||
|
||||
breakGoalTape(goalId, ball) {
|
||||
@@ -2182,7 +2186,7 @@ export class StageRuntime {
|
||||
y: (max.y - min.y) * 0.5,
|
||||
z: (max.z - min.z) * 0.5,
|
||||
};
|
||||
this.boundSphere.radius = Math.sqrt((half.x * half.x) + (half.y * half.y) + (half.z * half.z));
|
||||
this.boundSphere.radius = sqrt((half.x * half.x) + (half.y * half.y) + (half.z * half.z));
|
||||
if (this.boundSphere.radius < FLY_IN_MIN_RADIUS) {
|
||||
this.boundSphere.radius = FLY_IN_MIN_RADIUS;
|
||||
}
|
||||
@@ -2357,7 +2361,7 @@ function updateGoalBagTransform(bag, stack) {
|
||||
stack.tfPoint(bag.modelOrigin, bag.position);
|
||||
}
|
||||
|
||||
function updateGoalBag(bag, animGroups, gravity, holdOpen, stack) {
|
||||
function updateGoalBag(bag, animGroups, gravity, holdOpen, stack, rng) {
|
||||
bag.prevOpenness = bag.openness;
|
||||
bag.prevRotX = bag.rotX;
|
||||
bag.prevRotY = bag.rotY;
|
||||
@@ -2369,7 +2373,7 @@ function updateGoalBag(bag, animGroups, gravity, holdOpen, stack) {
|
||||
case 3:
|
||||
bag.state = 4;
|
||||
bag.counter = -1;
|
||||
bag.unk8 = 0.05 + 0.1 * Math.random();
|
||||
bag.unk8 = 0.05 + 0.1 * randFloat(rng);
|
||||
// fall through
|
||||
case 4:
|
||||
if (bag.counter > 0) {
|
||||
@@ -2397,7 +2401,7 @@ function updateGoalBag(bag, animGroups, gravity, holdOpen, stack) {
|
||||
case 6:
|
||||
bag.state = 7;
|
||||
bag.counter = 60;
|
||||
bag.unk8 = 0.05 + 0.1 * Math.random();
|
||||
bag.unk8 = 0.05 + 0.1 * randFloat(rng);
|
||||
// fall through
|
||||
case 7:
|
||||
bag.counter -= 1;
|
||||
@@ -2519,8 +2523,18 @@ function updateGoalBag(bag, animGroups, gravity, holdOpen, stack) {
|
||||
stack.fromRotateY(-bag.rotY);
|
||||
stack.rotateX(0);
|
||||
stack.tfVec(rotVec, rotVec);
|
||||
bag.rotX = atan2S16(rotVec.z, rotVec.y) - 0x8000;
|
||||
bag.rotZ = atan2S16(rotVec.x, sqrt(sumSq2(rotVec.z, rotVec.y)));
|
||||
const debug = (globalThis as any).__DETERMINISM_DEBUG__;
|
||||
if (debug) {
|
||||
debug.source = 'goalBagRotX';
|
||||
}
|
||||
bag.rotX = atan2S16Safe(rotVec.z, rotVec.y) - 0x8000;
|
||||
if (debug) {
|
||||
debug.source = 'goalBagRotZ';
|
||||
}
|
||||
bag.rotZ = atan2S16Safe(rotVec.x, sqrt(sumSq2(rotVec.z, rotVec.y)));
|
||||
if (debug) {
|
||||
debug.source = null;
|
||||
}
|
||||
|
||||
updateGoalBagTransform(bag, stack);
|
||||
}
|
||||
@@ -2819,7 +2833,7 @@ function createConfettiParticle(modelIndex, pos, vel, rotX, rotY, rotZ, scale, l
|
||||
};
|
||||
}
|
||||
|
||||
function spawnGoalBagConfetti(stageRuntime, bag, ball) {
|
||||
function spawnGoalBagConfetti(stageRuntime, bag, ball, rng) {
|
||||
const animGroup = stageRuntime.animGroups[bag.animGroupId];
|
||||
const stack = stageRuntime.matrixStack;
|
||||
const vel = { x: ball.vel.x, y: ball.vel.y, z: ball.vel.z };
|
||||
@@ -2859,22 +2873,22 @@ function spawnGoalBagConfetti(stageRuntime, bag, ball) {
|
||||
const spawnPos = { x: 0, y: 0, z: 0 };
|
||||
const spawnCount = 160;
|
||||
for (let i = 0; i < spawnCount; i += 1) {
|
||||
localPos.z = 0.5 * (baseRadius * (1.0 + randFloat()));
|
||||
stack.rotateY(randS16());
|
||||
stack.rotateX(randS16());
|
||||
localPos.z = 0.5 * (baseRadius * (1.0 + randFloat(rng)));
|
||||
stack.rotateY(randS16(rng));
|
||||
stack.rotateX(randS16(rng));
|
||||
localPos.x = 0;
|
||||
localPos.y = 0;
|
||||
stack.tfPoint(localPos, spawnPos);
|
||||
|
||||
const rotX = randS16();
|
||||
const rotY = randS16();
|
||||
const rotZ = randS16();
|
||||
const scale = 0.5 + 0.5 * randFloat();
|
||||
const life = Math.trunc(CONFETTI_LIFE_BASE + CONFETTI_LIFE_RANGE * randFloat());
|
||||
const rotX = randS16(rng);
|
||||
const rotY = randS16(rng);
|
||||
const rotZ = randS16(rng);
|
||||
const scale = 0.5 + 0.5 * randFloat(rng);
|
||||
const life = Math.trunc(CONFETTI_LIFE_BASE + CONFETTI_LIFE_RANGE * randFloat(rng));
|
||||
const modelIndex = (modelIndexBase + (i % CONFETTI_MODEL_COUNT)) % CONFETTI_MODEL_COUNT;
|
||||
|
||||
const frag = createConfettiParticle(modelIndex, spawnPos, vel, rotX, rotY, rotZ, scale, life);
|
||||
frag.groundBias = 0.0001 * randFloat();
|
||||
frag.groundBias = 0.0001 * randFloat(rng);
|
||||
stageRuntime.confetti.push(frag);
|
||||
}
|
||||
}
|
||||
@@ -3089,7 +3103,7 @@ function computeGmaBoundSphere(gma, modelNames = null) {
|
||||
y: (max.y - min.y) * 0.5,
|
||||
z: (max.z - min.z) * 0.5,
|
||||
};
|
||||
const radius = Math.sqrt((half.x * half.x) + (half.y * half.y) + (half.z * half.z));
|
||||
const radius = sqrt((half.x * half.x) + (half.y * half.y) + (half.z * half.z));
|
||||
return { pos, radius: Math.max(radius, FLY_IN_MIN_RADIUS) };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user