diff --git a/game/README.md b/game/README.md index 5ca6960..90e078e 100644 --- a/game/README.md +++ b/game/README.md @@ -49,4 +49,6 @@ F2 shows speed, hold time, position and the 28×76 player body. The hero current uses the idle atlas pose on the ground; full animation switching is delivered by #31. Space/K or gamepad A/Cross jumps, with about 52px tap height and 96px held height, 0.1s coyote time and 0.12s buffering. Landing uses the drawn landing pose; falls -of at least 152px use the four-frame hard landing. Slopes and ladders follow separately. +of at least 152px use the four-frame hard landing. Authored walk polylines follow +slopes through 40°; steeper surfaces accelerate downhill, with jumping still available. +F2 shows the contact slope and slide state. One-way platforms and ladders follow separately. diff --git a/game/src/player/body.ts b/game/src/player/body.ts index a94e933..827190c 100644 --- a/game/src/player/body.ts +++ b/game/src/player/body.ts @@ -2,15 +2,22 @@ import type { Box, Geometry } from '../stage/collision'; import { GRAVITY } from './jump'; export const BODY_WIDTH = 28; export const BODY_HEIGHT = 76; -export interface Body { x: number; y: number; vx: number; vy: number; grounded: boolean } +export interface Body { x: number; y: number; vx: number; vy: number; grounded: boolean; slope?: number; sliding?: boolean } export function overlaps(body: Body, box: Box) { return body.x + 14 > box.x && body.x - 14 < box.x + box.w && body.y > box.y && body.y - BODY_HEIGHT < box.y + box.h; } -/** Feet origin, swept wall/floor checks. Slope and one-way policies follow separately. */ +/** Feet origin and continuous polylines; one-way policy follows separately. */ export function stepBody(body: Body, geometry: Geometry[], dt: number) { const oldX = body.x, oldY = body.y; + const wasGrounded = body.grounded; + const oldSlope = body.slope ?? 0; + if (body.sliding && wasGrounded && body.vy >= 0) { + const slope = body.slope ?? 0; + const acceleration = GRAVITY * slope / (1 + slope * slope); + body.vx = Math.max(-420, Math.min(420, body.vx + acceleration * dt)); + } body.x += body.vx * dt; for (const world of geometry) for (const wall of world.solid) { if (oldY <= wall.y || oldY - BODY_HEIGHT >= wall.y + wall.h) continue; @@ -23,18 +30,28 @@ export function stepBody(body: Body, geometry: Geometry[], dt: number) { body.vy = Math.min(900, body.vy + GRAVITY * dt); let nextY = body.y + body.vy * dt; body.grounded = false; + body.sliding = false; + body.slope = 0; for (const world of geometry) { - for (const line of world.walk) for (let i = 1; i < line.length; i++) { + for (const line of [...world.walk, ...world.slide]) for (let i = 1; i < line.length; i++) { const [a, b] = [line[i - 1], line[i]]; - if (a[1] !== b[1] || body.x < a[0] || body.x > b[0]) continue; - if (body.vy >= 0 && oldY <= a[1] + 0.01 && nextY >= a[1]) { - nextY = a[1]; body.vy = 0; body.grounded = true; + if (body.x < a[0] || body.x > b[0]) continue; + const slope = (b[1] - a[1]) / (b[0] - a[0]); + const surface = a[1] + (body.x - a[0]) * slope; + const crossedEnd = oldX < a[0] && body.x >= a[0] || oldX > b[0] && body.x <= b[0]; + // The authored chunk seams differ by up to 5px. Only bridge that small + // endpoint mismatch while already grounded, never snap a falling body up. + const follows = wasGrounded && Math.abs(surface - oldY) + <= Math.abs(body.x - oldX) * Math.max(Math.abs(slope), Math.abs(oldSlope)) + (crossedEnd ? 6 : 0.1); + if (body.vy >= 0 && (follows || oldY <= surface + 0.01 && nextY >= surface)) { + nextY = surface; body.vy = 0; body.grounded = true; body.slope = slope; + body.sliding = Math.abs(Math.atan(slope)) > 40 * Math.PI / 180 + 1e-9; } } for (const wall of world.solid) { if (body.x + 14 <= wall.x || body.x - 14 >= wall.x + wall.w) continue; if (body.vy >= 0 && oldY <= wall.y && nextY >= wall.y) { - nextY = wall.y; body.vy = 0; body.grounded = true; + nextY = wall.y; body.vy = 0; body.grounded = true; body.slope = 0; body.sliding = false; } else if (body.vy < 0 && oldY - BODY_HEIGHT >= wall.y + wall.h && nextY - BODY_HEIGHT < wall.y + wall.h) { nextY = wall.y + wall.h + BODY_HEIGHT; body.vy = 0; } diff --git a/game/src/player/player.ts b/game/src/player/player.ts index a7ae3d7..c6abb0a 100644 --- a/game/src/player/player.ts +++ b/game/src/player/player.ts @@ -37,7 +37,8 @@ export class Player { let remaining = Math.min(milliseconds / 1000, 0.1); while (remaining > 1e-9) { const dt = Math.min(remaining, 1 / 120); - move(this.motion, axis, dt); this.body.vx = this.motion.vx; + move(this.motion, axis, dt); + if (!this.body.sliding) this.body.vx = this.motion.vx; jump(this.body, this.air, jumpDown, dt); const wasGrounded = this.body.grounded; stepBody(this.body, this.chunks.map(c => c.world), dt); @@ -46,13 +47,14 @@ export class Player { remaining -= dt; } if (this.body.y > 900 || this.chunks.some(c => c.world.noGo.some(box => overlaps(this.body, box)))) { - Object.assign(this.body, { x: this.spawn.x, y: this.spawn.y, vx: 0, vy: 0, grounded: true }); + Object.assign(this.body, { x: this.spawn.x, y: this.spawn.y, vx: 0, vy: 0, grounded: true, sliding: false, slope: 0 }); this.motion = movement(); this.air = jumpState(this.body.y); } const pose = this.air.landing > 0 ? this.air.hard ? `hard-landing-${String(Math.min(3, Math.floor((4 / 12 - this.air.landing) * 12))).padStart(2, '0')}` - : 'jump-08' : !this.body.grounded ? this.body.vy < 0 ? 'jump-02' : 'fall-00' : null; + : 'jump-08' : this.body.sliding ? `fall-${String(Math.floor(this.scene.time.now * 12 / 1000) % 3).padStart(2, '0')}` + : !this.body.grounded ? this.body.vy < 0 ? 'jump-02' : 'fall-00' : null; if (pose && this.scene.textures.get('hero-air').has(pose)) this.sprite.setTexture('hero-air', pose); else this.sprite.setTexture('hero-move', 'idle-00'); this.sprite.setPosition(Math.round(this.body.x), Math.round(this.body.y)).setFlipX(this.motion.facing < 0); @@ -60,6 +62,6 @@ export class Player { this.debug.clear(); if (this.showDebug) this.debug.lineStyle(2, 0xffffff).strokeRect(this.body.x - BODY_WIDTH / 2, this.body.y - BODY_HEIGHT, BODY_WIDTH, BODY_HEIGHT); - this.label.setText(`vx ${this.body.vx.toFixed(1)} px/s | last ${this.lastSpeed.toFixed(1)} (${(this.lastSpeed / 43).toFixed(2)} m/s)\nheld ${this.motion.held.toFixed(2)}s | x ${this.body.x.toFixed(1)} y ${this.body.y.toFixed(1)}\njump ${this.air.height.toFixed(1)}px | landing ${this.air.hard ? 'hard' : 'normal'}`); + this.label.setText(`vx ${this.body.vx.toFixed(1)} px/s | last ${this.lastSpeed.toFixed(1)} (${(this.lastSpeed / 43).toFixed(2)} m/s)\nheld ${this.motion.held.toFixed(2)}s | x ${this.body.x.toFixed(1)} y ${this.body.y.toFixed(1)}\njump ${this.air.height.toFixed(1)}px | ${this.body.sliding ? 'slide' : 'walk'} slope ${(Math.atan(this.body.slope ?? 0) * 180 / Math.PI).toFixed(1)}°`); } } diff --git a/game/src/player/slopes.test.ts b/game/src/player/slopes.test.ts new file mode 100644 index 0000000..0fae5fc --- /dev/null +++ b/game/src/player/slopes.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { stepBody, type Body } from './body'; +import { jump, jumpState } from './jump'; +import type { CollisionData, Geometry, Point } from '../stage/collision'; +import { placeCollision } from '../stage/collision'; +const terrain = (walk: Point[][], slide: Point[][] = []): Geometry => ({ walk, slide, oneWay: [], solid: [], + climb: [], noGo: [], hazard: [], interact: [], spawn: [], ledge: [], glideLift: [] }); +const body = (x: number, y: number, vx = 150.5): Body => ({ x, y, vx, vy: 0, grounded: true }); +describe('continuous slope contacts', () => { + it('follows uphill, crest and downhill without gaps at walk and run speeds', () => { + const world = terrain([[[0, 500], [150, 400], [300, 500], [600, 500]]]); + for (const speed of [150.5, 258]) { + const b = body(50, 500 - 50 * 2 / 3, speed); + for (let i = 0; i < 180; i++) { + stepBody(b, [world], 1 / 120); + expect(b.grounded).toBe(true); expect(b.sliding).toBe(false); + const expected = b.x < 150 ? 500 - b.x * 2 / 3 : b.x < 300 ? 400 + (b.x - 150) * 2 / 3 : 500; + expect(b.y).toBeCloseTo(expected, 7); + } + } + }); + it('walks exactly 40 degrees and slides above it in both directions', () => { + const s40 = Math.tan(40 * Math.PI / 180); + const b = body(10, 100 + 10 * s40); + stepBody(b, [terrain([[[0, 100], [100, 100 + 100 * s40]]])], 1 / 120); + expect(b.sliding).toBe(false); + for (const sign of [-1, 1]) { + const world = terrain([], [[[0, sign < 0 ? 500 : 100], [200, sign < 0 ? 100 : 500]]]); + const b = body(100, 300, 0); + for (let i = 0; i < 30; i++) stepBody(b, [world], 1 / 120); + expect(b.sliding).toBe(true); expect(Math.sign(b.vx)).toBe(sign); + expect(Math.abs(b.vx)).toBeGreaterThan(100); + jump(b, jumpState(b.y), true, 1 / 120); + stepBody(b, [world], 1 / 120); + expect(b.vy).toBeLessThan(0); expect(b.grounded).toBe(false); + } + }); + it('does not magnetize a jumping body back onto the slope', () => { + const b = { ...body(100, 400), grounded: false, vy: -100 }; + stepBody(b, [terrain([[[0, 500], [500, 250]]])], 1 / 120); + expect(b.grounded).toBe(false); expect(b.y).toBeLessThan(400); + }); + it('uses real authored Rusty Creek walk segments at both speeds', () => { + const files = import.meta.glob('../../../art/stages/rusty-creek/*.collision.json', { eager: true, import: 'default' }); + for (const world of Object.values(files)) for (const line of world.walk) for (let i = 1; i < line.length; i++) { + const [a, z] = [line[i - 1], line[i]], slope = (z[1] - a[1]) / (z[0] - a[0]); + for (const speed of [150.5, 258]) { + const b = body((a[0] + z[0]) / 2, (a[1] + z[1]) / 2, speed); + stepBody(b, [terrain([[a, z]])], 1 / 120); + expect(b.y).toBeCloseTo(a[1] + (b.x - a[0]) * slope, 7); + expect(b.grounded).toBe(true); + } + } + }); + it('crosses the real chunk seams in both states and directions without falling through', () => { + const files = Object.values(import.meta.glob('../../../art/stages/rusty-creek/*.collision.json', { eager: true, import: 'default' })); + for (const state of ['clean', 'anomaly']) for (const column of [0, 1]) { + const left = files.find(f => f.chunk === `rc-${column}-0` && f.state === state)!; + const right = files.find(f => f.chunk === `rc-${column + 1}-0` && f.state === state)!; + const geometry = [placeCollision(left, [0, 0]).world, placeCollision(right, [2160, 0]).world]; + const leftY = left.walk.at(-1)!.at(-1)![1], rightY = right.walk[0][0][1]; + for (const speed of [-258, -150.5, 150.5, 258]) { + const b = body(speed > 0 ? 2155 : 2165, speed > 0 ? leftY : rightY, speed); + for (let i = 0; i < 10; i++) { + stepBody(b, geometry, 1 / 120); + expect(b.grounded).toBe(true); expect(b.y).toBe(b.x < 2160 ? leftY : rightY); + } + } + } + }); +});