From 9a93cdb256fe20044964b828cdf03779f660d559 Mon Sep 17 00:00:00 2001 From: smcga Date: Wed, 8 Apr 2026 01:36:09 +0100 Subject: [PATCH 1/3] Improve platformer runner jump arc physics --- src/renderer/effects/platformerScroll.test.ts | 34 ++++++- src/renderer/effects/platformerScroll.ts | 90 ++++++++++++++++++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/src/renderer/effects/platformerScroll.test.ts b/src/renderer/effects/platformerScroll.test.ts index 325859b9..ec417ee4 100644 --- a/src/renderer/effects/platformerScroll.test.ts +++ b/src/renderer/effects/platformerScroll.test.ts @@ -7,7 +7,8 @@ import { PlatformerScrollEffect, platformAt, runnerJumpOffset, - supportTopY + supportTopY, + updateRunnerJumpState } from "./platformerScroll"; const createGradient = () => ({ addColorStop: vi.fn() }); @@ -87,6 +88,37 @@ describe("platformerScroll helpers", () => { expect(down).toBe(0); }); + it("updateRunnerJumpState performs a physical jump arc for platform rises", () => { + const launched = updateRunnerJumpState( + { airborne: false, velocityY: 0, y: 220 }, + 220, + 188, + 1 / 60, + 0.2 + ); + const rising = updateRunnerJumpState(launched, 220, 188, 1 / 60, 0.2); + const falling = updateRunnerJumpState({ ...rising, velocityY: 220 }, 220, 188, 1 / 10, 0.2); + + expect(launched.airborne).toBe(true); + expect(launched.velocityY).toBeLessThan(0); + expect(rising.y).toBeLessThan(220); + expect(falling.velocityY).toBeGreaterThan(rising.velocityY); + }); + + it("updateRunnerJumpState lands and snaps cleanly to support", () => { + const landed = updateRunnerJumpState( + { airborne: true, velocityY: 300, y: 195 }, + 220, + 188, + 1 / 10, + 0.2 + ); + + expect(landed.airborne).toBe(false); + expect(landed.velocityY).toBe(0); + expect(landed.y).toBe(188); + }); + it("buildRunnerSprite creates a colorful mascot silhouette with animated limbs", () => { const earlyFrame = buildRunnerSprite(100, 160, 16, 0, 0.2); const laterFrame = buildRunnerSprite(100, 160, 16, 0.2, 0.2); diff --git a/src/renderer/effects/platformerScroll.ts b/src/renderer/effects/platformerScroll.ts index f383004c..014cf9ad 100644 --- a/src/renderer/effects/platformerScroll.ts +++ b/src/renderer/effects/platformerScroll.ts @@ -116,6 +116,63 @@ export function runnerJumpOffset(prevSupportY: number, currentSupportY: number, return Math.floor(arc * (lift * 0.85 + extra)); } +export type RunnerJumpState = { + airborne: boolean; + velocityY: number; + y: number; +}; + +export function updateRunnerJumpState( + state: RunnerJumpState, + supportY: number, + nextSupportY: number, + deltaTime: number, + audioAmount: number +): RunnerJumpState { + const dt = clamp(deltaTime, 1 / 240, 0.2); + const rise = supportY - nextSupportY; + const shouldJump = !state.airborne && rise > 0.5; + const jumpStrength = 180 + rise * 5 + audioAmount * 26; + const gravity = 560; + const maxFallSpeed = 380; + const snapDistance = 0.75; + + let y = state.y; + let velocityY = state.velocityY; + let airborne = state.airborne; + + if (shouldJump) { + velocityY = -jumpStrength; + y = supportY; + airborne = true; + } + + if (!airborne) { + return { + airborne: false, + velocityY: 0, + y: supportY + }; + } + + velocityY = Math.min(maxFallSpeed, velocityY + gravity * dt); + y += velocityY * dt; + + if (velocityY >= 0 && y >= nextSupportY - snapDistance) { + return { + airborne: false, + velocityY: 0, + y: nextSupportY + }; + } + + return { + airborne: true, + velocityY, + y + }; +} + export function buildRunnerSprite(baseX: number, footY: number, tileSize: number, time: number, audioAmount: number): RunnerSprite { const unit = Math.max(1, Math.round(tileSize / 16)); const x = baseX - 4 * unit; @@ -277,6 +334,9 @@ const drawHillLayer = ( }; export class PlatformerScrollEffect implements Effect { + private jumpState: RunnerJumpState | null = null; + private lastTime = Number.NEGATIVE_INFINITY; + render({ ctx, width, height, time, audio, params }: EffectRenderContext): void { const speed = Math.max(0, asFinite(params.speed, PLATFORMER_SCROLL_DEFAULTS.speed)); const seed = asFinite(params.seed, PLATFORMER_SCROLL_DEFAULTS.seed); @@ -407,7 +467,35 @@ export class PlatformerScrollEffect implements Effect { ); const supportY = Math.floor(prevSupportY + (currentSupportY - prevSupportY) * smoothstep(colProgress)); - const hop = runnerJumpOffset(prevSupportY, currentSupportY, colProgress, audioAmount); + const nextColSupportY = supportTopY( + currentCol + 1, + seed, + platformRate, + platformMaxSteps, + basePlatformY, + tileSize, + groundTop, + frontPulse + ); + const currentJumpState = + this.jumpState ?? + ({ + airborne: false, + velocityY: 0, + y: supportY + } satisfies RunnerJumpState); + const jumpedTime = time < this.lastTime || time - this.lastTime > 0.5; + const jumpState = jumpedTime + ? { + airborne: false, + velocityY: 0, + y: supportY + } + : updateRunnerJumpState(currentJumpState, supportY, nextColSupportY, time - this.lastTime, audioAmount); + this.jumpState = jumpState; + this.lastTime = time; + + const hop = Math.max(0, Math.floor(supportY - jumpState.y)); const runBob = Math.floor((Math.sin(time * 14) * 1.5 + audioAmount * 1.5) * 0.5); const runnerFootY = supportY - hop - runBob - 1; const runnerSprite = buildRunnerSprite(runnerBaseX, runnerFootY, tileSize, time, audioAmount); From 7e69a461066058a4e4262b400f5d6f98702596d7 Mon Sep 17 00:00:00 2001 From: smcga Date: Wed, 8 Apr 2026 01:45:02 +0100 Subject: [PATCH 2/3] Refine platformer jumps to stable traversal arcs --- src/renderer/effects/platformerScroll.test.ts | 44 +++----- src/renderer/effects/platformerScroll.ts | 101 +++--------------- 2 files changed, 32 insertions(+), 113 deletions(-) diff --git a/src/renderer/effects/platformerScroll.test.ts b/src/renderer/effects/platformerScroll.test.ts index ec417ee4..a8f8dd50 100644 --- a/src/renderer/effects/platformerScroll.test.ts +++ b/src/renderer/effects/platformerScroll.test.ts @@ -7,8 +7,8 @@ import { PlatformerScrollEffect, platformAt, runnerJumpOffset, + runnerTraversalY, supportTopY, - updateRunnerJumpState } from "./platformerScroll"; const createGradient = () => ({ addColorStop: vi.fn() }); @@ -88,35 +88,23 @@ describe("platformerScroll helpers", () => { expect(down).toBe(0); }); - it("updateRunnerJumpState performs a physical jump arc for platform rises", () => { - const launched = updateRunnerJumpState( - { airborne: false, velocityY: 0, y: 220 }, - 220, - 188, - 1 / 60, - 0.2 - ); - const rising = updateRunnerJumpState(launched, 220, 188, 1 / 60, 0.2); - const falling = updateRunnerJumpState({ ...rising, velocityY: 220 }, 220, 188, 1 / 10, 0.2); - - expect(launched.airborne).toBe(true); - expect(launched.velocityY).toBeLessThan(0); - expect(rising.y).toBeLessThan(220); - expect(falling.velocityY).toBeGreaterThan(rising.velocityY); + it("runnerTraversalY adds a jump arc for upward steps", () => { + const start = runnerTraversalY(220, 188, 0, 0.2); + const peak = runnerTraversalY(220, 188, 0.5, 0.2); + const end = runnerTraversalY(220, 188, 1, 0.2); + + expect(start).toBe(220); + expect(peak).toBeLessThan(204); + expect(end).toBe(188); }); - it("updateRunnerJumpState lands and snaps cleanly to support", () => { - const landed = updateRunnerJumpState( - { airborne: true, velocityY: 300, y: 195 }, - 220, - 188, - 1 / 10, - 0.2 - ); - - expect(landed.airborne).toBe(false); - expect(landed.velocityY).toBe(0); - expect(landed.y).toBe(188); + it("runnerTraversalY keeps footing briefly before dropping down", () => { + const early = runnerTraversalY(188, 220, 0.1, 0.2); + const late = runnerTraversalY(188, 220, 0.9, 0.2); + + expect(early).toBe(188); + expect(late).toBeGreaterThan(210); + expect(late).toBeLessThanOrEqual(220); }); it("buildRunnerSprite creates a colorful mascot silhouette with animated limbs", () => { diff --git a/src/renderer/effects/platformerScroll.ts b/src/renderer/effects/platformerScroll.ts index 014cf9ad..919abe3b 100644 --- a/src/renderer/effects/platformerScroll.ts +++ b/src/renderer/effects/platformerScroll.ts @@ -116,61 +116,24 @@ export function runnerJumpOffset(prevSupportY: number, currentSupportY: number, return Math.floor(arc * (lift * 0.85 + extra)); } -export type RunnerJumpState = { - airborne: boolean; - velocityY: number; - y: number; -}; - -export function updateRunnerJumpState( - state: RunnerJumpState, - supportY: number, - nextSupportY: number, - deltaTime: number, - audioAmount: number -): RunnerJumpState { - const dt = clamp(deltaTime, 1 / 240, 0.2); - const rise = supportY - nextSupportY; - const shouldJump = !state.airborne && rise > 0.5; - const jumpStrength = 180 + rise * 5 + audioAmount * 26; - const gravity = 560; - const maxFallSpeed = 380; - const snapDistance = 0.75; - - let y = state.y; - let velocityY = state.velocityY; - let airborne = state.airborne; - - if (shouldJump) { - velocityY = -jumpStrength; - y = supportY; - airborne = true; - } - - if (!airborne) { - return { - airborne: false, - velocityY: 0, - y: supportY - }; +export function runnerTraversalY(prevSupportY: number, currentSupportY: number, colProgress: number, audioAmount: number): number { + const progress = clamp(colProgress, 0, 1); + const heightDelta = prevSupportY - currentSupportY; + + if (heightDelta > 0) { + const phase = smoothstep(progress); + const arcLift = Math.sin(phase * Math.PI) * Math.max(3, heightDelta * 0.45 + audioAmount * 3); + const baseY = prevSupportY + (currentSupportY - prevSupportY) * phase; + return baseY - arcLift; } - velocityY = Math.min(maxFallSpeed, velocityY + gravity * dt); - y += velocityY * dt; - - if (velocityY >= 0 && y >= nextSupportY - snapDistance) { - return { - airborne: false, - velocityY: 0, - y: nextSupportY - }; + if (heightDelta < 0) { + const ledgeHold = 0.22; + const fallProgress = progress <= ledgeHold ? 0 : smoothstep((progress - ledgeHold) / (1 - ledgeHold)); + return prevSupportY + (currentSupportY - prevSupportY) * fallProgress; } - return { - airborne: true, - velocityY, - y - }; + return prevSupportY; } export function buildRunnerSprite(baseX: number, footY: number, tileSize: number, time: number, audioAmount: number): RunnerSprite { @@ -334,9 +297,6 @@ const drawHillLayer = ( }; export class PlatformerScrollEffect implements Effect { - private jumpState: RunnerJumpState | null = null; - private lastTime = Number.NEGATIVE_INFINITY; - render({ ctx, width, height, time, audio, params }: EffectRenderContext): void { const speed = Math.max(0, asFinite(params.speed, PLATFORMER_SCROLL_DEFAULTS.speed)); const seed = asFinite(params.seed, PLATFORMER_SCROLL_DEFAULTS.seed); @@ -466,38 +426,9 @@ export class PlatformerScrollEffect implements Effect { frontPulse ); - const supportY = Math.floor(prevSupportY + (currentSupportY - prevSupportY) * smoothstep(colProgress)); - const nextColSupportY = supportTopY( - currentCol + 1, - seed, - platformRate, - platformMaxSteps, - basePlatformY, - tileSize, - groundTop, - frontPulse - ); - const currentJumpState = - this.jumpState ?? - ({ - airborne: false, - velocityY: 0, - y: supportY - } satisfies RunnerJumpState); - const jumpedTime = time < this.lastTime || time - this.lastTime > 0.5; - const jumpState = jumpedTime - ? { - airborne: false, - velocityY: 0, - y: supportY - } - : updateRunnerJumpState(currentJumpState, supportY, nextColSupportY, time - this.lastTime, audioAmount); - this.jumpState = jumpState; - this.lastTime = time; - - const hop = Math.max(0, Math.floor(supportY - jumpState.y)); + const supportY = runnerTraversalY(prevSupportY, currentSupportY, colProgress, audioAmount); const runBob = Math.floor((Math.sin(time * 14) * 1.5 + audioAmount * 1.5) * 0.5); - const runnerFootY = supportY - hop - runBob - 1; + const runnerFootY = Math.floor(supportY) - runBob - 1; const runnerSprite = buildRunnerSprite(runnerBaseX, runnerFootY, tileSize, time, audioAmount); ctx.fillStyle = runnerSprite.shadow.color; From 155ecba8750f851440cb8613cd5b3cdd470401c1 Mon Sep 17 00:00:00 2001 From: smcga Date: Wed, 8 Apr 2026 02:04:14 +0100 Subject: [PATCH 3/3] Strengthen platformer jump arc profile --- src/renderer/effects/platformerScroll.test.ts | 4 +++- src/renderer/effects/platformerScroll.ts | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/renderer/effects/platformerScroll.test.ts b/src/renderer/effects/platformerScroll.test.ts index a8f8dd50..523ea4b8 100644 --- a/src/renderer/effects/platformerScroll.test.ts +++ b/src/renderer/effects/platformerScroll.test.ts @@ -90,11 +90,13 @@ describe("platformerScroll helpers", () => { it("runnerTraversalY adds a jump arc for upward steps", () => { const start = runnerTraversalY(220, 188, 0, 0.2); + const preTakeoff = runnerTraversalY(220, 188, 0.04, 0.2); const peak = runnerTraversalY(220, 188, 0.5, 0.2); const end = runnerTraversalY(220, 188, 1, 0.2); expect(start).toBe(220); - expect(peak).toBeLessThan(204); + expect(preTakeoff).toBe(220); + expect(peak).toBeLessThan(194); expect(end).toBe(188); }); diff --git a/src/renderer/effects/platformerScroll.ts b/src/renderer/effects/platformerScroll.ts index 919abe3b..d7b78b59 100644 --- a/src/renderer/effects/platformerScroll.ts +++ b/src/renderer/effects/platformerScroll.ts @@ -121,9 +121,11 @@ export function runnerTraversalY(prevSupportY: number, currentSupportY: number, const heightDelta = prevSupportY - currentSupportY; if (heightDelta > 0) { - const phase = smoothstep(progress); - const arcLift = Math.sin(phase * Math.PI) * Math.max(3, heightDelta * 0.45 + audioAmount * 3); - const baseY = prevSupportY + (currentSupportY - prevSupportY) * phase; + const jumpWindowStart = 0.08; + const jumpWindowEnd = 0.96; + const jumpProgress = smoothstep((progress - jumpWindowStart) / (jumpWindowEnd - jumpWindowStart)); + const arcLift = Math.sin(jumpProgress * Math.PI) * Math.max(6, heightDelta * 0.9 + audioAmount * 4); + const baseY = prevSupportY + (currentSupportY - prevSupportY) * jumpProgress; return baseY - arcLift; }