Three new modes, all our own code: - Pursuit (Simpsons Hit & Run's meter): smashing in cruise fills heat, at full the police hunt you. Open 250 m for 6 s to escape, get pinned 4 s to be busted. Heat decays, so picking a fight is a choice not a timer. - Drag (NFSU2's): the gearbox IS the event. The builder finds each level's longest genuinely straight run of wide road (442-628 m across the five OSM levels) and drops DragStart/DragEnd, so no level needs hand-authored coords. - Derby: five opponents in the carpark, last one running. Reuses the pursuit AI, each hunting a different car so it's a brawl not a mob. Crash damage now shows: total control-point travel drives paint dulling, roughness and a scratch layer, so scuffs and crumple can never disagree. Junction generator fixes, all found by measurement rather than inspection: - streams[:6] truncated after street A's ways, so junctions where A yielded 3+ usable ways shipped with ZERO cross traffic. Interleaved before capping; every event now has 2-4 crossing streams. - RADIUS 170 -> 95 spread the convoy over a 340 m window, so you threaded the intersection through a 20 m gap. Margaret x Edward was unwinnable: 27 convoy cars alive, zero collisions, $0. - Launch points now walk the street to exactly SPAWN_DIST and pick the busiest shared node, and _clear_spawn() unblocks a buried start. Albert x Adelaide went from $0 (drove 1 m into a building) to $91k. - build_all_junctions.sh records the street pairs, which existed nowhere before. Fixes from an adversarial review (23 confirmed findings), the worst being: - Derby could only ever be LOST: wrecked opponents un-wrecked 2.2 s later via Car._recover, so "last one running" was unreachable and the kill bounty could be farmed to the boost cap off one revived car. They now hold their wreck. - Drag reported WON to a beaten player: the AI's lookahead wrapped on a 2-point path, U-turning before the line so its distance-to-finish grew again. - The scratch layer used BLEND_MODE_MIX, whose mask defaults to white -- it replaced the paint entirely rather than scuffing it. Now MUL. - Drag on a level with no strip left the car at the origin, half-buried, unable to end. Cop spawns used an unflattened basis. Stale "WANTED" HUD text. Test harness: smoke covers all seven modes, and failures now exit instead of hanging -- a failed assert in a headless SceneTree halts the script but leaves the tree spinning, which cost 53 minutes of wall clock to notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
3.6 KiB
GDScript
105 lines
3.6 KiB
GDScript
extends SceneTree
|
|
## Measure what each crash junction is actually worth, then write per-event
|
|
## medal targets back into levels/<id>/junctions.json.
|
|
##
|
|
## Godot --headless --path . --script tools/tune_junctions.gd -- runs=12
|
|
##
|
|
## Every junction shipped with the same 15/35/60k guess, which is meaningless:
|
|
## a six-lane approach with four convoy streams pays several times what a
|
|
## carpark aisle does. This drives each event headless with a spread of launch
|
|
## speeds and steering biases, takes the damage distribution, and sets
|
|
## bronze/silver/gold at the 35th/65th/88th percentile of what was achieved.
|
|
##
|
|
## Percentiles, not max: gold should be a good run, not a freak one. Runs are
|
|
## seeded per (event, attempt) so the numbers reproduce.
|
|
|
|
const SETTLE_FRAMES := 1500
|
|
|
|
func _initialize() -> void:
|
|
_run()
|
|
|
|
func _run() -> void:
|
|
var runs := 12
|
|
for a in OS.get_cmdline_user_args():
|
|
if a.begins_with("runs="):
|
|
runs = int(a.split("=")[1])
|
|
|
|
var by_level := {}
|
|
for j in Registry.junctions():
|
|
by_level.get_or_add(j.level_id, []).append(j)
|
|
|
|
for level_id in by_level:
|
|
var path := "res://levels/%s/junctions.json" % level_id
|
|
var events: Array = JSON.parse_string(FileAccess.get_file_as_string(path))
|
|
for j in by_level[level_id]:
|
|
var scores: Array[float] = []
|
|
for attempt in runs:
|
|
scores.append(await _play(j.data, attempt))
|
|
scores.sort()
|
|
var pick := func(p: float) -> int:
|
|
var v: float = scores[clampi(int(round(p * (scores.size() - 1))), 0, scores.size() - 1)]
|
|
return int(round(v / 500.0)) * 500 # tidy numbers, not $47,213
|
|
var targets := [pick.call(0.35), pick.call(0.65), pick.call(0.88)]
|
|
# guarantee a real gap between medals even on a flat distribution
|
|
targets[0] = maxi(targets[0], 2000)
|
|
targets[1] = maxi(targets[1], targets[0] + 2500)
|
|
targets[2] = maxi(targets[2], targets[1] + 4000)
|
|
var duds := 0
|
|
for s in scores:
|
|
if s <= 0.0:
|
|
duds += 1
|
|
if duds * 2 >= scores.size():
|
|
push_warning("%s: %d/%d runs scored nothing -- event may be unwinnable"
|
|
% [j.name, duds, scores.size()])
|
|
print(" !! %s scored nothing in %d/%d runs" % [j.name, duds, scores.size()])
|
|
for ev in events:
|
|
if ev.get("name") == j.name:
|
|
ev["targets"] = targets
|
|
print("%-18s %-24s min $%d med $%d max $%d -> %s"
|
|
% [level_id, j.name, int(scores[0]), int(scores[scores.size() / 2]),
|
|
int(scores[-1]), str(targets)])
|
|
var f := FileAccess.open(path, FileAccess.WRITE)
|
|
f.store_string(JSON.stringify(events, " ") + "\n")
|
|
f.close()
|
|
print("tuned: wrote targets for %d levels" % by_level.size())
|
|
quit(0)
|
|
|
|
func _play(data: Dictionary, attempt: int) -> float:
|
|
seed(hash(str(data.get("name", "")) + str(attempt)))
|
|
Game.car_path = Registry.cars()["kingswood_hz"]
|
|
Game.level_path = Registry.levels()[_level_of(data)]
|
|
Game.mode = "junction"
|
|
Game.junction = data
|
|
var game: Game = (load("res://src/game.tscn") as PackedScene).instantiate()
|
|
root.add_child(game)
|
|
await process_frame
|
|
# vary the launch so the spread reflects skill, not one scripted line
|
|
var lean := (attempt % 5 - 2) * 0.16
|
|
var boosty := attempt % 3 == 0
|
|
Input.action_press("throttle")
|
|
if boosty:
|
|
Input.action_press("boost")
|
|
var fired := false
|
|
for i in SETTLE_FRAMES:
|
|
await physics_frame
|
|
if i < 90:
|
|
game.car.steer_in = lean
|
|
if game.jstate == "settle" and not fired:
|
|
Input.action_release("throttle")
|
|
Input.action_press("boost")
|
|
fired = true
|
|
if game.over:
|
|
break
|
|
Input.action_release("throttle")
|
|
Input.action_release("boost")
|
|
var s: float = game.score
|
|
game.free()
|
|
await process_frame
|
|
return s
|
|
|
|
func _level_of(data: Dictionary) -> String:
|
|
for j in Registry.junctions():
|
|
if j.data == data:
|
|
return j.level_id
|
|
return ""
|