Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
31 changes: 24 additions & 7 deletions game/src/player/body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
10 changes: 6 additions & 4 deletions game/src/player/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -46,20 +47,21 @@ 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);
if (axis) this.lastSpeed = Math.abs(this.body.vx);
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)}°`);
}
}
71 changes: 71 additions & 0 deletions game/src/player/slopes.test.ts
Original file line number Diff line number Diff line change
@@ -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<CollisionData>('../../../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<CollisionData>('../../../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);
}
}
}
});
});
Loading