Reintroduce apps/arcade and the ten game crates that were ripped out of
the fork (assets, audio, blocks, coedit, gen, net, pkg, render, script,
session), from makepad-internal/dev. Reconcile against the fork's layout:
- makepad-game-sim/math come from the fork's existing libs/sim + sim/math;
the copied internal libs/game/{sim,math} are dropped and the kept game
crates plus arcade point at ../../sim and ../../sim/math.
- arcade AI is rewritten off the removed makepad-ai agent API onto the
makepad-ai-hub headless ChatProvider worker (example/cad pattern):
AiWorker{send,cancel,poll}, ai_worker_loop driving
ClaudeApiChatProvider(ClaudeCli), AiWorkerEvent availability/delta/
done/error mapped into the chat feed and authoring land_edit, with
main.rs send_message/cancel_request/event-drain rebuilt around it.
- the game-script sandbox is adapted to the fork's jailed splash storage:
every isolate gets mod.fs = splash_storage jail; splaes splash_storage
set_root_for_heap is now public so the ScriptHost (or Splash) can grant
a per-game jail root. ENT font: build.rs/dispatch.rs entities fill new
fork sim Entity/Part fields via ..Default::default(); world_raycast now
borrows mutably and returns the material id.
cargo check -p makepad-arcade and the game + arcade test suites pass.
510 lines
18 KiB
Rust
510 lines
18 KiB
Rust
//! The whole player loop, headless: walk, jump, approach the car, press the
|
|
//! primary activity button, drive, press it again, end up standing beside it.
|
|
//!
|
|
//! This is the acceptance test for the controller binding. Every assertion is
|
|
//! something a player would notice, and the transitions — the two moments the
|
|
//! camera changes what it follows — are where this has broken before, so they
|
|
//! are checked from both ends rather than only at rest.
|
|
//!
|
|
//! No window, no renderer, no script VM: scripted `RawInput` in, world state
|
|
//! out. The camera is checked numerically, which is the only way to catch it
|
|
//! sinking into a wall without a human watching.
|
|
|
|
use makepad_game_blocks::*;
|
|
use makepad_game_script::interact::{self, InteractKind, InteractSet};
|
|
use makepad_game_sim::*;
|
|
use makepad_widgets::makepad_math::*;
|
|
|
|
const DT: f32 = TICK_DT;
|
|
|
|
fn new_id(world: &mut GameWorld) -> u64 {
|
|
world.next_id += 1;
|
|
world.next_id
|
|
}
|
|
|
|
fn ground(world: &mut GameWorld) {
|
|
let id = new_id(world);
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Static,
|
|
pos: vec3f(0.0, -2.0, 0.0),
|
|
half: vec3f(200.0, 2.0, 200.0),
|
|
collide: true,
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
density: 1.0,
|
|
friction: 0.6,
|
|
..Default::default()
|
|
});
|
|
}
|
|
|
|
/// The two-line binding the brief asks for, plus the body every entity needs.
|
|
fn player(world: &mut GameWorld, blocks: &mut Blocks, pos: Vec3f) -> u64 {
|
|
let id = new_id(world);
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Mover,
|
|
pos,
|
|
half: vec3f(0.35, 0.9, 0.35),
|
|
collide: true,
|
|
gravity_scale: 1.0,
|
|
speed_mult: 1.0,
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
density: 1.0,
|
|
friction: 0.7,
|
|
..Default::default()
|
|
});
|
|
blocks.characters.push(Character::new(
|
|
id,
|
|
CharacterConfig::default(),
|
|
ControlSource::Player,
|
|
None,
|
|
));
|
|
blocks.player_rigs.insert(PlayerId(0), PlayerRig::new(id));
|
|
id
|
|
}
|
|
|
|
fn car(world: &mut GameWorld, blocks: &mut Blocks, pos: Vec3f) -> u64 {
|
|
let id = new_id(world);
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Rigid,
|
|
pos,
|
|
half: vec3f(0.9, 0.4, 1.6),
|
|
tag: "car".to_string(),
|
|
collide: true,
|
|
gravity_scale: 1.0,
|
|
speed_mult: 1.0,
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
density: 1.0,
|
|
friction: 0.7,
|
|
..Default::default()
|
|
});
|
|
blocks
|
|
.cars
|
|
.push(Car::new(id, CarConfig::default(), ControlSource::Player));
|
|
id
|
|
}
|
|
|
|
fn wall(world: &mut GameWorld, pos: Vec3f, half: Vec3f) -> u64 {
|
|
let id = new_id(world);
|
|
world.push_entity(Entity {
|
|
id,
|
|
kind: BodyKind::Static,
|
|
pos,
|
|
half,
|
|
collide: true,
|
|
scale: vec3f(1.0, 1.0, 1.0),
|
|
scale_target: vec3f(1.0, 1.0, 1.0),
|
|
density: 1.0,
|
|
friction: 0.6,
|
|
..Default::default()
|
|
});
|
|
id
|
|
}
|
|
|
|
/// One full host frame, in the order the host owes the engine.
|
|
fn step(world: &mut GameWorld, blocks: &mut Blocks, raw: RawInput) {
|
|
let mut intents = std::collections::HashMap::new();
|
|
intents.insert(PlayerId(0), raw);
|
|
blocks.tick_player_rigs(world, &intents);
|
|
blocks.pre_step(world);
|
|
step_world(world);
|
|
blocks.post_step(world);
|
|
world.tick += 1;
|
|
world.time += DT as f64;
|
|
}
|
|
|
|
fn rig(blocks: &Blocks) -> PlayerRig {
|
|
*blocks.player_rigs.get(&PlayerId(0)).expect("player 0 has a rig")
|
|
}
|
|
|
|
/// How far the camera's eye is INSIDE the nearest solid box, or 0 if clear.
|
|
///
|
|
/// Static geometry only: the camera is allowed to pass through the character
|
|
/// and the car it is following, and a boom that refused to would jam against
|
|
/// the very thing it is looking at.
|
|
fn camera_penetration(world: &GameWorld, eye: Vec3f) -> f32 {
|
|
let mut worst: f32 = 0.0;
|
|
for e in world.entities.iter().filter(|e| e.kind == BodyKind::Static) {
|
|
let dx = e.half.x - (eye.x - e.pos.x).abs();
|
|
let dy = e.half.y - (eye.y - e.pos.y).abs();
|
|
let dz = e.half.z - (eye.z - e.pos.z).abs();
|
|
if dx > 0.0 && dy > 0.0 && dz > 0.0 {
|
|
worst = worst.max(dx.min(dy).min(dz));
|
|
}
|
|
}
|
|
worst
|
|
}
|
|
|
|
fn walk(move_y: f32) -> RawInput {
|
|
RawInput {
|
|
move_y,
|
|
run: 0.0,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// **The headline test.** Walk, jump, get in, drive, get out, still standing.
|
|
#[test]
|
|
fn walk_jump_drive_and_get_back_out() {
|
|
let mut world = GameWorld::new();
|
|
world.gravity = 30.0;
|
|
let mut blocks = Blocks::new();
|
|
ground(&mut world);
|
|
// The car sits ahead of the player along -Z, which is forward at yaw 0.
|
|
let car_id = car(&mut world, &mut blocks, vec3f(0.0, 1.2, -10.0));
|
|
let me = player(&mut world, &mut blocks, vec3f(0.0, 1.0, 0.0));
|
|
|
|
// Settle onto the ground before measuring anything.
|
|
for _ in 0..30 {
|
|
step(&mut world, &mut blocks, RawInput::default());
|
|
}
|
|
let start = world.entity(me).unwrap().pos;
|
|
assert!(
|
|
world.entity(me).unwrap().on_floor,
|
|
"the player should be standing on the ground before we start"
|
|
);
|
|
|
|
// ── walk ────────────────────────────────────────────────────────────
|
|
for _ in 0..40 {
|
|
step(&mut world, &mut blocks, walk(1.0));
|
|
}
|
|
let walked = world.entity(me).unwrap().pos;
|
|
assert!(
|
|
walked.z < start.z - 2.0,
|
|
"holding forward for 40 ticks should walk several metres along -Z, \
|
|
went from z={} to z={}",
|
|
start.z,
|
|
walked.z
|
|
);
|
|
|
|
// ── jump ────────────────────────────────────────────────────────────
|
|
let ground_y = world.entity(me).unwrap().pos.y;
|
|
let mut peak = ground_y;
|
|
for tick in 0..45 {
|
|
let mut raw = walk(0.0);
|
|
// Held, not tapped: variable jump height only engages on evidence the
|
|
// button is genuinely down.
|
|
raw.jump = tick < 12;
|
|
raw.jump_pressed = tick == 0;
|
|
step(&mut world, &mut blocks, raw);
|
|
peak = peak.max(world.entity(me).unwrap().pos.y);
|
|
}
|
|
assert!(
|
|
peak > ground_y + 1.0,
|
|
"a full jump should clear a metre, only reached {:.2} above {:.2}",
|
|
peak - ground_y,
|
|
ground_y
|
|
);
|
|
assert!(
|
|
world.entity(me).unwrap().on_floor,
|
|
"the player must land again within 45 ticks"
|
|
);
|
|
|
|
// ── approach ────────────────────────────────────────────────────────
|
|
for _ in 0..90 {
|
|
step(&mut world, &mut blocks, walk(1.0));
|
|
let r = rig(&blocks);
|
|
let pos = world.entity(r.mount.subject()).unwrap().pos;
|
|
let facing = heading_to_forward(r.camera.yaw);
|
|
if interact::choose(
|
|
&world,
|
|
&blocks,
|
|
&InteractSet::default(),
|
|
r.seat(),
|
|
pos,
|
|
facing,
|
|
)
|
|
.is_some()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
// The affordance must be offered BEFORE the button does anything —
|
|
// otherwise nobody discovers the mechanic.
|
|
let r = rig(&blocks);
|
|
let pos = world.entity(r.mount.subject()).unwrap().pos;
|
|
let offered = interact::choose(
|
|
&world,
|
|
&blocks,
|
|
&InteractSet::default(),
|
|
r.seat(),
|
|
pos,
|
|
heading_to_forward(r.camera.yaw),
|
|
)
|
|
.expect("walking up to a car must offer something");
|
|
assert_eq!(offered.kind, InteractKind::Drive);
|
|
assert_eq!(offered.entity, car_id);
|
|
|
|
// ── get in ──────────────────────────────────────────────────────────
|
|
let mut press = walk(0.0);
|
|
press.use_pressed = true;
|
|
step(&mut world, &mut blocks, press);
|
|
assert_eq!(
|
|
rig(&blocks).seat(),
|
|
Seat::Driving(car_id),
|
|
"pressing the activity button beside a car must seat the player in it"
|
|
);
|
|
|
|
// ── drive ───────────────────────────────────────────────────────────
|
|
let car_start = world.entity(car_id).unwrap().pos;
|
|
for _ in 0..90 {
|
|
let mut raw = RawInput::default();
|
|
raw.throttle = 1.0;
|
|
step(&mut world, &mut blocks, raw);
|
|
}
|
|
let car_now = world.entity(car_id).unwrap().pos;
|
|
let travelled =
|
|
((car_now.x - car_start.x).powi(2) + (car_now.z - car_start.z).powi(2)).sqrt();
|
|
assert!(
|
|
travelled > 3.0,
|
|
"full throttle for 90 ticks should move the car, only went {travelled:.2}m"
|
|
);
|
|
|
|
// While seated, the button means "get out" no matter what else is near.
|
|
let r = rig(&blocks);
|
|
let seated_offer = interact::choose(
|
|
&world,
|
|
&blocks,
|
|
&InteractSet::default(),
|
|
r.seat(),
|
|
world.entity(r.mount.subject()).unwrap().pos,
|
|
heading_to_forward(r.camera.yaw),
|
|
)
|
|
.expect("seated always offers an exit");
|
|
assert_eq!(seated_offer.kind, InteractKind::Exit);
|
|
|
|
// ── get out ─────────────────────────────────────────────────────────
|
|
let mut press = RawInput::default();
|
|
press.use_pressed = true;
|
|
step(&mut world, &mut blocks, press);
|
|
assert_eq!(rig(&blocks).seat(), Seat::OnFoot, "the button must let go");
|
|
|
|
// Settle, then check the player is genuinely standing — not falling, not
|
|
// buried, not left inside the car.
|
|
for _ in 0..60 {
|
|
step(&mut world, &mut blocks, RawInput::default());
|
|
}
|
|
let out = world.entity(me).unwrap();
|
|
assert!(
|
|
out.on_floor,
|
|
"the player must end on solid ground, not mid-air at y={:.2}",
|
|
out.pos.y
|
|
);
|
|
assert!(!out.hidden, "the player must be visible again after dismount");
|
|
assert!(
|
|
out.pos.y > -1.0,
|
|
"the player must not be dropped through the floor (y={:.2})",
|
|
out.pos.y
|
|
);
|
|
}
|
|
|
|
/// The camera must not enter geometry — across BOTH transitions, not just at
|
|
/// rest. A rig that only settles correctly still looks broken while it blends.
|
|
#[test]
|
|
fn the_camera_never_enters_geometry_across_both_transitions() {
|
|
let mut world = GameWorld::new();
|
|
world.gravity = 30.0;
|
|
let mut blocks = Blocks::new();
|
|
ground(&mut world);
|
|
// A wall right behind where the player starts, so a boom that did not
|
|
// shorten would push the eye straight into it.
|
|
wall(&mut world, vec3f(0.0, 2.0, 6.0), vec3f(12.0, 2.0, 0.5));
|
|
let car_id = car(&mut world, &mut blocks, vec3f(0.0, 1.2, -6.0));
|
|
player(&mut world, &mut blocks, vec3f(0.0, 1.0, 0.0));
|
|
|
|
let mut worst = 0.0f32;
|
|
let mut worst_at = "settle";
|
|
let check = |world: &GameWorld, blocks: &Blocks, phase: &'static str,
|
|
worst: &mut f32, worst_at: &mut &'static str| {
|
|
let pen = camera_penetration(world, rig(blocks).camera.eye());
|
|
if pen > *worst {
|
|
*worst = pen;
|
|
*worst_at = phase;
|
|
}
|
|
};
|
|
|
|
for _ in 0..40 {
|
|
step(&mut world, &mut blocks, RawInput::default());
|
|
check(&world, &blocks, "settle", &mut worst, &mut worst_at);
|
|
}
|
|
// Walk up to the car with the wall behind — the boom is squeezed here.
|
|
for _ in 0..120 {
|
|
step(&mut world, &mut blocks, walk(1.0));
|
|
check(&world, &blocks, "approach", &mut worst, &mut worst_at);
|
|
}
|
|
// Transition 1: on foot → driving, sampled every tick through the blend.
|
|
let mut press = walk(0.0);
|
|
press.use_pressed = true;
|
|
step(&mut world, &mut blocks, press);
|
|
for _ in 0..60 {
|
|
let mut raw = RawInput::default();
|
|
raw.throttle = 1.0;
|
|
step(&mut world, &mut blocks, raw);
|
|
check(&world, &blocks, "mount blend", &mut worst, &mut worst_at);
|
|
}
|
|
assert_eq!(rig(&blocks).seat(), Seat::Driving(car_id));
|
|
// Transition 2: driving → on foot, likewise.
|
|
let mut press = RawInput::default();
|
|
press.use_pressed = true;
|
|
step(&mut world, &mut blocks, press);
|
|
for _ in 0..60 {
|
|
step(&mut world, &mut blocks, RawInput::default());
|
|
check(&world, &blocks, "dismount blend", &mut worst, &mut worst_at);
|
|
}
|
|
|
|
assert!(
|
|
worst < 0.15,
|
|
"camera sank {worst:.2}m into static geometry during '{worst_at}' — \
|
|
the boom must shorten before the eye reaches a wall"
|
|
);
|
|
}
|
|
|
|
/// Analog in, analog out: the property a keyboard cannot have, and the one
|
|
/// most likely to be lost to a threshold.
|
|
#[test]
|
|
fn partial_stick_deflection_gives_partial_speed() {
|
|
let speed_after = |deflection: f32| -> f32 {
|
|
let mut world = GameWorld::new();
|
|
world.gravity = 30.0;
|
|
let mut blocks = Blocks::new();
|
|
ground(&mut world);
|
|
let me = player(&mut world, &mut blocks, vec3f(0.0, 1.0, 0.0));
|
|
for _ in 0..30 {
|
|
step(&mut world, &mut blocks, RawInput::default());
|
|
}
|
|
for _ in 0..60 {
|
|
step(&mut world, &mut blocks, walk(deflection));
|
|
}
|
|
let v = world.entity(me).unwrap().vel;
|
|
(v.x * v.x + v.z * v.z).sqrt()
|
|
};
|
|
|
|
let half = speed_after(0.5);
|
|
let full = speed_after(1.0);
|
|
assert!(
|
|
half > 0.5,
|
|
"half deflection must still walk, got {half:.2} m/s"
|
|
);
|
|
assert!(
|
|
half < full * 0.85,
|
|
"half deflection must be meaningfully slower than full \
|
|
({half:.2} vs {full:.2} m/s) — it is snapping to full speed"
|
|
);
|
|
}
|
|
|
|
/// The seated body must RIDE with the car, not stay behind at the kerb.
|
|
///
|
|
/// Mount pins the driver with the sim's own `attached_to` seat, so the body
|
|
/// travels with the vehicle. The bug this guards is the old behaviour: the
|
|
/// driver's collider left standing at the spot they boarded, invisible but
|
|
/// still solid, so driving back past it later hits a wall that isn't there.
|
|
///
|
|
/// Deliberately NOT a modality-gate test. The seat pin overwrites pos/vel/yaw
|
|
/// every step, so it masks the gate on the seated body — an assertion that the
|
|
/// body stays put would pass even with the gate deleted. The gate is covered
|
|
/// where it is still observable, on a second unpinned car, in the blocks suite.
|
|
#[test]
|
|
fn the_seated_player_rides_with_the_car() {
|
|
let mut world = GameWorld::new();
|
|
world.gravity = 30.0;
|
|
let mut blocks = Blocks::new();
|
|
ground(&mut world);
|
|
let car_id = car(&mut world, &mut blocks, vec3f(0.0, 1.2, -3.0));
|
|
let me = player(&mut world, &mut blocks, vec3f(0.0, 1.0, 0.0));
|
|
for _ in 0..30 {
|
|
step(&mut world, &mut blocks, RawInput::default());
|
|
}
|
|
let mut press = RawInput::default();
|
|
press.use_pressed = true;
|
|
step(&mut world, &mut blocks, press);
|
|
assert_eq!(rig(&blocks).seat(), Seat::Driving(car_id));
|
|
|
|
let boarded_at = world.entity(me).unwrap().pos;
|
|
for _ in 0..90 {
|
|
let mut raw = RawInput::default();
|
|
raw.throttle = 1.0;
|
|
raw.move_y = 1.0;
|
|
step(&mut world, &mut blocks, raw);
|
|
}
|
|
let (body, chassis) = (
|
|
world.entity(me).unwrap().pos,
|
|
world.entity(car_id).unwrap().pos,
|
|
);
|
|
let apart =
|
|
((body.x - chassis.x).powi(2) + (body.z - chassis.z).powi(2)).sqrt();
|
|
assert!(
|
|
apart < 1.0,
|
|
"the driver came {apart:.2}m off the car they are sitting in"
|
|
);
|
|
// And they genuinely travelled — otherwise "stayed with the car" would
|
|
// also pass for a car that never moved.
|
|
let travelled = ((chassis.x - boarded_at.x).powi(2)
|
|
+ (chassis.z - boarded_at.z).powi(2))
|
|
.sqrt();
|
|
assert!(
|
|
travelled > 3.0,
|
|
"the car only moved {travelled:.2}m, so riding along proves nothing"
|
|
);
|
|
}
|
|
|
|
/// Reversing must actually reverse, and the brake-then-reverse contract must
|
|
/// hold: back-input decelerates a rolling car first, and only drives it
|
|
/// backward once it has stopped. Reported as "reversing barely works".
|
|
#[test]
|
|
fn reverse_engages_from_a_standstill_and_brakes_first_when_rolling() {
|
|
let drive = |throttle: f32, brake: f32, ticks: usize| -> (f32, f32) {
|
|
let mut world = GameWorld::new();
|
|
world.gravity = 30.0;
|
|
let mut blocks = Blocks::new();
|
|
ground(&mut world);
|
|
let car_id = car(&mut world, &mut blocks, vec3f(0.0, 1.2, 0.0));
|
|
player(&mut world, &mut blocks, vec3f(0.0, 1.0, 0.0));
|
|
for _ in 0..20 {
|
|
step(&mut world, &mut blocks, RawInput::default());
|
|
}
|
|
let mut press = RawInput::default();
|
|
press.use_pressed = true;
|
|
step(&mut world, &mut blocks, press);
|
|
assert_eq!(rig(&blocks).seat(), Seat::Driving(car_id));
|
|
let start_z = world.entity(car_id).unwrap().pos.z;
|
|
for _ in 0..ticks {
|
|
let mut raw = RawInput::default();
|
|
raw.throttle = throttle;
|
|
raw.brake = brake;
|
|
step(&mut world, &mut blocks, raw);
|
|
}
|
|
let e = world.entity(car_id).unwrap();
|
|
(e.pos.z - start_z, blocks.cars[0].speed)
|
|
};
|
|
|
|
// Forward is -Z, so reversing must move the car toward +Z.
|
|
let (moved, speed) = drive(-1.0, 0.0, 120);
|
|
assert!(
|
|
moved > 1.0,
|
|
"two seconds of reverse from a standstill should back the car up at \
|
|
least a metre, only moved {moved:.2} (speed {speed:.2})"
|
|
);
|
|
|
|
// The same input WITH the brake also held barely moves — which is what a
|
|
// pad mapping that sets both from one trigger produces.
|
|
let (with_brake, _) = drive(-1.0, 1.0, 120);
|
|
assert!(
|
|
with_brake < moved,
|
|
"holding brake while reversing must not out-reverse a clean reverse \
|
|
({with_brake:.2} vs {moved:.2}) — the brake force opposes it"
|
|
);
|
|
|
|
// Reverse should be slower than forward, but not uselessly so.
|
|
let (fwd, _) = drive(1.0, 0.0, 120);
|
|
let reverse_ratio = moved / fwd.abs();
|
|
assert!(
|
|
reverse_ratio > 0.2,
|
|
"reverse is only {:.0}% of forward travel — too weak to reposition",
|
|
reverse_ratio * 100.0
|
|
);
|
|
}
|