Skip to content

Commit 5bbf849

Browse files
fix: make kart boost panels drivable and drift turbos reachable
中文:修复加速带及倾斜路面的碰撞分类,增加接触加速;调整漂移转向并修复低速起跳后无法进入漂移的问题,使用真实赛道验证自然充能及释放。
1 parent 51f2b9a commit 5bbf849

7 files changed

Lines changed: 238 additions & 28 deletions

File tree

‎docs/mario-kart.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ mini-turbo. Pressing accelerate just after the 2 in the countdown gives a rocket
3333
start; pressing too early burns out. Following close behind another racer
3434
builds a slipstream.
3535

36+
The nine boost panels on the final banked bend give a one-second boost on
37+
contact. Banking is drivable terrain, not a vertical barrier. Drift steering
38+
uses speed-dependent handling so ordinary bends allow natural blue/orange
39+
charge; a low-speed hop held through acceleration can also enter a drift.
40+
3641
Item boxes sit in three rows around the course. The roulette lands on an item
3742
weighted by position, as in the original: front runners mostly see bananas and
3843
green shells, back markers see stars, triple mushrooms, Golden Mushrooms, and

‎packages/app/public/decorative/kart/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ All archives were downloaded on 2026-10-05 from The Models Resource.
5656
boundary) plus ground height. Format: `KSRF` magic, then little-endian
5757
`x0, z0, res` (f32), `pad, width, height` (u32), `width*height` surface bytes,
5858
and `width*height` int16 heights in 1/32 units.
59+
- The bank and all nine boost strips are restored from the shipped course's
60+
baked `polygon8` (MainRoad), `polygon1` (curb), and `polygon0`
61+
(`ef_dushBoard`) triangles by `bun scripts/repair-kart-surface.ts` in
62+
`packages/app`. Surface codes 7 and 8 identify drivable banking and boost
63+
panels; steep vertical faces and higher structures remain blocked.
5964
- `circuit.webp` and `circuit-mobile.webp`: screenshots of the converted course
6065
in this app's renderer, without HUD.
6166

0 Bytes
Binary file not shown.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { readFileSync, writeFileSync } from 'node:fs';
2+
import { fileURLToPath } from 'node:url';
3+
import { parseSurface, SURFACE } from '../src/components/kart/kart-surface';
4+
5+
// The exported course has baked world-space vertices. polygon8 is MainRoad,
6+
// polygon1 is the curb, and polygon0 in the course (not sky) is ef_dushBoard.
7+
// Restore their drivable triangles instead of treating the bank as a wall.
8+
const directory = new URL('../public/decorative/kart/', import.meta.url);
9+
const gltf = JSON.parse(readFileSync(new URL('luigi-circuit.gltf', directory), 'utf8'));
10+
const buffers = gltf.buffers.map((b: { uri: string }) =>
11+
Buffer.from(b.uri.split(',')[1], 'base64'),
12+
);
13+
const path = fileURLToPath(new URL('luigi-circuit-surface.bin', directory));
14+
const bytes = readFileSync(path);
15+
const map = parseSurface(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
16+
const course = gltf.nodes[gltf.nodes[gltf.scenes[0].nodes[0]].children[0]];
17+
let changed = 0;
18+
for (const [name, type] of [
19+
['polygon8', SURFACE.road],
20+
['polygon1', SURFACE.curb],
21+
['polygon0', SURFACE.boost],
22+
] as const) {
23+
const node = course.children
24+
.map((i: number) => gltf.nodes[i])
25+
.find((n: { name: string }) => n.name === name);
26+
const primitive = gltf.meshes[node.mesh].primitives[0];
27+
if (primitive.indices !== undefined) throw new Error('Expected non-indexed course mesh');
28+
const accessor = gltf.accessors[primitive.attributes.POSITION];
29+
const view = gltf.bufferViews[accessor.bufferView];
30+
const data = buffers[view.buffer];
31+
const vertex = (i: number) =>
32+
[0, 1, 2].map((axis) =>
33+
data.readFloatLE(
34+
(view.byteOffset ?? 0) +
35+
(accessor.byteOffset ?? 0) +
36+
i * (view.byteStride ?? 12) +
37+
axis * 4,
38+
),
39+
);
40+
for (let t = 0; t < accessor.count; t += 3) {
41+
const [a, b, c] = [vertex(t), vertex(t + 1), vertex(t + 2)];
42+
const den = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2]);
43+
if (Math.abs(den) < 1e-6) continue;
44+
const gx = ((b[1] - a[1]) * (c[2] - a[2]) - (c[1] - a[1]) * (b[2] - a[2])) / den;
45+
const gz = ((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])) / den;
46+
const slope = Math.hypot(gx, gz);
47+
if (slope > 1.15) continue; // Keep vertical rails and curb faces blocked.
48+
const surface = type !== SURFACE.boost && slope > 0.25 ? SURFACE.bank : type;
49+
const minI = Math.max(0, Math.floor((Math.min(a[0], b[0], c[0]) - map.x0) / map.res));
50+
const maxI = Math.min(
51+
map.width - 1,
52+
Math.ceil((Math.max(a[0], b[0], c[0]) - map.x0) / map.res),
53+
);
54+
const minJ = Math.max(0, Math.floor((Math.min(a[2], b[2], c[2]) - map.z0) / map.res));
55+
const maxJ = Math.min(
56+
map.height - 1,
57+
Math.ceil((Math.max(a[2], b[2], c[2]) - map.z0) / map.res),
58+
);
59+
for (let j = minJ; j <= maxJ; j++)
60+
for (let i = minI; i <= maxI; i++) {
61+
const x = map.x0 + (i + 0.5) * map.res,
62+
z = map.z0 + (j + 0.5) * map.res;
63+
const u = ((b[2] - c[2]) * (x - c[0]) + (c[0] - b[0]) * (z - c[2])) / den;
64+
const v = ((c[2] - a[2]) * (x - c[0]) + (a[0] - c[0]) * (z - c[2])) / den;
65+
if (u < -1e-5 || v < -1e-5 || u + v > 1.00001) continue;
66+
const y = u * a[1] + v * b[1] + (1 - u - v) * c[1];
67+
const index = j * map.width + i;
68+
// Do not punch through a higher structure covering the road.
69+
if (map.y[index] / 32 > y + 0.5) continue;
70+
if (type !== SURFACE.boost && map.type[index] === SURFACE.boost) continue;
71+
if (map.type[index] !== surface || map.y[index] !== Math.round(y * 32)) changed++;
72+
map.type[index] = surface;
73+
map.y[index] = Math.round(y * 32);
74+
}
75+
}
76+
}
77+
Buffer.from(map.type).copy(bytes, 28);
78+
for (let i = 0; i < map.y.length; i++) bytes.writeInt16LE(map.y[i], 28 + map.type.length + i * 2);
79+
writeFileSync(path, bytes);
80+
console.log(`Updated ${changed} drivable surface samples.`);

‎packages/app/src/components/kart/kart-engine.test.ts‎

Lines changed: 111 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
type Race,
1919
} from './kart-engine';
2020
import { heightAt, parseSurface, SURFACE, surfaceAt } from './kart-surface';
21-
import { pointAt as trackPoint, wrapAngle } from './kart-track';
21+
import { nearest, pointAt as trackPoint, wrapAngle } from './kart-track';
2222

2323
const bin = readFileSync(
2424
path.resolve(import.meta.dirname, '../../../public/decorative/kart/luigi-circuit-surface.bin'),
@@ -153,23 +153,117 @@ describe('kart engine', () => {
153153
expect(race.player.speed).toBeLessThan(0);
154154
});
155155

156-
it('drifting charges blue then orange sparks and releases a mini-turbo', () => {
156+
it.each([
157+
[200, 'right', 1, 1],
158+
[200, 'right', 2, 1.7],
159+
[400, 'left', 1, 1],
160+
] as const)(
161+
'drifts from %s to the %s and naturally earns stage %s',
162+
(distance, direction, stage, seconds) => {
163+
const race = solo(distance);
164+
race.karts = [race.player];
165+
race.player.speed = 60;
166+
for (let i = 0; i < Math.round(seconds / STEP); i++) {
167+
stepRace(race, { ...EMPTY_CONTROLS, throttle: true, drift: true, [direction]: true }, STEP);
168+
expect(race.events.some((e) => e.type === 'wall')).toBe(false);
169+
race.events.length = 0;
170+
}
171+
expect(race.player.driftStage).toBe(stage);
172+
const speed = race.player.speed;
173+
stepRace(race, { ...EMPTY_CONTROLS, throttle: true }, STEP);
174+
expect(race.events).toContainEqual({
175+
type: 'mini-turbo',
176+
kart: race.player.index,
177+
value: stage,
178+
});
179+
expect(race.player.driftDir).toBe(0);
180+
expect(race.player.driftCharge).toBe(0);
181+
expect(race.player.boost).toBeCloseTo(stage === 2 ? 1.25 : 0.7);
182+
run(race, { throttle: true }, 0.1);
183+
expect(race.player.speed).toBeGreaterThan(speed + 2);
184+
},
185+
);
186+
187+
it('starts a held drift after accelerating out of a low-speed hop', () => {
188+
const race = solo(190);
189+
race.karts = [race.player];
190+
race.player.speed = 11;
191+
run(race, { drift: true }, 0.5);
192+
expect(race.player.grounded).toBe(true);
193+
expect(race.player.driftDir).toBe(0);
194+
run(race, { throttle: true, drift: true, right: true }, 0.7);
195+
expect(race.player.driftDir).toBe(-1);
196+
expect(race.player.driftCharge).toBeGreaterThan(0);
197+
});
198+
199+
it('does not reward an uncharged drift or a spin-out with a mini-turbo', () => {
200+
for (const spin of [false, true]) {
201+
const race = solo(200);
202+
race.karts = [race.player];
203+
race.player.speed = 60;
204+
run(race, { throttle: true, drift: true, right: true }, spin ? 1 : 0.3);
205+
expect(race.player.driftStage).toBe(spin ? 1 : 0);
206+
if (spin) race.player.spin = 1;
207+
stepRace(race, { ...EMPTY_CONTROLS, throttle: true }, STEP);
208+
expect(race.player.boost).toBe(0);
209+
expect(race.events.some((e) => e.type === 'mini-turbo')).toBe(false);
210+
}
211+
});
212+
213+
it('does not activate a boost strip while flying above it', () => {
157214
const race = solo();
158-
run(race, { throttle: true }, 1.5);
159-
run(race, { throttle: true, drift: true, left: true }, 0.3);
160-
expect(race.player.driftDir).toBe(1);
161-
run(race, { throttle: true, drift: true, left: true }, 0.5);
162-
expect(race.player.driftStage).toBe(0);
163-
expect(race.player.driftCharge).toBeGreaterThan(0.5);
164-
// Skip ahead in the charge rather than circling into the wall on a straight.
165-
race.player.driftCharge = 1.1;
166-
run(race, { throttle: true, drift: true, left: true }, 0.1);
167-
expect(race.player.driftStage).toBe(1);
168-
race.player.driftCharge = 2.05;
169-
run(race, { throttle: true, drift: true, left: true }, 0.15);
170-
expect(race.player.driftStage).toBe(2);
171-
run(race, { throttle: true }, STEP);
172-
expect(race.player.boost).toBeGreaterThan(0);
215+
race.karts = [race.player];
216+
Object.assign(race.player, {
217+
x: -125.13,
218+
z: 511.65,
219+
y: 30,
220+
speed: 60,
221+
grounded: false,
222+
});
223+
stepRace(race, { ...EMPTY_CONTROLS, throttle: true }, STEP);
224+
expect(race.player.boost).toBe(0);
225+
expect(race.player.boostPanelContact).toBe(false);
226+
});
227+
228+
// Centers of all nine ef_dushBoard strips in the shipped course mesh.
229+
it.each([
230+
[-157.58, 505.58],
231+
[-185.15, 488.04],
232+
[-203.74, 462.55],
233+
[-125.13, 511.65],
234+
[-92.65, 505.64],
235+
[-65.04, 488.15],
236+
[-39.95, 439.66],
237+
[-46.41, 462.68],
238+
[-210.19, 439.41],
239+
])('drives through boost strip at (%s, %s) without a wall impact', (x, z) => {
240+
const race = solo();
241+
race.karts = [race.player];
242+
expect(surfaceAt(surface, x, z)).toBe(SURFACE.boost);
243+
const n = nearest(x, z);
244+
Object.assign(race.player, {
245+
x: x - n.tx * 9,
246+
z: z - n.tz * 9,
247+
heading: Math.atan2(n.tx, n.tz),
248+
speed: 60,
249+
});
250+
race.player.y = heightAt(surface, race.player.x, race.player.z);
251+
let boosts = 0;
252+
for (let i = 0; i < 15; i++) {
253+
stepRace(race, { ...EMPTY_CONTROLS, throttle: true }, STEP);
254+
expect(race.events.some((e) => e.type === 'wall')).toBe(false);
255+
boosts += race.events.filter((e) => e.type === 'boost').length;
256+
race.events.length = 0;
257+
}
258+
expect(boosts).toBe(1);
259+
expect(race.player.speed).toBeGreaterThan(70);
260+
expect((race.player.x - x) * n.tx + (race.player.z - z) * n.tz).toBeGreaterThan(5);
261+
// Moving to ordinary road must let the boost expire, not refresh it forever.
262+
const p = pointAt(10);
263+
Object.assign(race.player, { x: p.x, z: p.z, y: p.y, heading: p.heading });
264+
run(race, { throttle: true }, 1.2);
265+
expect(race.player.boost).toBe(0);
266+
expect(race.player.boostPanelContact).toBe(false);
173267
});
174268

175269
it('rocket start rewards timing and burns out when early', () => {

‎packages/app/src/components/kart/kart-engine.ts‎

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export interface Kart {
132132
driftStage: 0 | 1 | 2;
133133
boost: number;
134134
boostPower: number;
135+
boostPanelContact: boolean;
135136
draft: number;
136137
spin: number;
137138
tumble: number;
@@ -317,6 +318,7 @@ export function newRace(options: RaceOptions = {}): Race {
317318
driftStage: 0,
318319
boost: 0,
319320
boostPower: 1,
321+
boostPanelContact: false,
320322
draft: 0,
321323
spin: 0,
322324
tumble: 0,
@@ -404,18 +406,26 @@ export const isStunned = (k: Kart) => k.spin > 0 || k.tumble > 0 || k.respawn >
404406
// ---------------------------------------------------------------------------
405407
// Simulation
406408
// ---------------------------------------------------------------------------
407-
const blocked = (race: Race, k: { y: number }, x: number, z: number) => {
409+
const blocked = (race: Race, k: { x: number; y: number; z: number }, x: number, z: number) => {
408410
const s = surfaceAt(race.surface, x, z);
409411
if (s === SURFACE.wall || s === SURFACE.none) return true;
410412
// Barrier rails and building ledges register as floors that are too tall to climb.
411-
return heightAt(race.surface, x, z) - k.y > 1.6;
413+
const allowance =
414+
s === SURFACE.bank || s === SURFACE.boost ? 1.6 + Math.hypot(x - k.x, z - k.z) * 1.15 : 1.6;
415+
return heightAt(race.surface, x, z) - k.y > allowance;
412416
};
413417
const RING = Array.from({ length: 10 }, (_, i) => [
414418
Math.cos((i / 10) * Math.PI * 2),
415419
Math.sin((i / 10) * Math.PI * 2),
416420
]);
417421
/** Returns an outward wall normal if a circle at (x, z) overlaps blocked terrain. */
418-
function wallNormal(race: Race, k: { y: number }, x: number, z: number, r: number) {
422+
function wallNormal(
423+
race: Race,
424+
k: { x: number; y: number; z: number },
425+
x: number,
426+
z: number,
427+
r: number,
428+
) {
419429
let nx = 0;
420430
let nz = 0;
421431
let hit = false;
@@ -877,15 +887,21 @@ function stepKart(race: Race, k: Kart, input: Controls, dt: number) {
877887
k.driftArmed = true;
878888
}
879889
if (!driftHeld) k.driftArmed = false;
880-
if (k.hop > 0) {
881-
k.hop -= dt;
882-
if (driftHeld && Math.abs(k.steer) > 0.3 && k.driftDir === 0 && k.speed > 22)
883-
k.driftDir = k.steer > 0 ? 1 : -1;
884-
}
890+
if (k.hop > 0) k.hop -= dt;
891+
// A hop started below drift speed stays armed while held. Otherwise the player
892+
// can accelerate through the threshold but never start drifting until re-pressing.
893+
if (
894+
driftHeld &&
895+
(k.hop > 0 || (k.driftArmed && k.grounded)) &&
896+
Math.abs(k.steer) > 0.3 &&
897+
k.driftDir === 0 &&
898+
k.speed > 22
899+
)
900+
k.driftDir = k.steer > 0 ? 1 : -1;
885901
if (k.driftDir !== 0) {
886902
if (!driftHeld || k.speed < 18 || stunned || surf === SURFACE.water) {
887903
// Release: mini-turbo.
888-
if (!stunned && driftHeld === false && k.driftStage > 0) {
904+
if (!stunned && !driftHeld && k.driftStage > 0 && k.speed >= 18 && surf !== SURFACE.water) {
889905
const stage = k.driftStage;
890906
giveBoost(k, stage === 2 ? 1.25 : 0.7, stage === 2 ? 1.3 : 1.22);
891907
race.events.push({ type: 'mini-turbo', kart: k.index, value: stage });
@@ -907,7 +923,8 @@ function stepKart(race: Race, k: Kart, input: Controls, dt: number) {
907923
let yaw =
908924
k.driftDir === 0
909925
? k.steer * s.handling * speedFactor * Math.sign(k.speed || 1) * (k.grounded ? 1 : 0.6)
910-
: k.driftDir * s.handling * (0.72 + 0.48 * k.steer * k.driftDir) * Math.min(1, k.speed / 24);
926+
: // Keep the same speed-dependent steering envelope as normal driving.
927+
k.driftDir * s.handling * (0.55 + 0.45 * k.steer * k.driftDir) * speedFactor;
911928
if (k.spin > 0) yaw = 0;
912929
if (k.tumble > 0) yaw *= 0.2;
913930
yaw *= grip;
@@ -1004,7 +1021,14 @@ function stepKart(race: Race, k: Kart, input: Controls, dt: number) {
10041021
k.driftDir = k.steer > 0 ? 1 : -1;
10051022
}
10061023
}
1007-
if (surfaceAt(race.surface, k.x, k.z) === SURFACE.water && k.grounded) {
1024+
const landedSurface = surfaceAt(race.surface, k.x, k.z);
1025+
const onPanel = landedSurface === SURFACE.boost && k.grounded;
1026+
if (onPanel && !k.boostPanelContact && !stunned && !finished) {
1027+
giveBoost(k, 1, 1.35);
1028+
race.events.push({ type: 'boost', kart: k.index });
1029+
}
1030+
k.boostPanelContact = onPanel;
1031+
if (landedSurface === SURFACE.water && k.grounded) {
10081032
k.respawn = 1.8;
10091033
k.trailing = false;
10101034
k.driftDir = 0;

‎packages/app/src/components/kart/kart-surface.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export const SURFACE = {
1313
water: 4,
1414
wall: 5,
1515
curb: 6,
16+
bank: 7,
17+
boost: 8,
1618
} as const;
1719
export type SurfaceType = (typeof SURFACE)[keyof typeof SURFACE];
1820

0 commit comments

Comments
 (0)