diff --git a/scripts/audio.gd b/scripts/audio.gd index 6698be5..20821d9 100644 --- a/scripts/audio.gd +++ b/scripts/audio.gd @@ -17,6 +17,7 @@ signal ready_for_play() var sounds: Dictionary = {} var music_on := true var loaded := false +var music_loaded := false var clacks := 0 ## Zähler, damit sich das testen lässt var last_played := "" ## zuletzt gestarteter Klang @@ -63,28 +64,32 @@ func _exit_tree() -> void: ## Läuft im Thread: nur Rechnen, keine Godot-Knoten anfassen. func _build_async() -> void: - var pcm: Dictionary = Sfx.build_all() - pcm["music"] = Sfx.build_music() - call_deferred("_finish_build", pcm) + call_deferred("_publish_sfx", Sfx.build_all()) + call_deferred("_publish_music", Sfx.build_music()) -func _finish_build(pcm: Dictionary) -> void: - if _thread != null: - _thread.wait_to_finish() - _thread = null +## Die kurzen Klänge sind schnell fertig - sie gehen zuerst live. +func _publish_sfx(pcm: Dictionary) -> void: for name in pcm.keys(): - var loop: bool = name == "music" or LOOPS.has(name) - sounds[name] = Synth.to_stream(pcm[name], loop) - _music.stream = sounds["music"] - _music.volume_db = MUSIC_DB if music_on else -80.0 - _music.play() + sounds[name] = Synth.to_stream(pcm[name], LOOPS.has(name)) _rumble.stream = sounds["rumble"] _rumble.play() _squeal.stream = sounds["squeal"] _squeal.play() loaded = true - print("[audio] %d Klänge in %d ms erzeugt" % [sounds.size(), Time.get_ticks_msec() - _t0]) + print("[audio] %d Klänge in %d ms" % [sounds.size(), Time.get_ticks_msec() - _t0]) ready_for_play.emit() +func _publish_music(pcm: PackedByteArray) -> void: + if _thread != null: + _thread.wait_to_finish() + _thread = null + sounds["music"] = Synth.to_stream(pcm, true) + _music.stream = sounds["music"] + _music.volume_db = MUSIC_DB if music_on else -80.0 + _music.play() + music_loaded = true + print("[audio] Musik in %d ms" % (Time.get_ticks_msec() - _t0)) + func toggle_music() -> bool: music_on = not music_on _music.volume_db = MUSIC_DB if music_on else -80.0 diff --git a/scripts/camera_rig.gd b/scripts/camera_rig.gd index 88c9342..fbce675 100644 --- a/scripts/camera_rig.gd +++ b/scripts/camera_rig.gd @@ -11,6 +11,7 @@ var distance := 46.0 var _cam: Camera3D var _focus := Vector3.ZERO +var blocked := false ## zuletzt an einem Hindernis abgefangen func _ready() -> void: _cam = Camera3D.new() @@ -51,11 +52,26 @@ func _process(delta: float) -> void: if target: _focus = _focus.lerp(target.global_position, clampf(delta * 5.0, 0.0, 1.0)) + blocked = false position = _focus var dir := Vector3(0, 0, 1).rotated(Vector3.RIGHT, pitch).rotated(Vector3.UP, yaw) - _cam.position = dir * distance + var wanted: Vector3 = dir * distance + _cam.position = _clip_to_view(wanted) _cam.look_at(global_position + Vector3(0, 2.5, 0), Vector3.UP) +## Zieht die Kamera vor ein Hindernis, statt hindurchzuschauen. +func _clip_to_view(local_target: Vector3) -> Vector3: + var from: Vector3 = global_position + Vector3(0, 2.5, 0) + var to: Vector3 = global_position + local_target + var space := get_world_3d().direct_space_state + var q := PhysicsRayQueryParameters3D.create(from, to, 2) + var hit := space.intersect_ray(q) + if hit.is_empty(): + return local_target + var p: Vector3 = hit["position"] + (from - hit["position"]).normalized() * 0.7 + blocked = true + return p - global_position + func snap_to(p: Vector3) -> void: _focus = p position = p diff --git a/scripts/deco.gd b/scripts/deco.gd index 3532889..206e3ad 100644 --- a/scripts/deco.gd +++ b/scripts/deco.gd @@ -92,4 +92,15 @@ static func house(wall: Color, roof: Color, w: float, d: float, h: float, chimne if chimney: n.add_child(_mi(box(Vector3(w * 0.16, h * 0.7, w * 0.16)), roof.darkened(0.3), Vector3(w * 0.25, h + h * 0.45, 0))) + # Kollision nur fuer die Aussenkamera, damit sie nicht durch Waende schaut + var body := StaticBody3D.new() + body.collision_layer = 2 + body.collision_mask = 0 + var shape := CollisionShape3D.new() + var bs := BoxShape3D.new() + bs.size = Vector3(w * 1.14, h * 1.6, d * 1.06) + shape.shape = bs + shape.position = Vector3(0, h * 0.8, 0) + body.add_child(shape) + n.add_child(body) return n diff --git a/scripts/game.gd b/scripts/game.gd index 963f70a..95cced6 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -22,18 +22,24 @@ const MenuScript = preload("res://scripts/menu.gd") enum Mode { MENU, SOLO, HOST, CLIENT } -const TRACK_POINTS := [ - Vector2(-139, -77), Vector2(-53, -101), Vector2(38, -86), Vector2(110, -48), - Vector2(144, 14), Vector2(101, 77), Vector2(14, 96), Vector2(-62, 77), - Vector2(-101, 34), Vector2(-154, -14), -] +## Zwei Weichenpunkte, drei Abschnitte: Talstrecke und Bergstrecke fuehren +## beide von J1 nach J2, die Heimstrecke zurueck. +const J1 := Vector2(-139, -77) +const J2 := Vector2(144, 14) +const T1 := Vector2(101, -87) ## Fahrtrichtung an J1 +const T2 := Vector2(-9, 125) ## Fahrtrichtung an J2 +const SEG_TAL := [J1, Vector2(-53, -101), Vector2(38, -86), Vector2(110, -48), J2] +const SEG_BERG := [J1, Vector2(-85, -38), Vector2(-20, -14), Vector2(58, -12), Vector2(112, -24), J2] +const SEG_HEIM := [J2, Vector2(101, 77), Vector2(14, 96), Vector2(-62, 77), Vector2(-101, 34), Vector2(-154, -14), J1] +## name, abschnitt, anteil, produziert, akzeptiert, seite, wand, dach, schornstein const STATION_DEFS := [ - ["Waldkirch", 0.04, Cargo.Type.WOOD, [Cargo.Type.GRAIN], 1.0, "c9a06a", "8a4b2f", false], - ["Kohlberg", 0.22, Cargo.Type.COAL, [], -1.0, "6f6a63", "40393a", true], - ["Eisenwerk", 0.40, -1, [Cargo.Type.COAL, Cargo.Type.WOOD], 1.0, "9aa0a6", "55606b", true], - ["Grüntal", 0.60, Cargo.Type.GRAIN, [], -1.0, "e8ddc4", "b4562f", false], - ["Ahornstadt", 0.80, -1, [Cargo.Type.GRAIN, Cargo.Type.WOOD], 1.0, "f0e6d2", "a83b30", true], + ["Waldkirch", 0, 0.10, Cargo.Type.WOOD, [Cargo.Type.GRAIN], 1.0, "c9a06a", "8a4b2f", false], + ["Kohlberg", 0, 0.55, Cargo.Type.COAL, [], -1.0, "6f6a63", "40393a", true], + ["Eisenwerk", 0, 0.88, -1, [Cargo.Type.COAL, Cargo.Type.WOOD], 1.0, "9aa0a6", "55606b", true], + ["Grüntal", 2, 0.33, Cargo.Type.GRAIN, [], -1.0, "e8ddc4", "b4562f", false], + ["Ahornstadt", 2, 0.67, -1, [Cargo.Type.GRAIN, Cargo.Type.WOOD], 1.0, "f0e6d2", "a83b30", true], + ["Bergblick", 1, 0.50, -1, [], -1.0, "e6dcc2", "5c7a4e", false], ] const WORLD_SEED := 20260828 @@ -42,7 +48,21 @@ const BOARD_INTERVAL := 0.45 const LOAD_INTERVAL := 0.35 const DRINK_TIP := 14 const START_MONEY := 1500 -const POSE_HZ := 15.0 +const POSE_HZ := 20.0 +const SAVE_PATH := "user://save.json" +const AUTOSAVE_SEC := 20.0 + +## Meilensteine. "kind" sagt, welcher Zähler zählt. +const GOALS := [ + {"id": "tickets10", "text": "10 Tickets kontrollieren", "kind": "tickets", "value": 10, "reward": 250}, + {"id": "cargo30", "text": "30 Einheiten Fracht liefern", "kind": "cargo", "value": 30, "reward": 400}, + {"id": "dining", "text": "Einen Speisewagen anhängen", "kind": "dining", "value": 1, "reward": 300}, + {"id": "berg", "text": "Bergblick anfahren", "kind": "visit", "value": 1, "reward": 500}, + {"id": "rep15", "text": "Ansehen 15 erreichen", "kind": "rep", "value": 15, "reward": 800}, + {"id": "tickets60", "text": "60 Tickets kontrollieren", "kind": "tickets", "value": 60, "reward": 900}, + {"id": "rep40", "text": "Ansehen 40 erreichen", "kind": "rep", "value": 40, "reward": 1500}, + {"id": "final", "text": "Elektro-Express mit 5 Wagen und Ansehen 60", "kind": "final", "value": 60, "reward": 5000}, +] const STATE_HZ := 4.0 var mode := Mode.MENU @@ -53,6 +73,13 @@ var money := START_MONEY var reputation := 0 var upgrades: Dictionary = {} var delivered_units := 0 +var tickets_checked := 0 +var visited: Dictionary = {} ## Bahnhofsname -> true +var goals_done: Dictionary = {} ## Ziel-ID -> true +var won := false +var my_name := "Crew" +var _autosave := AUTOSAVE_SEC +var _load_on_begin := false var track: Node3D var world: Node3D @@ -83,6 +110,7 @@ func _ready() -> void: menu = MenuScript.new() add_child(menu) menu.solo_chosen.connect(_start_solo) + menu.continue_chosen.connect(_start_continue) menu.host_chosen.connect(_start_host) menu.join_chosen.connect(_start_join) multiplayer.peer_connected.connect(_on_peer_connected) @@ -104,6 +132,10 @@ func _start_solo() -> void: my_id = 1 _begin() +func _start_continue() -> void: + _load_on_begin = true + _start_solo() + func _start_host(port: int) -> void: var peer := ENetMultiplayerPeer.new() var err := peer.create_server(port, 8) @@ -140,6 +172,7 @@ func _on_server_gone() -> void: hud.toast("Verbindung zum Host verloren.", Color("ff9a8a")) func _begin() -> void: + my_name = menu.player_name() _rng.seed = WORLD_SEED _build_environment() _build_track() @@ -152,10 +185,18 @@ func _begin() -> void: _build_ui() audio = AudioScript.new() add_child(audio) - players[my_id] = {"s": player.s, "x": player.x, "yaw": 0.0, "carrying": 0, "avatar": null} + players[my_id] = _new_player_entry(my_name) menu.hide_menu() player.capture_mouse(true) started = true + if _load_on_begin: + load_game() + if mode == Mode.CLIENT: + rpc_id(1, "net_hello", my_name) + +func _new_player_entry(pname: String) -> Dictionary: + return {"s": 0.0, "x": 0.0, "yaw": 0.0, "carrying": 0, "avatar": null, + "name": pname, "tpos": Vector3.ZERO, "tyaw": 0.0} # --- Aufbau --------------------------------------------------------------- @@ -193,22 +234,26 @@ func _build_environment() -> void: func _build_track() -> void: track = TrackScript.new() add_child(track) - var pts := PackedVector2Array() - for p in TRACK_POINTS: - pts.append(p) - track.build(pts) + var tal: int = track.add_segment("Talstrecke", SEG_TAL, T1, T2) + var berg: int = track.add_segment("Bergstrecke", SEG_BERG, T1, T2) + var heim: int = track.add_segment("Heimstrecke", SEG_HEIM, T2, T1) + track.connect_segments(heim, tal) # Nachfolger 0 = links + track.connect_segments(heim, berg) # Nachfolger 1 = rechts + track.connect_segments(tal, heim) + track.connect_segments(berg, heim) + track.finish() func _build_stations() -> void: - var l: float = track.length() for d in STATION_DEFS: var st := StationScript.new() add_child(st) var acc: Array[int] = [] - for t in d[3]: + for t in d[4]: acc.append(int(t)) - st.setup(String(d[0]), l * float(d[1]), int(d[2]), acc) - st.build_visuals(track.sample(st.progress), float(d[4]), - Color(String(d[5])), Color(String(d[6])), bool(d[7])) + var seg_id := int(d[1]) + st.setup(String(d[0]), seg_id, track.seg_length(seg_id) * float(d[2]), int(d[3]), acc) + st.build_visuals(track.sample(seg_id, st.progress), float(d[5]), + Color(String(d[6])), Color(String(d[7])), bool(d[8])) if st.produces >= 0: st.stock = 8.0 stations.append(st) @@ -223,8 +268,9 @@ func _build_train() -> void: ## Stellt den Zug mittig an einen Bahnsteig. func _park_at(st) -> void: - train.progress = fposmod(st.progress + train.total_length() * 0.5, track.length()) - train.apply_snapshot(train.progress, 0.0) + train.speed = 0.0 + train.place_head(st.seg, st.progress + train.total_length() * 0.5) + train._update_dock(0.0) func _build_player() -> void: player = PlayerScript.new() @@ -234,8 +280,8 @@ func _build_player() -> void: cam_rig = CameraRig.new() add_child(cam_rig) - cam_rig.target = train.followers[0] - cam_rig.snap_to(track.sample(train.progress).origin) + cam_rig.target = train.cars[0] + cam_rig.snap_to(train.cars[0].global_position) player.camera.current = true func _build_ui() -> void: @@ -257,6 +303,11 @@ func _process(delta: float) -> void: _spawn_passengers(delta) _update_passengers(delta) _handle_station_stop(delta) + _track_visits() + _autosave -= delta + if _autosave <= 0.0: + _autosave = AUTOSAVE_SEC + save_game() _handle_hold_actions(delta) _update_audio(delta) _sync_out(delta) @@ -269,22 +320,38 @@ func _physics_process(delta: float) -> void: var driving: bool = driver_id == my_id and not player.input_locked var th := 0.0 var br := false + var sw: int = train.switch_choice if driving: th = 1.0 if Input.is_key_pressed(KEY_W) else 0.0 br = Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_SPACE) + if Input.is_key_pressed(KEY_A): + sw = 0 + elif Input.is_key_pressed(KEY_D): + sw = 1 if is_server(): if driver_id == my_id: train.throttle = th train.braking = br + train.switch_choice = sw train.drive(delta) - elif driving: - rpc_id(1, "net_controls", th, br) + else: + if driving: + rpc_id(1, "net_controls", th, br, sw) + train.client_tick(delta) + +func _notification(what: int) -> void: + if what == NOTIFICATION_WM_CLOSE_REQUEST and started and is_server(): + save_game() func _unhandled_input(event: InputEvent) -> void: if not started: return if event is InputEventKey and event.pressed and not event.echo: match event.keycode: + KEY_F5: + if is_server(): + save_game() + hud.toast("Gespeichert", Color("8fd8ff")) KEY_H: if driver_id == my_id: request("whistle") @@ -358,13 +425,13 @@ func _sync_out(delta: float) -> void: players[my_id]["x"] = player.x players[my_id]["yaw"] = player.yaw rpc("net_poses", _pose_payload()) + rpc("net_train", train.seg, train.progress, train.speed, train.switch_choice, train.history) else: rpc_id(1, "net_pose", player.s, player.x, player.yaw) if is_server(): _state_timer -= delta if _state_timer <= 0.0: _state_timer = 1.0 / STATE_HZ - rpc("net_train", train.progress, train.speed) rpc("net_state", _state_payload()) rpc("net_passengers", _passenger_payload()) @@ -379,10 +446,15 @@ func _state_payload() -> Dictionary: var stock: Array = [] for st in stations: stock.append(st.stock) + var names := {} + for id in players.keys(): + names[int(id)] = String(players[id]["name"]) return { "money": money, "rep": reputation, "upg": upgrades.keys(), "driver": driver_id, "cargo": train.cargo.duplicate(), "stock": stock, "loco": train.loco_id, "consist": train.consist.duplicate(), + "names": names, "goals": goals_done.keys(), "won": won, + "tickets": tickets_checked, "delivered": delivered_units, } func _passenger_payload() -> Array: @@ -402,7 +474,7 @@ func _passenger_payload() -> Array: func net_pose(s: float, x: float, yaw: float) -> void: var id := multiplayer.get_remote_sender_id() if not players.has(id): - players[id] = {"s": s, "x": x, "yaw": yaw, "carrying": 0, "avatar": null} + players[id] = _new_player_entry("Crew %d" % id) players[id]["s"] = s players[id]["x"] = x players[id]["yaw"] = yaw @@ -414,7 +486,7 @@ func net_poses(list: Array) -> void: var id := int(e[0]) seen[id] = true if not players.has(id): - players[id] = {"s": 0.0, "x": 0.0, "yaw": 0.0, "carrying": 0, "avatar": null} + players[id] = _new_player_entry("Crew %d" % id) if id != my_id: players[id]["s"] = float(e[1]) players[id]["x"] = float(e[2]) @@ -427,8 +499,8 @@ func net_poses(list: Array) -> void: _drop_player(int(id)) @rpc("authority", "call_remote", "unreliable_ordered") -func net_train(p: float, v: float) -> void: - train.apply_snapshot(p, v) +func net_train(seg_id: int, p: float, v: float, sw: int, hist: Array) -> void: + train.apply_snapshot(seg_id, p, v, sw, hist) @rpc("authority", "call_remote", "reliable") func net_state(d: Dictionary) -> void: @@ -438,6 +510,16 @@ func net_state(d: Dictionary) -> void: for k in d["upg"]: upgrades[String(k)] = true driver_id = int(d["driver"]) + for id in d["names"].keys(): + if not players.has(int(id)): + players[int(id)] = _new_player_entry(String(d["names"][id])) + players[int(id)]["name"] = String(d["names"][id]) + goals_done.clear() + for g in d["goals"]: + goals_done[String(g)] = true + won = bool(d["won"]) + tickets_checked = int(d["tickets"]) + delivered_units = int(d["delivered"]) if driver_id != my_id and player.mode == PlayerScript.Mode.DRIVE: player.mode = PlayerScript.Mode.WALK elif driver_id == my_id: @@ -498,13 +580,25 @@ func net_sfx(name: String, at_loco: bool, db: float) -> void: _play_sfx(name, at_loco, db) @rpc("any_peer", "call_remote", "unreliable_ordered") -func net_controls(th: float, br: bool) -> void: +func net_controls(th: float, br: bool, sw: int) -> void: if not is_server(): return if multiplayer.get_remote_sender_id() != driver_id: return train.throttle = th train.braking = br + train.switch_choice = sw + +@rpc("any_peer", "call_remote", "reliable") +func net_hello(pname: String) -> void: + if not is_server(): + return + var id := multiplayer.get_remote_sender_id() + if not players.has(id): + players[id] = _new_player_entry(pname) + players[id]["name"] = pname + _broadcast_toast("%s ist an Bord" % pname, Color("8fd8ff")) + _push_state() @rpc("any_peer", "call_remote", "reliable") func net_request(action: String, a: String, b: int, c: int) -> void: @@ -549,17 +643,17 @@ func _make_passenger(pid: int): func _on_peer_connected(id: int) -> void: if not is_server() or not started: return - players[id] = {"s": 0.0, "x": 0.0, "yaw": 0.0, "carrying": 0, "avatar": null} + players[id] = _new_player_entry("Crew %d" % id) rpc_id(id, "net_state", _state_payload()) rpc_id(id, "net_passengers", _passenger_payload()) - hud.toast("Spieler %d ist an Bord" % id, Color("8fd8ff")) func _on_peer_disconnected(id: int) -> void: + var pname: String = String(players.get(id, {}).get("name", "Crew %d" % id)) _drop_player(id) if driver_id == id: driver_id = 0 if hud: - hud.toast("Spieler %d hat den Zug verlassen" % id, Color("ffd978")) + hud.toast("%s hat den Zug verlassen" % pname, Color("ffd978")) func _drop_player(id: int) -> void: if id == my_id or not players.has(id): @@ -576,16 +670,22 @@ func _update_avatars() -> void: var e: Dictionary = players[id] if e["avatar"] == null: var av := AvatarScript.new() - av.build(int(id), "Crew %d" % int(id)) + av.build(int(id), String(e["name"])) add_child(av) e["avatar"] = av var av2 = e["avatar"] var loc: Dictionary = train.locate(float(e["s"])) var xf: Transform3D = train.car_transform(int(loc["index"])) - av2.global_position = xf * Vector3(float(e["x"]), 1.10, float(loc["local_z"])) - av2.global_rotation = Vector3(0, xf.basis.get_euler().y + float(e["yaw"]), 0) + # Zielpose im Wagenraum; Bewegung wird geglättet, die Wagenlage nicht + var tp: Vector3 = Vector3(float(e["x"]), 1.10, float(loc["local_z"])) + var ty: float = float(e["yaw"]) + var k: float = clampf(get_process_delta_time() * 14.0, 0.0, 1.0) + e["tpos"] = (e["tpos"] as Vector3).lerp(tp, k) if e["tpos"] != Vector3.ZERO else tp + e["tyaw"] = lerp_angle(float(e["tyaw"]), ty, k) + av2.global_position = xf * (e["tpos"] as Vector3) + av2.global_rotation = Vector3(0, xf.basis.get_euler().y + float(e["tyaw"]), 0) av2.set_carrying(int(e["carrying"])) - av2.set_label("Crew %d%s" % [int(id), " (Führerstand)" if int(id) == driver_id else ""]) + av2.set_label("%s%s" % [String(e["name"]), " (Führerstand)" if int(id) == driver_id else ""]) # --- Fahrgäste ----------------------------------------------------------- @@ -613,13 +713,12 @@ func spawn_passenger(idx: int, forced_dest := -1) -> bool: p.fare = _fare(idx, dest) var spot: Vector3 = st.waiting_spots[st.waiting.size()] p.global_position = spot - p.global_rotation = Vector3(0, track.sample(st.progress).basis.get_euler().y + PI * 0.5 * st.side, 0) + p.global_rotation = Vector3(0, track.sample(st.seg, st.progress).basis.get_euler().y + PI * 0.5 * st.side, 0) st.waiting.append(p) return true func _fare(a: int, b: int) -> int: - var l: float = track.length() - var d: float = fposmod(stations[b].progress - stations[a].progress, l) + var d: float = stations[a].global_position.distance_to(stations[b].global_position) * 1.6 var bonus := 1.0 for c in train.cars: if c.id == "saloon": @@ -749,6 +848,7 @@ func _do_request(peer: int, action: String, a: String, b: int, c: int) -> void: "buy_loco": _srv_buy_loco(peer, a) "buy_car": _srv_buy_car(peer, a) "sell_car": _srv_sell_car(peer, b) + "sell_loco": _srv_sell_loco(peer) "buy_upgrade": _srv_buy_upgrade(peer, a) "whistle": _srv_whistle(peer) @@ -796,7 +896,7 @@ func _carrying(peer: int) -> int: func _set_carrying(peer: int, n: int) -> void: if not players.has(peer): - players[peer] = {"s": 0.0, "x": 0.0, "yaw": 0.0, "carrying": 0, "avatar": null} + players[peer] = _new_player_entry("Crew %d" % peer) players[peer]["carrying"] = n if peer == my_id: player.carrying = n @@ -836,6 +936,7 @@ func _srv_use_seat(peer: int, car_index: int, seat_index: int) -> void: func _check_ticket(p, peer: int) -> void: p.ticket_checked = true + tickets_checked += 1 money += p.fare _toast_to(peer, "Ticket nach %s +%d €" % [stations[p.dest_station].station_name, p.fare], Color("aef2a0")) @@ -849,6 +950,7 @@ func _check_all_tickets(peer: int) -> void: for p in passengers: if p.state == PassengerScript.State.SEATED and not p.ticket_checked: p.ticket_checked = true + tickets_checked += 1 sum += p.fare n += 1 p._refresh_label() @@ -899,9 +1001,54 @@ func _srv_transfer_one(peer: int) -> void: _push_state() func _push_state() -> void: + if is_server(): + _check_goals() if mode == Mode.HOST: rpc("net_state", _state_payload()) +func _track_visits() -> void: + if train.docked_at != null and train.speed < 0.1: + var nm: String = train.docked_at.station_name + if not visited.has(nm): + visited[nm] = true + _check_goals() + +## Aktuelles Ziel und Fortschritt, für das HUD. +func current_goal() -> Dictionary: + for g in GOALS: + if not goals_done.has(String(g["id"])): + return {"text": String(g["text"]), "progress": _goal_progress(g), "target": int(g["value"])} + return {} + +func _goal_progress(g: Dictionary) -> int: + match String(g["kind"]): + "tickets": return tickets_checked + "cargo": return delivered_units + "rep": return reputation + "dining": return 1 if train.has_dining() else 0 + "visit": return 1 if visited.has("Bergblick") else 0 + "final": return reputation if train.loco_id == "express" and train.consist.size() >= 5 else 0 + return 0 + +func _check_goals() -> void: + for g in GOALS: + var id := String(g["id"]) + if goals_done.has(id): + continue + if _goal_progress(g) < int(g["value"]): + break # Ziele kommen der Reihe nach + goals_done[id] = true + money += int(g["reward"]) + if id == "final": + won = true + _broadcast_toast("Linie ausgebaut! Ihr habt es geschafft. +%d €" % int(g["reward"]), + Color("ffd978")) + else: + _broadcast_toast("Ziel erreicht: %s +%d €" % [String(g["text"]), int(g["reward"])], + Color("ffd978")) + _broadcast_sfx("bell", false, -8.0) + save_game() + # --- Laden ---------------------------------------------------------------- func _open_shop() -> void: @@ -953,6 +1100,18 @@ func _srv_sell_car(peer: int, index: int) -> void: _broadcast_sfx("coin", false, -5.0) _push_state() +## Die Lok geht zurück ans Depot, der Dampf-Veteran springt ein. +func _srv_sell_loco(peer: int) -> void: + if train.loco_id == "steam": + return + var d: Dictionary = Defs.LOCOS[train.loco_id] + money += int(d["sell"]) + train.loco_id = "steam" + _rebuild_train() + _broadcast_toast("Verkauft: %s +%d €" % [String(d["name"]), int(d["sell"])], Color("ffd978")) + _broadcast_sfx("coin", false, -5.0) + _push_state() + func _srv_buy_upgrade(peer: int, id: String) -> void: if not Defs.UPGRADES.has(id): return @@ -986,6 +1145,76 @@ func buy_upgrade(id: String) -> bool: request("buy_upgrade", id) return upgrades.size() != before +# --- Spielstand ------------------------------------------------------------- + +func save_game() -> void: + if not is_server(): + return + var stock: Array = [] + for st in stations: + stock.append(st.stock) + var data := { + "money": money, "rep": reputation, "upg": upgrades.keys(), + "delivered": delivered_units, "tickets": tickets_checked, + "visited": visited.keys(), "goals": goals_done.keys(), "won": won, + "loco": train.loco_id, "consist": train.consist, + "seg": train.seg, "progress": train.progress, "cargo": train.cargo, + "stock": stock, + } + var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + if f == null: + return + f.store_string(JSON.stringify(data)) + f.close() + +func load_game() -> bool: + if not FileAccess.file_exists(SAVE_PATH): + return false + var f := FileAccess.open(SAVE_PATH, FileAccess.READ) + if f == null: + return false + var parsed = JSON.parse_string(f.get_as_text()) + f.close() + if not (parsed is Dictionary): + return false + var d: Dictionary = parsed + money = int(d.get("money", START_MONEY)) + reputation = int(d.get("rep", 0)) + upgrades.clear() + for k in d.get("upg", []): + upgrades[String(k)] = true + delivered_units = int(d.get("delivered", 0)) + tickets_checked = int(d.get("tickets", 0)) + visited.clear() + for k in d.get("visited", []): + visited[String(k)] = true + goals_done.clear() + for k in d.get("goals", []): + goals_done[String(k)] = true + won = bool(d.get("won", false)) + var loco := String(d.get("loco", "steam")) + if Defs.LOCOS.has(loco): + train.loco_id = loco + var typed: Array[String] = [] + for c in d.get("consist", []): + if Defs.CARS.has(String(c)): + typed.append(String(c)) + train.consist = typed + train.cargo.clear() + var cargo: Dictionary = d.get("cargo", {}) + for k in cargo.keys(): + train.cargo[int(k)] = int(cargo[k]) + var stock: Array = d.get("stock", []) + for i in mini(stock.size(), stations.size()): + stations[i].stock = float(stock[i]) + _rebuild_train() + var seg_id := int(d.get("seg", 0)) + if seg_id >= 0 and seg_id < track.segments.size(): + train.place_head(seg_id, float(d.get("progress", 0.0))) + train._update_dock(0.0) + hud.toast("Spielstand geladen", Color("8fd8ff")) + return true + func _rebuild_train() -> void: # Sitzende Gaeste zwischenparken, sonst verschwinden sie mit dem alten Wagen. var riders: Array = [] @@ -1005,7 +1234,7 @@ func _rebuild_train() -> void: p.seat_index = int(seat["index"]) train.cars[p.seat_car].seats[p.seat_index]["occupant"] = p _finish_boarding(p) - cam_rig.target = train.followers[0] + cam_rig.target = train.cars[0] player.snap_to_cab() if driver_id == my_id: player.mode = PlayerScript.Mode.DRIVE diff --git a/scripts/hud.gd b/scripts/hud.gd index 23d5059..fcda890 100644 --- a/scripts/hud.gd +++ b/scripts/hud.gd @@ -129,7 +129,7 @@ func refresh(game) -> void: _prompt.text = String(PROMPTS.get(String(h["kind"]), "")) if game.player.mode == PlayerScript.Mode.DRIVE: - _drive.text = "W Gas S Bremse %3.0f km/h" % t.speed_kmh() + _drive.text = "W Gas S Bremse A/D Weiche %3.0f km/h" % t.speed_kmh() _drive.add_theme_color_override("font_color", Color("aef2a0") if t.speed > 0.2 else Color("ffd978")) else: diff --git a/scripts/menu.gd b/scripts/menu.gd index eb3aa37..9b94f8f 100644 --- a/scripts/menu.gd +++ b/scripts/menu.gd @@ -2,6 +2,7 @@ extends CanvasLayer ## Startmenue: Einzelspieler, Hosten oder einer Sitzung beitreten. signal solo_chosen() +signal continue_chosen() signal host_chosen(port: int) signal join_chosen(address: String, port: int) @@ -11,6 +12,8 @@ var _root: Control var _status := Label.new() var _addr := LineEdit.new() var _port := LineEdit.new() +var _name := LineEdit.new() +var _continue: Button func _ready() -> void: layer = 3 @@ -47,6 +50,14 @@ func _ready() -> void: vb.add_child(sub) vb.add_child(_spacer(18)) + _name.placeholder_text = "Dein Name" + _name.text = "Crew" + _name.max_length = 16 + _name.alignment = HORIZONTAL_ALIGNMENT_CENTER + vb.add_child(_name) + _continue = _button("Weiterspielen", func(): continue_chosen.emit()) + _continue.visible = FileAccess.file_exists("user://save.json") + vb.add_child(_continue) vb.add_child(_button("Einzelspieler", func(): solo_chosen.emit())) vb.add_child(_button("Sitzung hosten", func(): host_chosen.emit(_port_value()))) @@ -91,6 +102,10 @@ func _port_value() -> int: var v := int(_port.text) return v if v > 0 else DEFAULT_PORT +func player_name() -> String: + var n := _name.text.strip_edges() + return n if n != "" else "Crew" + func set_status(text: String) -> void: _status.text = text diff --git a/scripts/sfx.gd b/scripts/sfx.gd index 2f19931..3792b7b 100644 --- a/scripts/sfx.gd +++ b/scripts/sfx.gd @@ -32,6 +32,30 @@ const PROGRESSION := [ "melody": [440.00, 493.88, 587.33, 659.26, 783.99]}, ] +## Zweiter Teil: geht über Dm7 und Em7, kommt über G6 zurück. +const PROGRESSION_B := [ + {"name": "Dm7", "pad": [220.00, 261.63, 293.66, 349.23], "bass": 146.83, + "melody": [440.00, 523.25, 587.33, 659.26, 698.46, 783.99]}, + {"name": "Em7", "pad": [196.00, 246.94, 293.66, 329.63], "bass": 164.81, + "melody": [493.88, 587.33, 659.26, 783.99, 880.00]}, + {"name": "Fmaj7", "pad": [174.61, 220.00, 261.63, 329.63], "bass": 87.31, + "melody": [440.00, 523.25, 587.33, 659.26, 783.99, 880.00]}, + {"name": "G6", "pad": [196.00, 246.94, 293.66, 329.63], "bass": 98.00, + "melody": [440.00, 493.88, 587.33, 659.26, 783.99]}, + {"name": "Am7", "pad": [220.00, 261.63, 329.63, 392.00], "bass": 110.00, + "melody": [440.00, 493.88, 523.25, 587.33, 659.26, 783.99]}, + {"name": "Em7", "pad": [196.00, 246.94, 293.66, 329.63], "bass": 164.81, + "melody": [493.88, 587.33, 659.26, 783.99, 880.00]}, + {"name": "Fmaj7", "pad": [174.61, 220.00, 261.63, 329.63], "bass": 87.31, + "melody": [440.00, 523.25, 587.33, 659.26, 783.99, 880.00]}, + {"name": "G6", "pad": [196.00, 246.94, 293.66, 329.63], "bass": 98.00, + "melody": [440.00, 493.88, 587.33, 659.26, 783.99]}, +] + +## Reihenfolge der Teile: A, B, A mit anderer Melodie. +const FORM := [0, 1, 0] +const SECTION_SEEDS := [7411, 9187, 3323] + static func build_all() -> Dictionary: var rng := RandomNumberGenerator.new() rng.seed = 20260828 @@ -53,26 +77,29 @@ static func build_all() -> Dictionary: } static func build_music() -> PackedByteArray: - var rng := RandomNumberGenerator.new() - rng.seed = 7411 - var loop_len := BAR * BARS + var section_len := BAR * BARS + var loop_len := section_len * FORM.size() var buf := Synth.buffer(loop_len + 3.0) + for sec in FORM.size(): + var prog: Array = PROGRESSION if FORM[sec] == 0 else PROGRESSION_B + var rng := RandomNumberGenerator.new() + rng.seed = int(SECTION_SEEDS[sec]) + _render_section(buf, sec * section_len, prog, rng) + return Synth.to_pcm(Synth.seamless(buf, loop_len)) +static func _render_section(buf: PackedFloat32Array, offset: float, prog: Array, + rng: RandomNumberGenerator) -> void: for b in BARS: - var t0 := b * BAR - var stage: Dictionary = PROGRESSION[b] + var t0 := offset + b * BAR + var stage: Dictionary = prog[b] var pad: Array = stage["pad"] var bass: float = float(stage["bass"]) var melody: Array = stage["melody"] - - # Flächen, leicht überlappend für weiche Übergänge for i in pad.size(): var amp: float = 0.19 / (1.0 + i * 0.4) Synth.add_pad(buf, t0 - 0.45, float(pad[i]), BAR + 1.0, amp, 1.2) - # Bass: Grundton auf 1, Quinte auf 3 Synth.add_tone(buf, t0, bass, 2.3, 0.36, 1.4, [1.0, 0.38, 0.12], 0.01) Synth.add_tone(buf, t0 + BEAT * 2.0, bass * 1.5, 1.7, 0.20, 1.8, [1.0, 0.28], 0.01) - # Melodie: sparsame Glockentöne, immer aus dem Akkord var beats := [0.0, 1.5, 2.5] if b % 2 == 0 else [0.5, 2.0, 3.0] for k in beats.size(): if rng.randf() < 0.3: @@ -80,13 +107,10 @@ static func build_music() -> PackedByteArray: var f: float = float(melody[rng.randi() % melody.size()]) Synth.add_tone(buf, t0 + float(beats[k]) * BEAT, f, 2.4, 0.11, 2.2, [1.0, 0.45, 0.18, 0.07], 0.006) - # Sehr leiser Shaker auf jedem Schlag for beat in 4: Synth.add_noise(buf, t0 + beat * BEAT, 0.12, 0.032 if beat % 2 == 1 else 0.020, 34.0, 0.62, rng) - return Synth.to_pcm(Synth.seamless(buf, loop_len)) - # --- Einzelklänge --------------------------------------------------------- ## Ein Radstoß: tiefer Schlag, mittleres Klopfen, kurzer Metallanteil. diff --git a/scripts/shop.gd b/scripts/shop.gd index 83f77ba..715baaf 100644 --- a/scripts/shop.gd +++ b/scripts/shop.gd @@ -181,8 +181,12 @@ func _car_rows() -> Array: func _consist_rows() -> Array: var rows: Array = [] var d: Dictionary = Defs.LOCOS[_game.train.loco_id] - rows.append(_row(String(d["name"]), "Lok - lässt sich nur ersetzen, nicht verkaufen", - "", "", false, func(): pass)) + var is_steam: bool = _game.train.loco_id == "steam" + rows.append(_row(String(d["name"]), + "Lok - beim Verkauf springt der Dampf-Veteran ein" if not is_steam else "Lok - die Grundausstattung bleibt", + "" if is_steam else "Rücknahme %d €" % int(d["sell"]), + "Verkaufen", not is_steam, + func(): _game.request("sell_loco"); _rebuild())) var list: Array = _game.train.consist for i in list.size(): var cid: String = list[i] diff --git a/scripts/station.gd b/scripts/station.gd index 02f380d..c8e31c7 100644 --- a/scripts/station.gd +++ b/scripts/station.gd @@ -9,6 +9,7 @@ const PLATFORM_LEN := 44.0 const PLATFORM_H := 0.95 var station_name := "" +var seg := 0 var progress := 0.0 var side := 1.0 var produces := -1 @@ -19,8 +20,9 @@ var rate := 0.55 var waiting_spots: Array[Vector3] = [] ## Weltpositionen auf dem Bahnsteig var waiting: Array = [] ## wartende Passagiere -func setup(nm: String, prog: float, prod: int, acc: Array[int]) -> void: +func setup(nm: String, seg_id: int, prog: float, prod: int, acc: Array[int]) -> void: station_name = nm + seg = seg_id progress = prog produces = prod accepts = acc diff --git a/scripts/track.gd b/scripts/track.gd index b62e33f..aa17d4a 100644 --- a/scripts/track.gd +++ b/scripts/track.gd @@ -1,5 +1,6 @@ extends Node3D -## Baut aus 2D-Stuetzpunkten eine geschlossene Gleisschleife inkl. Optik. +## Streckennetz aus Abschnitten. Ein Abschnitt hat einen oder zwei +## Nachfolger - bei zweien steht dort eine Weiche. const Mat = preload("res://scripts/mat.gd") const Deco = preload("res://scripts/deco.gd") @@ -9,37 +10,69 @@ const SLEEPER := Color("5e4130") const RAIL := Color("9a9da2") const STEP := 1.2 -var path: Path3D +## Jeder Eintrag: { "name", "curve": Curve3D, "length", "next": [ids], "prev": [ids] } +var segments: Array = [] -func build(points: PackedVector2Array) -> void: - path = Path3D.new() - path.name = "TrackPath" +## Abschnitt anlegen. Anfangs- und Endrichtung werden vorgegeben, damit +## sich mehrere Abschnitte an einer Weiche ohne Knick treffen. +func add_segment(seg_name: String, points: Array, t_start: Vector2, t_end: Vector2) -> int: var c := Curve3D.new() var n := points.size() - # Catmull-Rom Tangenten -> weiche, geschlossene Schleife. - for i in n + 1: - var idx := i % n - var prev := points[(idx - 1 + n) % n] - var nxt := points[(idx + 1) % n] - var t := (nxt - prev) / 6.0 - var p := points[idx] + for i in n: + var p: Vector2 = points[i] + var t: Vector2 + if i == 0: + t = t_start.normalized() * p.distance_to(points[1]) / 3.0 + elif i == n - 1: + t = t_end.normalized() * p.distance_to(points[n - 2]) / 3.0 + else: + t = (points[i + 1] - points[i - 1]) / 6.0 c.add_point(Vector3(p.x, 0, p.y), Vector3(-t.x, 0, -t.y), Vector3(t.x, 0, t.y)) - path.curve = c - add_child(path) - _build_visuals() + segments.append({"name": seg_name, "curve": c, "length": c.get_baked_length(), + "next": [], "prev": []}) + return segments.size() - 1 -func length() -> float: - return path.curve.get_baked_length() +func connect_segments(from: int, to: int) -> void: + segments[from]["next"].append(to) + segments[to]["prev"].append(from) -func sample(offset: float) -> Transform3D: - return path.curve.sample_baked_with_rotation(fposmod(offset, length()), true, true) +func finish() -> void: + for i in segments.size(): + _build_visuals(i) -func _build_visuals() -> void: - var count := int(length() / STEP) +func seg_length(i: int) -> float: + return float(segments[i]["length"]) + +func seg_name(i: int) -> String: + return String(segments[i]["name"]) + +func next_of(i: int) -> Array: + return segments[i]["next"] + +func prev_of(i: int) -> Array: + return segments[i]["prev"] + +func is_switch(i: int) -> bool: + return segments[i]["next"].size() > 1 + +func sample(i: int, offset: float) -> Transform3D: + var c: Curve3D = segments[i]["curve"] + return c.sample_baked_with_rotation(clampf(offset, 0.0, seg_length(i)), true, false) + +## Nächster Punkt auf irgendeinem Abschnitt - für die Deko-Verteilung. +func distance_to_track(p: Vector2) -> float: + var best := INF + for s in segments: + var c: Curve3D = s["curve"] + var q: Vector3 = c.get_closest_point(Vector3(p.x, 0, p.y)) + best = minf(best, Vector2(q.x, q.z).distance_to(p)) + return best + +func _build_visuals(i: int) -> void: + var count := int(seg_length(i) / STEP) var xf: Array[Transform3D] = [] - for i in count: - xf.append(sample(i * STEP)) - + for k in count: + xf.append(sample(i, k * STEP + STEP * 0.5)) _multi(Deco.box(Vector3(2.7, 0.16, STEP * 1.12)), BALLAST, xf, [Vector3(0, 0.08, 0)]) _multi(Deco.box(Vector3(2.1, 0.09, 0.34)), SLEEPER, xf, [Vector3(0, 0.20, 0)]) _multi(Deco.box(Vector3(0.09, 0.11, STEP * 1.08)), RAIL, xf, diff --git a/scripts/train.gd b/scripts/train.gd index 95c03e8..00a0a34 100644 --- a/scripts/train.gd +++ b/scripts/train.gd @@ -1,6 +1,6 @@ extends Node3D -## Der Zugverband: Lok + Waggons auf der Strecke, Fahrphysik, -## und die Abbildung "Laengsposition im Zug" -> Weltkoordinate. +## Der Zugverband auf dem Streckennetz: Fahrphysik, Weichenwahl und +## die Abbildung "Laengsposition im Zug" -> Weltkoordinate. const CarScene = preload("res://scripts/car.gd") const Defs = preload("res://scripts/car_defs.gd") @@ -8,25 +8,33 @@ const Cargo = preload("res://scripts/cargo.gd") signal consist_changed() -const GAP := 0.75 ## Abstand zwischen zwei Waggons +const GAP := 0.75 const DRAG := 0.6 const DOCK_RANGE := 3.0 +const PLATFORM_HALF := 18.0 ## Bahnsteig reicht so weit um seine Mitte +const HISTORY := 6 ## so viele Abschnitte merken wir uns rückwärts var loco_id := "steam" -var consist: Array[String] = [] ## nur die Waggons, ohne Lok -var cars: Array = [] ## Car-Knoten, Index 0 = Lok -var followers: Array[PathFollow3D] = [] +var consist: Array[String] = [] +var cars: Array = [] -var progress := 0.0 ## Position der Zugspitze auf der Kurve +var seg := 0 ## Abschnitt der Zugspitze +var progress := 0.0 ## Position der Spitze auf dem Abschnitt +var history: Array = [] ## zuletzt befahrene Abschnitte, ältester zuerst +var switch_choice := 0 ## 0 = links/erster Nachfolger, 1 = rechts var speed := 0.0 var throttle := 0.0 var braking := false -var cargo: Dictionary = {} ## Cargo.Type -> int +var cargo: Dictionary = {} var stations: Array = [] var docked_at: Node = null +var sway_enabled := true var _track var _stand_time := 0.0 +var _last_yaw := 0.0 +var _roll := 0.0 +var _time := 0.0 func setup(track, station_list: Array) -> void: _track = track @@ -68,14 +76,12 @@ func seat_count() -> int: n += c.seats.size() return n -# --- Verband aufbauen ----------------------------------------------------- +# --- Verband -------------------------------------------------------------- func rebuild() -> void: - for f in followers: - f.queue_free() - followers.clear() + for c in cars: + c.queue_free() cars.clear() - var ids: Array[String] = [loco_id] ids.append_array(consist) var s := 0.0 @@ -85,16 +91,9 @@ func rebuild() -> void: car.index = i car.center_s = s - car.length * 0.5 s = car.center_s - car.length * 0.5 - GAP - var f := PathFollow3D.new() - f.loop = true - f.rotation_mode = PathFollow3D.ROTATION_Y - f.use_model_front = true - f.cubic_interp = true - f.add_child(car) - _track.path.add_child(f) - followers.append(f) + add_child(car) cars.append(car) - _sync_followers() + _place_cars(0.0) refresh_cargo_visuals() consist_changed.emit() @@ -102,9 +101,8 @@ func total_length() -> float: if cars.is_empty(): return 0.0 var last = cars[cars.size() - 1] - return abs(last.center_s - last.length * 0.5) + return absf(last.center_s - last.length * 0.5) -## Begehbarer Bereich als [hinten, vorne] in Zug-Laengskoordinaten. func walk_range() -> Vector2: if cars.is_empty(): return Vector2.ZERO @@ -112,7 +110,6 @@ func walk_range() -> Vector2: var back = cars[cars.size() - 1] return Vector2(back.center_s + back.inner_z0 + 0.35, front.center_s + front.inner_z1 - 0.35) -## Findet den Waggon zu einer Laengsposition. func locate(s: float) -> Dictionary: var best := 0 var best_d := INF @@ -122,7 +119,7 @@ func locate(s: float) -> Dictionary: var hi: float = c.center_s + c.inner_z1 if s >= lo and s <= hi: return {"index": i, "local_z": s - c.center_s, "gangway": false} - var d: float = min(absf(s - lo), absf(s - hi)) + var d: float = minf(absf(s - lo), absf(s - hi)) if d < best_d: best_d = d best = i @@ -130,18 +127,95 @@ func locate(s: float) -> Dictionary: return {"index": best, "local_z": s - c2.center_s, "gangway": true} func car_transform(i: int) -> Transform3D: - return followers[i].global_transform + return cars[i].global_transform -## Weltposition eines Punktes im Zug (x quer, y hoch, s laengs). func world_point(s: float, x: float, y: float) -> Transform3D: var loc := locate(s) - var xf: Transform3D = car_transform(int(loc["index"])) - return xf.translated_local(Vector3(x, y, float(loc["local_z"]))) + return car_transform(int(loc["index"])).translated_local(Vector3(x, y, float(loc["local_z"]))) -func _sync_followers() -> void: - var l: float = _track.length() +# --- Position auf dem Netz ----------------------------------------------- + +## Spitze setzen und die Historie rückwärts auffüllen, damit auch der +## letzte Wagen einen Abschnitt unter sich hat. +func place_head(seg_id: int, prog: float) -> void: + seg = seg_id + progress = prog + while progress >= _track.seg_length(seg): + progress -= _track.seg_length(seg) + var nxt: Array = _track.next_of(seg) + history.append(seg) + seg = nxt[mini(switch_choice, nxt.size() - 1)] + history.clear() + var s := seg + for i in HISTORY: + var prev: Array = _track.prev_of(s) + if prev.is_empty(): + break + s = prev[0] + history.push_front(s) + _place_cars(0.0) + +## Abschnitt und Position eines Punktes, der d Meter hinter der Spitze liegt. +func point_behind(d: float) -> Array: + var s := seg + var p := progress - d + var k := history.size() + while p < 0.0 and k > 0: + k -= 1 + s = history[k] + p += _track.seg_length(s) + return [s, maxf(p, 0.0)] + +## Weltlage eines Punktes d Meter hinter der Spitze. +func transform_behind(d: float) -> Transform3D: + var sp := point_behind(d) + return _track.sample(int(sp[0]), float(sp[1])) + +func _advance() -> void: + while progress >= _track.seg_length(seg): + progress -= _track.seg_length(seg) + var nxt: Array = _track.next_of(seg) + if nxt.is_empty(): + progress = _track.seg_length(seg) + speed = 0.0 + return + history.append(seg) + if history.size() > HISTORY: + history.pop_front() + seg = nxt[mini(switch_choice, nxt.size() - 1)] + +## Der nächste Abschnitt mit Weiche, den die Spitze erreicht - oder -1. +func upcoming_switch() -> int: + var s := seg + for i in 3: + if _track.is_switch(s): + return s + var nxt: Array = _track.next_of(s) + if nxt.is_empty(): + return -1 + s = nxt[0] + return -1 + +func _place_cars(delta: float) -> void: + _time += delta + var f: float = clampf(speed / maxf(max_speed(), 1.0), 0.0, 1.0) + var head_xf: Transform3D = _track.sample(seg, progress) + var yaw: float = head_xf.basis.get_euler().y + if delta > 0.0: + var rate: float = wrapf(yaw - _last_yaw, -PI, PI) / delta + var target: float = clampf(-speed * rate * 0.018, -0.05, 0.05) + _roll = lerpf(_roll, target, clampf(delta * 3.0, 0.0, 1.0)) + _last_yaw = yaw for i in cars.size(): - followers[i].progress = fposmod(progress + cars[i].center_s, l) + var car = cars[i] + var xf: Transform3D = transform_behind(-car.center_s) + if sway_enabled and speed > 0.2: + var ph: float = float(i) * 1.9 + var roll: float = _roll + sin(_time * 1.7 + ph) * 0.006 * f + var pitch: float = sin(_time * 2.9 + ph) * 0.003 * f + var bounce: float = sin(_time * 3.3 + ph) * 0.014 * f + xf = xf * Transform3D(Basis.from_euler(Vector3(pitch, 0.0, roll)), Vector3(0, bounce, 0)) + car.global_transform = xf # --- Ladung --------------------------------------------------------------- @@ -188,39 +262,80 @@ func drive(delta: float) -> void: speed = minf(speed + accel() * throttle * delta, max_speed()) else: speed = maxf(speed - DRAG * delta, 0.0) - progress = fposmod(progress + speed * delta, _track.length()) - _sync_followers() + progress += speed * delta + _advance() + _place_cars(delta) _update_dock(delta) -## Nur Anzeige aktualisieren (Clients im Netzspiel). -func apply_snapshot(p: float, v: float) -> void: - progress = p +## Client: Server-Stand übernehmen, dazwischen selbst weiterrechnen. +func apply_snapshot(seg_id: int, prog: float, v: float, sw: int, hist: Array) -> void: + switch_choice = sw + if seg_id != seg: + seg = seg_id + progress = prog + history = hist.duplicate() + else: + progress = lerpf(progress, prog, 0.4) speed = v - _sync_followers() - _update_dock(0.0) + +func client_tick(delta: float) -> void: + progress += speed * delta + _advance() + _place_cars(delta) + _update_dock(delta) func _update_dock(delta: float) -> void: var near := nearest_station() var st: Node = near[0] - var dist: float = near[1] if st == null: return - if speed > 0.15 or dist > DOCK_RANGE: + if speed > 0.15 or float(near[1]) > DOCK_RANGE: _stand_time = 0.0 docked_at = null return _stand_time += delta docked_at = st -## Abstand eines Bahnhofs zum Zug. 0, solange der Bahnsteig neben dem -## Zug liegt - sonst waere ein langer Zug nie richtig am Bahnsteig. +## Abstand eines Bahnhofs zum Zug entlang der Strecke. 0, solange der +## Bahnsteig neben dem Zug liegt. func station_gap(st) -> float: - var l: float = _track.length() - var behind: float = fposmod(progress - st.progress, l) - var len_train: float = total_length() - if behind <= len_train: + var ahead := distance_ahead(st) + if ahead == 0.0: return 0.0 - return minf(behind - len_train, l - behind) + var behind := _distance_behind(st) + if behind >= 0.0: + return maxf(behind - total_length() - PLATFORM_HALF, 0.0) + return maxf(ahead - PLATFORM_HALF, 0.0) + +## Wie weit vor der Spitze liegt der Bahnhof, entlang der gewählten Route? +## 0 bedeutet: neben dem Zug oder direkt an der Spitze. INF: nicht auf dem Weg. +func distance_ahead(st) -> float: + var behind := _distance_behind(st) + if behind >= 0.0 and behind <= total_length(): + return 0.0 + var s := seg + var d := -progress + for i in 4: + if st.seg == s and (i > 0 or st.progress >= progress): + return maxf(d + st.progress, 0.0) + d += _track.seg_length(s) + var nxt: Array = _track.next_of(s) + if nxt.is_empty(): + break + s = nxt[mini(switch_choice, nxt.size() - 1)] + return INF + +## Abstand von der Spitze rückwärts bis zum Bahnhof, oder -1. +func _distance_behind(st) -> float: + if st.seg == seg and st.progress <= progress: + return progress - st.progress + var d := progress + for k in range(history.size() - 1, -1, -1): + var s: int = history[k] + if st.seg == s: + return d + (_track.seg_length(s) - st.progress) + d += _track.seg_length(s) + return -1.0 func nearest_station() -> Array: var best: Node = null @@ -235,10 +350,11 @@ func nearest_station() -> Array: func next_station() -> Array: var best: Node = null var best_d := INF - var l: float = _track.length() for s in stations: - var d: float = fposmod(s.progress - progress, l) - if d < best_d: + var d: float = distance_ahead(s) + if d > 0.0 and d < best_d: best_d = d best = s + if best == null: + return nearest_station() return [best, best_d] diff --git a/scripts/world.gd b/scripts/world.gd index b6d2bc5..ceb7b1d 100644 --- a/scripts/world.gd +++ b/scripts/world.gd @@ -68,8 +68,7 @@ func _scatter(track, stations: Array) -> void: while placed < 620 and tries < 12000: tries += 1 var p := Vector2(_rng.randf_range(-EXTENT, EXTENT), _rng.randf_range(-EXTENT, EXTENT)) - var closest: Vector3 = track.path.curve.get_closest_point(Vector3(p.x, 0, p.y)) - if Vector2(closest.x, closest.z).distance_to(p) < 11.0: + if track.distance_to_track(p) < 11.0: continue if p.distance_to(Vector2(-230, 150)) < 78.0: continue diff --git a/tests/audio_test.gd b/tests/audio_test.gd index c2d8971..f37079d 100644 --- a/tests/audio_test.gd +++ b/tests/audio_test.gd @@ -7,6 +7,7 @@ var ok := 0 var fail := 0 var worst := 0.0 var elapsed := 0.0 +var sfx_first := false func chk(n: String, c: bool, i := "") -> void: if c: ok += 1; print(" OK %s %s" % [n, i]) @@ -23,11 +24,14 @@ func _process(d): if not main.started: main._start_solo() return - if not main.audio.loaded: + if not main.audio.music_loaded: elapsed += d worst = maxf(worst, d) + if main.audio.loaded: + sfx_first = true return if step == 0: + chk("Klänge vor der Musik verfügbar", sfx_first) chk("Kein Ruckler beim Erzeugen", worst < 0.2, "längster Frame %.0f ms in %.1f s Ladezeit" % [worst * 1000.0, elapsed]) if wait > 0.0: @@ -56,7 +60,7 @@ func _process(d): chk("Thread beendet", a._thread == null) var m: AudioStreamWAV = a._music.stream chk("Musik ist eine Schleife", m.loop_mode == AudioStreamWAV.LOOP_FORWARD) - chk("Musik lang genug", m.data.size() / 2 > 20 * 22050, + chk("Musik hat drei Teile", m.data.size() / 2 > 75 * 22050, "%.1f s" % (m.data.size() / 2.0 / 22050.0)) chk("Musik nicht stumm", _peak(m) > 3000, "peak=%d" % _peak(m)) chk("Rumble läuft leise", a._rumble.playing and a._rumble.volume_db < -40.0, @@ -134,7 +138,7 @@ func _peak(w: AudioStreamWAV) -> int: func _check_harmony() -> void: var Sfx = load("res://scripts/sfx.gd") var bad: Array[String] = [] - for stage in Sfx.PROGRESSION: + for stage in Sfx.PROGRESSION + Sfx.PROGRESSION_B: var voices: Array[float] = [] for f in stage["pad"]: voices.append(float(f)) diff --git a/tests/net_test.gd b/tests/net_test.gd index 7234680..266b99e 100644 --- a/tests/net_test.gd +++ b/tests/net_test.gd @@ -31,6 +31,7 @@ func _process(d): if role == "host": main._start_host(7788) elif role == "client" and step == 0: + main.menu._name.text = "Tester" main._start_join("127.0.0.1", 7788) step = 1 return @@ -42,14 +43,24 @@ func _process(d): else: _client_step() +func _key(c: int, d: bool) -> void: + var e := InputEventKey.new(); e.physical_keycode = c; e.keycode = c; e.pressed = d + Input.parse_input_event(e) + func _host_step() -> void: match step: 0: print("[host] Sitzung gestartet") for i in 4: main.spawn_passenger(0, 2) - wait = 30.0 + wait = 16.0 1: + # Host faehrt los, der Client soll das weich sehen + main._srv_toggle_drive(1) + _key(KEY_W, true) + wait = 20.0 + 2: + _key(KEY_W, false) print("[host] Ende. money=%d driver=%d" % [main.money, main.driver_id]) get_tree().quit() step += 1 @@ -59,7 +70,7 @@ func _client_step() -> void: 1: wait = 6.0 2: - chk("Welt gebaut", main.stations.size() == 5) + chk("Welt gebaut", main.stations.size() == 6) chk("Zug übernommen", main.train.cars.size() == 3, "cars=%d" % main.train.cars.size()) chk("Eigene Peer-ID", main.my_id > 1) chk("Fahrgäste sichtbar", main.passengers.size() >= 4, "n=%d" % main.passengers.size()) @@ -69,6 +80,11 @@ func _client_step() -> void: seated += 1 chk("Gäste sitzen", seated >= 4, "seated=%d" % seated) chk("Geld synchron", main.money == 1500) + chk("Eigener Name beim Host angekommen", + String(main.players[main.my_id]["name"]) == "Tester", + "%s" % main.players[main.my_id]["name"]) + var host_av = main.players.get(1, {}).get("avatar") + chk("Avatar des Hosts sichtbar", host_av != null) main.request("drive") wait = 1.5 3: @@ -96,6 +112,36 @@ func _client_step() -> void: wait = 2.0 6: chk("Kein Selbstlauf ohne Gas", main.train.speed < 0.01) + main.request("drive") # Steuerung abgeben, der Host faehrt gleich + _measuring = true + wait = 12.0 + 7: + _measuring = false + chk("Zug beim Client in Fahrt", _top_speed > 3.0, "v=%.1f" % _top_speed) + chk("Zug beim Client ruckelfrei", _frames > 100 and _jumps * 20 < _frames, + "%d Sprünge in %d Frames, größter %.2f m" % [_jumps, _frames, _worst]) print("[client] == %d ok, %d fail ==" % [ok, fail]) get_tree().quit(1 if fail > 0 else 0) step += 1 + +var _measuring := false +var _last := Vector3.ZERO +var _frames := 0 +var _jumps := 0 +var _worst := 0.0 +var _top_speed := 0.0 + +func _physics_process(d: float) -> void: + if not _measuring or main == null or not main.started: + return + var t = main.train + var p: Vector3 = t.cars[0].global_position + if _last != Vector3.ZERO and t.speed > 1.0: + _frames += 1 + var moved := p.distance_to(_last) + var expected: float = t.speed * d + _worst = maxf(_worst, absf(moved - expected)) + if absf(moved - expected) > 0.6: + _jumps += 1 + _top_speed = maxf(_top_speed, t.speed) + _last = p diff --git a/tests/run.sh b/tests/run.sh index 8659cb9..8e59487 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -27,6 +27,12 @@ install_test tests/input_test.gd "$GODOT" --path . --resolution 1280x720 2>&1 | grep -E "OK|FAIL|==" INPUT=${PIPESTATUS[0]} +echo +echo "=== Weichen-Tests ===" +install_test tests/switch_test.gd +"$GODOT" --path . --resolution 1280x720 2>&1 | grep -E "OK|FAIL|==" +SWITCH=${PIPESTATUS[0]} + echo echo "=== Klang-Tests ===" install_test tests/audio_test.gd @@ -50,5 +56,5 @@ NET=${PIPESTATUS[0]} wait $HOST 2>/dev/null echo -[ "$SOLO" -eq 0 ] && [ "$INPUT" -eq 0 ] && [ "$DIR" -eq 0 ] && [ "$AUDIO" -eq 0 ] && [ "$NET" -eq 0 ] && echo "Alle Tests bestanden." || echo "Es sind Tests fehlgeschlagen." -exit $(( SOLO || INPUT || DIR || AUDIO || NET )) +[ "$SOLO" -eq 0 ] && [ "$SWITCH" -eq 0 ] && [ "$INPUT" -eq 0 ] && [ "$DIR" -eq 0 ] && [ "$AUDIO" -eq 0 ] && [ "$NET" -eq 0 ] && echo "Alle Tests bestanden." || echo "Es sind Tests fehlgeschlagen." +exit $(( SOLO || SWITCH || INPUT || DIR || AUDIO || NET )) diff --git a/tests/solo_test.gd b/tests/solo_test.gd index b01a34b..e83166b 100644 --- a/tests/solo_test.gd +++ b/tests/solo_test.gd @@ -112,6 +112,36 @@ func _process(d): var before = main.train.consist.size() main.sell_car(0) chk("Wagen verkauft", main.train.consist.size() == before - 1) + # Lok verkaufen: Dampf-Veteran springt ein, Geld kommt zurueck + var m_before = main.money + main.request("sell_loco") + chk("Lok verkauft", main.train.loco_id == "steam", "loco=%s" % main.train.loco_id) + chk("Rücknahme gutgeschrieben", main.money - m_before == 6600, "+%d €" % (main.money - m_before)) + main.buy_loco("express") + # Speichern und Laden + main.save_game() + var saved_money = main.money + var saved_consist = main.train.consist.duplicate() + var saved_seg = main.train.seg + var saved_prog = main.train.progress + main.money = 1 + main.train.consist = ["coach"] as Array[String] + main.train.loco_id = "steam" + main._rebuild_train() + chk("Spielstand geladen", main.load_game()) + chk("Geld wiederhergestellt", main.money == saved_money, "%d vs %d" % [main.money, saved_money]) + chk("Zug wiederhergestellt", main.train.consist == saved_consist and main.train.loco_id == "express", + "%s %s" % [main.train.loco_id, main.train.consist]) + chk("Position wiederhergestellt", main.train.seg == saved_seg and absf(main.train.progress - saved_prog) < 0.5) + # Ziele + main.tickets_checked = 10 + var m_goal = main.money + main._check_goals() + chk("Ziel erreicht", main.goals_done.has("tickets10")) + chk("Belohnung gezahlt", main.money - m_goal == 250, "+%d €" % (main.money - m_goal)) + chk("Ziele der Reihe nach", not main.goals_done.has("cargo30") and not main.won) + var g = main.current_goal() + chk("Nächstes Ziel im HUD", String(g.get("text", "")).begins_with("30 Einheiten"), "%s" % g) wait = 0.3 7: chk("Spieler im Zug", main.train.locate(main.player.s)["index"] == 0) @@ -121,6 +151,9 @@ func _process(d): wait = 3.0 8: chk("Zug fährt", main.train.speed > 3.0, "v=%.1f m/s" % main.train.speed) + var e: Vector3 = main.train.cars[0].global_transform.basis.get_euler() + chk("Wagen wackelt bei Fahrt", absf(e.z) > 0.0005 or absf(e.x) > 0.0005, + "roll=%.4f pitch=%.4f" % [e.z, e.x]) _key(KEY_W, false) _key(KEY_S, true) wait = 4.0 diff --git a/tests/switch_test.gd b/tests/switch_test.gd new file mode 100644 index 0000000..eb68782 --- /dev/null +++ b/tests/switch_test.gd @@ -0,0 +1,175 @@ +extends Node +## Weichen: Route wählen, Übergang ohne Sprung, Bergstrecke erreichbar. +var main +var step := 0 +var wait := 0.0 +var ok := 0 +var fail := 0 +var last_pos := Vector3.ZERO +var worst_jump := 0.0 +var jump_frames := 0 +var _dbg := 0.0 +var _cam_hits := 0 +var _cam_pulled := 0 +var _cam_yaw := 0 + +func chk(n: String, c: bool, i := "") -> void: + if c: ok += 1; print(" OK %s %s" % [n, i]) + else: fail += 1; print(" FAIL %s %s" % [n, i]) + +func _key(c: int, d: bool) -> void: + var e := InputEventKey.new(); e.physical_keycode = c; e.keycode = c; e.pressed = d + Input.parse_input_event(e) + +func _watch(d: float) -> void: + var t = main.train + var p: Vector3 = t.cars[t.cars.size() - 1].global_position + if last_pos != Vector3.ZERO and d > 0.0: + var jump := p.distance_to(last_pos) + var allowed: float = t.speed * d * 1.5 + 0.05 + if jump > allowed: + jump_frames += 1 + worst_jump = maxf(worst_jump, jump - t.speed * d) + last_pos = p + +## Im Physik-Takt messen: dort bewegt sich der Zug genau einmal pro Aufruf. +func _physics_process(d: float) -> void: + if main != null and main.started and step >= 1 and step <= 8: + _watch(d) + +func _process(d): + if main == null: + main = get_tree().current_scene + return + if not main.started: + main._start_solo() + return + var t = main.train + if wait > 0.0: + wait -= d + return + match step: + 0: + chk("Drei Abschnitte", main.track.segments.size() == 3) + chk("Heimstrecke hat Weiche", main.track.is_switch(2)) + chk("Sechs Bahnhöfe", main.stations.size() == 6) + main._park_at(main.stations[4]) # Ahornstadt, Heimstrecke + chk("Start auf Heimstrecke", t.seg == 2, "seg=%d" % t.seg) + chk("Weiche voraus erkannt", t.upcoming_switch() == 2) + var nxt: Array = t.next_station() + chk("Nächster Halt Waldkirch (links)", nxt[0].station_name == "Waldkirch", + "%s" % nxt[0].station_name) + t.switch_choice = 1 + var nxt2: Array = t.next_station() + chk("Nächster Halt Bergblick (rechts)", nxt2[0].station_name == "Bergblick", + "%s" % nxt2[0].station_name) + t.switch_choice = 0 + main.driver_id = 1 + _key(KEY_W, true) + last_pos = Vector3.ZERO + wait = 1.0 + 1: + wait = 0.5 + 2: + if t.seg != 0: + step -= 1 + wait = 0.3 + _dbg += 0.3 + if _dbg >= 2.0: + _dbg = 0.0 + print("[dbg] seg=%d prog=%.1f/%.1f v=%.1f thr=%.1f driver=%d mode=%d" % [t.seg, t.progress, + main.track.seg_length(t.seg), t.speed, t.throttle, main.driver_id, main.player.mode]) + else: + _key(KEY_W, false) + _key(KEY_S, true) + chk("Links -> Talstrecke", t.seg == 0, "seg=%d" % t.seg) + chk("Historie geführt", t.history.size() > 0 and t.history[t.history.size() - 1] == 2, + "%s" % [t.history]) + chk("Letzter Wagen ohne Sprung", jump_frames == 0, + "Sprungframes=%d, größter Sprung %.2f m" % [jump_frames, worst_jump]) + wait = 4.0 + 3: + _key(KEY_S, false) + main._park_at(main.stations[4]) + t.switch_choice = 1 + last_pos = Vector3.ZERO + jump_frames = 0 + worst_jump = 0.0 + _key(KEY_D, true) + _key(KEY_W, true) + wait = 1.0 + 4: + wait = 0.5 + 5: + if t.seg != 1: + step -= 1 + wait = 0.3 + else: + chk("Rechts -> Bergstrecke", t.seg == 1, "seg=%d" % t.seg) + chk("D hält die Weiche rechts", t.switch_choice == 1) + chk("Übergang rechts ohne Sprung", jump_frames == 0, + "Sprungframes=%d" % jump_frames) + _key(KEY_D, false) + wait = 0.5 + 6: + var nxt: Array = t.next_station() + chk("Bergblick voraus", nxt[0].station_name == "Bergblick", "%s" % nxt[0].station_name) + wait = 0.5 + 7: + # bis kurz vor Bergblick fahren, dann bremsen + var berg = main.stations[5] + var ahead: float = t.distance_ahead(berg) + if ahead > t.speed * t.speed / 16.0 + 8.0: + step -= 1 + wait = 0.2 + else: + _key(KEY_W, false) + _key(KEY_S, true) + wait = 6.0 + 8: + _key(KEY_S, false) + chk("Zug steht", t.speed < 0.05, "v=%.2f" % t.speed) + chk("Am Bergblick angekommen", t.docked_at != null and t.docked_at.station_name == "Bergblick", + "dock=%s" % ("-" if t.docked_at == null else t.docked_at.station_name)) + # Sichtkontrolle: Kreuzung von oben + main._park_at(main.stations[4]) + t.place_head(2, main.track.seg_length(2) - 6.0) + main._toggle_camera() + main.cam_rig.distance = 95.0 + main.cam_rig.pitch = deg_to_rad(-70.0) + main.cam_rig.yaw = 0.4 + wait = 1.0 + 9: + get_viewport().get_texture().get_image().save_png("user://junction.png") + t.place_head(0, main.track.seg_length(0) - 20.0) + main.cam_rig.snap_to(t.cars[0].global_position) + wait = 1.0 + 10: + get_viewport().get_texture().get_image().save_png("user://junction2.png") + # Kamera-Kollision: Fokus auf den Bahnsteig, rundherum drehen + var st = main.stations[0] + main.cam_rig.target = null + main.cam_rig.snap_to(st.global_position + Vector3(0, 0, 0)) + main.cam_rig.distance = 30.0 + main.cam_rig.pitch = deg_to_rad(-10.0) + _cam_hits = 0 + _cam_yaw = 0 + wait = 0.2 + 11: + if _cam_yaw < 12: + main.cam_rig.yaw = _cam_yaw * TAU / 12.0 + _cam_yaw += 1 + step -= 1 + wait = 0.15 + if main.cam_rig.blocked: + _cam_hits += 1 + var dist: float = main.cam_rig._cam.global_position.distance_to(main.cam_rig.global_position) + if dist < 29.0: + _cam_pulled += 1 + else: + chk("Kamera stößt am Gebäude an", _cam_hits > 0, "%d von 12 Richtungen" % _cam_hits) + chk("Kamera rückt davor", _cam_pulled == _cam_hits, "%d/%d" % [_cam_pulled, _cam_hits]) + 12: + print("== %d ok, %d fail ==" % [ok, fail]) + get_tree().quit(1 if fail > 0 else 0) + step += 1