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 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-28 14:18:20 +10:00
parent 82dae637a1
commit da63829e9e
3 changed files with 114 additions and 3 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)