From da63829e9eba18b71789ae33f20e5d6c2540db83 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Tue, 28 Jul 2026 14:18:20 +1000 Subject: [PATCH] Outrun mode: NFSU2-style roaming 1v1 duels in cruise Two rivals now lap the RacePath in free roam. Pull alongside one at speed and the duel arms: first to gap the other by 250 m wins. Straight-line distance decides when it ends (side-street escapes are real); path order decides who won (reversing away is a loss). Rubber-banded chase, takedown-tier boost payout, R mid-duel forfeits, 8 s re-arm cooldown. Mechanics only -- no assets from anywhere near an EA disk. Smoke test arms a duel and settles the win path. Co-Authored-By: Claude Opus 5 --- README.md | 7 +++- src/game.gd | 88 +++++++++++++++++++++++++++++++++++++++++++++++++- tests/smoke.gd | 22 ++++++++++++- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 68abfdb..ee0fc77 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,12 @@ Controls: WASD/arrows drive, Shift drift, Space boost, R reset (or retry after C ## Modes -- **Cruise** — free roam with traffic. +- **Cruise** — free roam with traffic, plus two rivals lapping the circuit. + Pull alongside one at speed and it's an **Outrun** (NFSU2 rules, our code): + first to gap the other by 250 m wins. Straight-line distance decides *when* + it ends — so ducking down a side street is a real escape — but path order + decides *who* won, so reversing away from the circuit is a loss. Winning + pays takedown-tier boost. R mid-duel is a forfeit. - **Crash Mode** — 60 seconds, rack up damage dollars ramming traffic and parked cars. - **Crash Junctions** — pick a "CRASH JCT" entry in the level list: launch into scripted cross-traffic convoys at a real intersection, then Aftertouch-steer your wreck (arrows) diff --git a/src/game.gd b/src/game.gd index d2aa75f..d8579a6 100644 --- a/src/game.gd +++ b/src/game.gd @@ -1,7 +1,8 @@ class_name Game extends Node3D ## Loads the chosen level, spawns cars, runs the chosen mode: -## cruise -- free roam with traffic +## cruise -- free roam with traffic + roaming rivals (drive up close to one +## at speed to trigger an OUTRUN: first to gap the other wins) ## crash -- 60s damage-dollar rampage (Crash Mode v0) ## race -- 3 laps vs rivals on the level's RacePath, rubber-banded ## Takedowns refill the boost meter and grow it (up to 4x, per Burnout 3). @@ -17,10 +18,19 @@ const RIVAL_COUNT := 3 const TRAFFIC_COUNT := {"cruise": 6, "crash": 10, "race": 4} const JCT_RUN_TIME := 30.0 # crash into something before this or the run ends const JCT_SETTLE := 9.0 # aftertouch/crashbreaker window after the crash +const ROAM_RIVALS := 2 # challengers circling the RacePath in cruise +const OUTRUN_GAP := 250.0 # escape distance that decides an outrun (NFSU2 rule) +const OUTRUN_TIME := 90.0 +const OUTRUN_START_D := 12.0 # pull alongside within this, at speed, to trigger var jstate := "" # junction: "run" -> "settle" -> "done" var breaker_used := false +var outrun_rival: Rival = null +var outrun_t := 0.0 +var outrun_cd := 0.0 # cooldown so a finished outrun doesn't instantly re-arm +var outruns_won := 0 + var car: Car var cam: Camera3D var race_path: Path3D @@ -101,6 +111,20 @@ func _ready() -> void: prev_off[r] = off r.wreck_started.connect(_rival_wrecked.bind(r)) rivals.append(r) + elif mode == "cruise" and race_path: + # roaming challengers: they lap the circuit until you pick a fight + var pool := Registry.cars().values() + var L := race_path.curve.get_baked_length() + for i in ROAM_RIVALS: + var r: Rival = (load("res://src/rival.tscn") as PackedScene).instantiate() + r.stats = load(pool[randi() % pool.size()]) + r.path = race_path + var off := L * (i + 1) / float(ROAM_RIVALS + 1) + r.transform = _grid_xform(off) + r.progress = off + add_child(r) + r.wreck_started.connect(_rival_wrecked.bind(r)) + rivals.append(r) car.spawn_xform = car.global_transform if mode == "junction": @@ -265,6 +289,8 @@ func _physics_process(delta: float) -> void: _msg("NEAR MISS", 32) if mode == "race" and race_path and not over: _race_tick() + if mode == "cruise" and race_path: + _outrun_tick(delta) if mode == "junction" and jstate == "settle": if Input.is_action_just_pressed("boost") and not breaker_used: _crashbreaker() @@ -277,6 +303,66 @@ func _physics_process(delta: float) -> void: + fwd.normalized() * Input.get_axis("brake", "throttle") car.apply_central_force(push * car.mass * 9.0) +func _outrun_tick(delta: float) -> void: + ## NFSU2-style impromptu 1v1: pull alongside a roaming rival at speed and it's + ## on. Straight-line distance decides WHEN it ends (so ducking down a side + ## street is a real escape); path order decides WHO won (so reversing away + ## from the circuit is a loss, not a win). + outrun_cd = maxf(outrun_cd - delta, 0.0) + var L := race_path.curve.get_baked_length() + if outrun_rival == null: + if outrun_cd > 0.0 or car.wrecked: + return + for r in rivals: + if not r.wrecked and car.linear_velocity.length() > 8.0 \ + and car.global_position.distance_to(r.global_position) < OUTRUN_START_D: + outrun_rival = r + outrun_t = OUTRUN_TIME + laps[car] = 0 + laps[r] = 0 + prev_off[car] = _nearest_off(car.global_position, r.progress, L) + prev_off[r] = r.progress + _msg("OUTRUN!\ngap them by %dm" % int(OUTRUN_GAP), 44) + return + return + + var r := outrun_rival + outrun_t -= delta + if Input.is_action_just_pressed("reset"): # R teleports home: that's a forfeit + _outrun_end(false, "bailed") + return + prev_off[car] = _lap_step(car, _nearest_off(car.global_position, prev_off[car], L), L) + prev_off[r] = _lap_step(r, r.progress, L) + var my_total: float = laps[car] * L + prev_off[car] + var rt: float = laps[r] * L + prev_off[r] + var ahead := my_total >= rt + # rubber-band: the rival hunts when losing, coasts a touch when winning + r.power_scale = clampf(1.0 + (my_total - rt) / 250.0, 0.85, 1.4) + var d := car.global_position.distance_to(r.global_position) + info_label.text = "OUTRUN %s %dm / %dm %ds" % ["LEAD" if ahead else "CHASE", int(d), int(OUTRUN_GAP), ceili(outrun_t)] + if car.wrecked: + _outrun_end(false, "wrecked") + elif r.wrecked and d < 60.0: + _outrun_end(true, "rival wrecked") + elif d > OUTRUN_GAP: + _outrun_end(ahead, "") + elif outrun_t <= 0.0: + _outrun_end(false, "time up") + +func _outrun_end(won: bool, why: String) -> void: + outrun_rival.power_scale = 1.0 + outrun_rival = null + outrun_cd = 8.0 + info_label.text = "" + var tail := ("\n(%s)" % why) if why != "" else "" + if won: + outruns_won += 1 + car.boost_max = minf(car.boost_max + 0.5, Car.BOOST_CAP) # takedown-tier reward + car.boost = car.boost_max + _msg("OUTRUN WON! x%d%s" % [outruns_won, tail], 52) + else: + _msg("OUTRUN LOST%s" % tail, 44) + func _race_tick() -> void: var L := race_path.curve.get_baked_length() prev_off[car] = _lap_step(car, _nearest_off(car.global_position, prev_off[car], L), L) diff --git a/tests/smoke.gd b/tests/smoke.gd index 3ef2fc4..e656703 100644 --- a/tests/smoke.gd +++ b/tests/smoke.gd @@ -85,6 +85,26 @@ func _run() -> void: Game.junction = {} game.free() + # outrun: roaming rivals exist in cruise; pulling alongside at speed arms the + # duel, and the win path pays out and clears state + game = await _boot("cruise") + assert(game.rivals.size() == Game.ROAM_RIVALS, "no roaming rivals in cruise") + var rr: Rival = game.rivals[0] + await physics_frame + game.car.global_position = rr.global_position + Vector3(3, 0.3, 0) + game.car.linear_velocity = Vector3(0, 0, -12) # above the 8 m/s arming speed + for i in 5: + await physics_frame + if game.outrun_rival != null: + break + assert(game.outrun_rival == rr, "outrun didn't arm on proximity") + var bm0: float = game.car.boost_max + game._outrun_end(true, "test") + assert(game.outrun_rival == null and game.outruns_won == 1, "outrun win didn't settle") + assert(game.car.boost_max > bm0, "outrun win paid no boost") + assert(game.outrun_cd > 0.0, "no re-arm cooldown after outrun") + game.free() + # race: rivals spawn and make progress along the path game = await _boot("race") assert(game.rivals.size() == Game.RIVAL_COUNT, "rivals missing") @@ -98,7 +118,7 @@ func _run() -> void: assert(moved, "no rival is driving") game.free() - print("smoke ok: %d cars, %d levels | cruise %.1f m | crash $%d | junction $%d | race AI driving" % + print("smoke ok: %d cars, %d levels | cruise %.1f m | crash $%d | junction $%d | outrun arms+pays | race AI driving" % [cars.size(), levels.size(), dist, int(crash_score), int(jct_score)]) quit(0)