extends Node3D ## Weltaufbau, Spielstand, Crew-Aktionen und Netzwerk. ## Der Host ist massgeblich: er simuliert Zug, Fahrgäste und Kasse. ## Clients schicken Wuensche und bekommen Zustaende zurueck. const Mat = preload("res://scripts/mat.gd") const Deco = preload("res://scripts/deco.gd") const Cargo = preload("res://scripts/cargo.gd") const Defs = preload("res://scripts/car_defs.gd") const TrackScript = preload("res://scripts/track.gd") const WorldScript = preload("res://scripts/world.gd") const StationScript = preload("res://scripts/station.gd") const TrainScript = preload("res://scripts/train.gd") const PlayerScript = preload("res://scripts/player.gd") const PassengerScript = preload("res://scripts/passenger.gd") const AvatarScript = preload("res://scripts/avatar.gd") const CameraRig = preload("res://scripts/camera_rig.gd") const AudioScript = preload("res://scripts/audio.gd") const HudScript = preload("res://scripts/hud.gd") const ShopScript = preload("res://scripts/shop.gd") const MenuScript = preload("res://scripts/menu.gd") enum Mode { MENU, SOLO, HOST, CLIENT } ## 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, 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 const SPAWN_INTERVAL := 3.5 const BOARD_INTERVAL := 0.45 const LOAD_INTERVAL := 0.35 const DRINK_TIP := 14 const START_MONEY := 1500 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 var started := false var my_id := 1 var driver_id := 0 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 var train: Node3D var player: Node3D var hud: CanvasLayer var shop: CanvasLayer var menu: CanvasLayer var cam_rig: Node3D var audio: Node var stations: Array = [] var passengers: Array = [] var players: Dictionary = {} ## peer_id -> { "s", "x", "yaw", "carrying", "avatar" } var _rng := RandomNumberGenerator.new() var _spawn_timer := 0.0 var _board_timer := 0.0 var _load_timer := 0.0 var _pose_timer := 0.0 var _state_timer := 0.0 var _next_pid := 1 var _by_pid: Dictionary = {} var _exterior := false var _last_dock: Node = null var _last_speed := 0.0 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) multiplayer.peer_disconnected.connect(_on_peer_disconnected) multiplayer.connected_to_server.connect(_on_connected) multiplayer.connection_failed.connect(_on_connect_failed) multiplayer.server_disconnected.connect(_on_server_gone) func is_server() -> bool: return mode == Mode.SOLO or mode == Mode.HOST func networked() -> bool: return mode == Mode.HOST or mode == Mode.CLIENT # --- Start ---------------------------------------------------------------- func _start_solo() -> void: mode = Mode.SOLO 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) if err != OK: menu.set_status("Port %d belegt (Fehler %d)." % [port, err]) return multiplayer.multiplayer_peer = peer mode = Mode.HOST my_id = 1 _begin() hud.toast("Sitzung läuft auf Port %d" % port, Color("8fd8ff")) func _start_join(address: String, port: int) -> void: var peer := ENetMultiplayerPeer.new() var err := peer.create_client(address, port) if err != OK: menu.set_status("Verbindung nicht möglich (Fehler %d)." % err) return multiplayer.multiplayer_peer = peer mode = Mode.CLIENT menu.set_status("Verbinde mit %s:%d ..." % [address, port]) func _on_connected() -> void: my_id = multiplayer.get_unique_id() _begin() func _on_connect_failed() -> void: mode = Mode.MENU multiplayer.multiplayer_peer = null menu.set_status("Host nicht erreichbar.") func _on_server_gone() -> void: if hud: hud.toast("Verbindung zum Host verloren.", Color("ff9a8a")) func _begin() -> void: my_name = menu.player_name() _rng.seed = WORLD_SEED _build_environment() _build_track() _build_stations() world = WorldScript.new() add_child(world) world.build(_rng, track, stations) _build_train() _build_player() _build_ui() audio = AudioScript.new() add_child(audio) 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 --------------------------------------------------------------- func _build_environment() -> void: var sky_mat := ProceduralSkyMaterial.new() sky_mat.sky_top_color = Color("5fa8e0") sky_mat.sky_horizon_color = Color("cfe6f0") sky_mat.ground_bottom_color = Color("bdb6a6") sky_mat.ground_horizon_color = Color("d8e2e6") sky_mat.sun_angle_max = 30.0 var sky := Sky.new() sky.sky_material = sky_mat var env := Environment.new() env.background_mode = Environment.BG_SKY env.sky = sky env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.ambient_light_color = Color("d5dde2") env.ambient_light_energy = 0.62 env.tonemap_mode = Environment.TONE_MAPPER_FILMIC env.tonemap_white = 4.0 var we := WorldEnvironment.new() we.environment = env add_child(we) var sun := DirectionalLight3D.new() sun.rotation_degrees = Vector3(-48, -125, 0) sun.light_energy = 1.5 sun.light_color = Color("fff2dd") sun.shadow_enabled = true sun.light_angular_distance = 1.4 sun.directional_shadow_max_distance = 260.0 sun.shadow_bias = 0.06 add_child(sun) func _build_track() -> void: track = TrackScript.new() add_child(track) 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: for d in STATION_DEFS: var st := StationScript.new() add_child(st) var acc: Array[int] = [] for t in d[4]: acc.append(int(t)) 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) func _build_train() -> void: train = TrainScript.new() add_child(train) train.setup(track, stations) train.consist = ["coach", "freight"] as Array[String] train.rebuild() _park_at(stations[0]) ## Stellt den Zug mittig an einen Bahnsteig. func _park_at(st) -> void: 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() add_child(player) player.setup(train) player.interact_pressed.connect(_on_interact) cam_rig = CameraRig.new() add_child(cam_rig) cam_rig.target = train.cars[0] cam_rig.snap_to(train.cars[0].global_position) player.camera.current = true func _build_ui() -> void: hud = HudScript.new() add_child(hud) shop = ShopScript.new() add_child(shop) shop.setup(self) shop.closed.connect(_on_shop_closed) # --- Ablauf --------------------------------------------------------------- func _process(delta: float) -> void: if not started: return if is_server(): for st in stations: st.tick(delta) _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) _update_avatars() hud.refresh(self) func _physics_process(delta: float) -> void: if not started: return 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) 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") KEY_M: hud.toast("Musik %s" % ("an" if audio.toggle_music() else "aus"), Color("8fd8ff")) KEY_V: _toggle_camera() KEY_ESCAPE: if shop.is_open: shop.close() else: player.capture_mouse(Input.mouse_mode != Input.MOUSE_MODE_CAPTURED) func _toggle_camera() -> void: _exterior = not _exterior if _exterior: cam_rig.activate() else: player.camera.current = true # --- Klang ---------------------------------------------------------------- func _update_audio(delta: float) -> void: audio.update_train(train.speed, train.max_speed(), train.braking, delta) audio.update_steps(player.move_speed(), delta) # Ankunft und Auszischen beim Halten if train.docked_at != _last_dock: if train.docked_at != null and train.speed < 0.1: audio.play_at("bell", train.docked_at.global_position, -6.0) _last_dock = train.docked_at if _last_speed > 2.0 and train.speed < 0.2: audio.play_at("hiss", _loco_pos(), -10.0) _last_speed = train.speed func _loco_pos() -> Vector3: return train.cars[0].global_position if not train.cars.is_empty() else global_position func _play_sfx(name: String, at_loco := false, db := 0.0) -> void: if at_loco: audio.play_at(name, _loco_pos(), db) else: audio.play(name, db) ## Klang bei allen Mitspielern auslösen. func _broadcast_sfx(name: String, at_loco := false, db := 0.0) -> void: _play_sfx(name, at_loco, db) if mode == Mode.HOST: rpc("net_sfx", name, at_loco, db) ## Klang nur bei einem bestimmten Spieler. func _sfx_to(peer: int, name: String, db := 0.0) -> void: if peer == my_id: _play_sfx(name, false, db) elif networked(): rpc_id(peer, "net_sfx", name, false, db) # --- Netzwerk: Ausgang ---------------------------------------------------- func _sync_out(delta: float) -> void: if not networked(): players[my_id]["s"] = player.s players[my_id]["x"] = player.x players[my_id]["yaw"] = player.yaw return _pose_timer -= delta if _pose_timer <= 0.0: _pose_timer = 1.0 / POSE_HZ if is_server(): players[my_id]["s"] = player.s 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_state", _state_payload()) rpc("net_passengers", _passenger_payload()) func _pose_payload() -> Array: var out: Array = [] for id in players.keys(): var e: Dictionary = players[id] out.append([int(id), float(e["s"]), float(e["x"]), float(e["yaw"]), int(e["carrying"])]) return out 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: var out: Array = [] for p in passengers: out.append({ "id": p.pid, "st": p.state, "dest": p.dest_station, "fare": p.fare, "chk": p.ticket_checked, "want": p.wants_drink, "srv": p.served, "mood": p.mood, "car": p.seat_car, "seat": p.seat_index, "pos": p.global_position, "yaw": p.global_rotation.y, }) return out # --- Netzwerk: Eingang ---------------------------------------------------- @rpc("any_peer", "call_remote", "unreliable_ordered") func net_pose(s: float, x: float, yaw: float) -> void: var id := multiplayer.get_remote_sender_id() if not players.has(id): players[id] = _new_player_entry("Crew %d" % id) players[id]["s"] = s players[id]["x"] = x players[id]["yaw"] = yaw @rpc("authority", "call_remote", "unreliable_ordered") func net_poses(list: Array) -> void: var seen := {} for e in list: var id := int(e[0]) seen[id] = true if not players.has(id): players[id] = _new_player_entry("Crew %d" % id) if id != my_id: players[id]["s"] = float(e[1]) players[id]["x"] = float(e[2]) players[id]["yaw"] = float(e[3]) players[id]["carrying"] = int(e[4]) if id == my_id: player.carrying = int(e[4]) for id in players.keys(): if not seen.has(id): _drop_player(int(id)) @rpc("authority", "call_remote", "unreliable_ordered") 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: money = int(d["money"]) reputation = int(d["rep"]) upgrades.clear() 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: player.mode = PlayerScript.Mode.DRIVE var stock: Array = d["stock"] for i in mini(stock.size(), stations.size()): stations[i].stock = float(stock[i]) var loco := String(d["loco"]) var consist: Array = d["consist"] if loco != train.loco_id or consist != Array(train.consist): train.loco_id = loco var typed: Array[String] = [] for c in consist: typed.append(String(c)) train.consist = typed _rebuild_train() train.cargo = (d["cargo"] as Dictionary).duplicate() train.refresh_cargo_visuals() if shop.is_open: shop.refresh() @rpc("authority", "call_remote", "reliable") func net_passengers(list: Array) -> void: for c in train.cars: for sd in c.seats: sd["occupant"] = null var seen := {} for e in list: var pid := int(e["id"]) seen[pid] = true var p = _by_pid.get(pid) if p == null: p = _make_passenger(pid) p.state = int(e["st"]) p.dest_station = int(e["dest"]) p.fare = int(e["fare"]) p.ticket_checked = bool(e["chk"]) p.wants_drink = bool(e["want"]) p.served = bool(e["srv"]) p.mood = float(e["mood"]) p.seat_car = int(e["car"]) p.seat_index = int(e["seat"]) _place_remote_passenger(p, e) p._refresh_label() for pid in _by_pid.keys(): if not seen.has(pid): var p = _by_pid[pid] passengers.erase(p) p.queue_free() _by_pid.erase(pid) @rpc("authority", "call_remote", "reliable") func net_toast(text: String, color: Color) -> void: hud.toast(text, color) @rpc("any_peer", "call_remote", "reliable") 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, 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: if not is_server(): return _do_request(multiplayer.get_remote_sender_id(), action, a, b, c) func _place_remote_passenger(p, e: Dictionary) -> void: if p.state == PassengerScript.State.SEATED or p.state == PassengerScript.State.BOARDING: if p.seat_car < 0 or p.seat_car >= train.cars.size(): return var car = train.cars[p.seat_car] if p.seat_index < 0 or p.seat_index >= car.seats.size(): return if p.get_parent() != car: p.reparent(car, false) var seat: Dictionary = car.seats[p.seat_index] seat["occupant"] = p p.position = Vector3(seat["pos"].x, seat["pos"].y + 0.42 - 0.8, seat["pos"].z) p.rotation = Vector3(0, float(seat["yaw"]), 0) p.set_seated(true) else: if p.get_parent() != self: p.reparent(self, false) p.global_position = e["pos"] p.global_rotation = Vector3(0, float(e["yaw"]), 0) p.set_seated(false) func _make_passenger(pid: int): var p := PassengerScript.new() var r := RandomNumberGenerator.new() r.seed = pid * 7919 + 13 p.build(r) p.pid = pid add_child(p) passengers.append(p) _by_pid[pid] = p return p # --- Peers ---------------------------------------------------------------- func _on_peer_connected(id: int) -> void: if not is_server() or not started: return players[id] = _new_player_entry("Crew %d" % id) rpc_id(id, "net_state", _state_payload()) rpc_id(id, "net_passengers", _passenger_payload()) 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("%s hat den Zug verlassen" % pname, Color("ffd978")) func _drop_player(id: int) -> void: if id == my_id or not players.has(id): return var av = players[id]["avatar"] if av != null: av.queue_free() players.erase(id) func _update_avatars() -> void: for id in players.keys(): if int(id) == my_id: continue var e: Dictionary = players[id] if e["avatar"] == null: var av := AvatarScript.new() 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"])) # 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("%s%s" % [String(e["name"]), " (Führerstand)" if int(id) == driver_id else ""]) # --- Fahrgäste ----------------------------------------------------------- func _spawn_passengers(delta: float) -> void: _spawn_timer -= delta if _spawn_timer > 0.0: return _spawn_timer = SPAWN_INTERVAL spawn_passenger(_rng.randi() % stations.size()) func spawn_passenger(idx: int, forced_dest := -1) -> bool: var st = stations[idx] if st.waiting.size() >= st.waiting_spots.size(): return false var dest := forced_dest if dest < 0: dest = idx while dest == idx: dest = _rng.randi() % stations.size() var pid := _next_pid _next_pid += 1 var p = _make_passenger(pid) p.origin_station = idx p.dest_station = dest 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.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 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": bonus = 1.5 break return int(round((14.0 + d * 0.11) * bonus)) func _update_passengers(delta: float) -> void: var has_dining: bool = train.has_dining() for p in passengers.duplicate(): match p.state: PassengerScript.State.SEATED: p.tick(delta, has_dining) PassengerScript.State.BOARDING: if p.advance_lerp(delta): _finish_boarding(p) PassengerScript.State.ALIGHTING: if p.advance_lerp(delta): _finish_alighting(p) func _finish_boarding(p) -> void: var car = train.cars[p.seat_car] p.reparent(car, true) var seat: Dictionary = car.seats[p.seat_index] p.position = Vector3(seat["pos"].x, seat["pos"].y + 0.42 - 0.8, seat["pos"].z) p.rotation = Vector3(0, float(seat["yaw"]), 0) p.set_seated(true) p.state = PassengerScript.State.SEATED func _finish_alighting(p) -> void: passengers.erase(p) _by_pid.erase(p.pid) p.queue_free() func _handle_station_stop(delta: float) -> void: var st = train.docked_at if st == null or train.speed > 0.1: _board_timer = 0.0 return _board_timer -= delta if _board_timer > 0.0: return _board_timer = BOARD_INTERVAL for p in passengers: if p.state == PassengerScript.State.SEATED and stations[p.dest_station] == st: _alight(p, st) return if st.waiting.is_empty(): return var seat := _find_free_seat() if seat.is_empty(): return var p2 = st.waiting.pop_front() _reindex_waiting(st) p2.seat_car = int(seat["car"]) p2.seat_index = int(seat["index"]) var car = train.cars[p2.seat_car] var sd: Dictionary = car.seats[p2.seat_index] sd["occupant"] = p2 var target_xf: Transform3D = car.global_transform.translated_local( Vector3(sd["pos"].x, sd["pos"].y + 0.42 - 0.8, sd["pos"].z)) target_xf.basis = car.global_transform.basis.rotated(Vector3.UP, float(sd["yaw"])) p2.set_seated(true) p2.state = PassengerScript.State.BOARDING p2.start_lerp(p2.global_transform, target_xf, 1.4) func _alight(p, st) -> void: var car = train.cars[p.seat_car] car.seats[p.seat_index]["occupant"] = null p.reparent(self, true) p.set_seated(false) var spot: Vector3 = st.waiting_spots[_rng.randi() % st.waiting_spots.size()] p.state = PassengerScript.State.ALIGHTING p.start_lerp(p.global_transform, Transform3D(p.global_transform.basis, spot), 1.4) _settle_fare(p) func _settle_fare(p) -> void: if not p.ticket_checked: reputation = maxi(0, reputation - 2) _broadcast_toast("Schwarzfahrer entkommen. Ansehen -2", Color("ff9a8a")) _broadcast_sfx("deny", false, -10.0) return if p.mood > 0.55: reputation += 1 elif p.mood < 0.35: reputation = maxi(0, reputation - 1) func _reindex_waiting(st) -> void: for i in st.waiting.size(): st.waiting[i].global_position = st.waiting_spots[i] func _find_free_seat() -> Dictionary: for ci in train.cars.size(): var idx: int = train.cars[ci].free_seat() if idx >= 0: return {"car": ci, "index": idx} return {} # --- Aktionen ------------------------------------------------------------- func _on_interact(h: Dictionary) -> void: var kind := String(h["kind"]) audio.play("click", -16.0) match kind: "shop": _open_shop() "drive": request("drive", "", 0, 0) "tap": request("tap", "", 0, 0) "seat": request("seat", "", int(h["car"]), int(h["seat"])) ## Fuehrt eine Aktion aus - lokal beim Host, sonst als Anfrage. func request(action: String, a := "", b := 0, c := 0) -> void: if is_server(): _do_request(my_id, action, a, b, c) else: rpc_id(1, "net_request", action, a, b, c) func _do_request(peer: int, action: String, a: String, b: int, c: int) -> void: match action: "drive": _srv_toggle_drive(peer) "tap": _srv_take_drinks(peer) "seat": _srv_use_seat(peer, b, c) "load": _srv_transfer_one(peer) "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) func _toast_to(peer: int, text: String, color: Color) -> void: if peer == my_id: hud.toast(text, color) elif networked(): rpc_id(peer, "net_toast", text, color) func _broadcast_toast(text: String, color: Color) -> void: hud.toast(text, color) if networked(): rpc("net_toast", text, color) func _srv_toggle_drive(peer: int) -> void: if driver_id == peer: driver_id = 0 train.throttle = 0.0 _toast_to(peer, "Steuerung abgegeben", Color("cfd8e0")) elif driver_id == 0: driver_id = peer _toast_to(peer, "Du fährst jetzt. W = Gas, S = Bremse", Color("aef2a0")) else: _toast_to(peer, "Der Führerstand ist besetzt", Color("ffd978")) _sfx_to(peer, "deny", -12.0) if peer == my_id: player.mode = PlayerScript.Mode.DRIVE if driver_id == my_id else PlayerScript.Mode.WALK _push_state() func _srv_whistle(peer: int) -> void: if peer != driver_id: return _broadcast_sfx("whistle", true, -6.0) func _srv_take_drinks(peer: int) -> void: if not train.has_dining(): return var n := 3 if upgrades.has("trolley") else 1 _set_carrying(peer, n) _toast_to(peer, "%d Getränk(e) aufgenommen" % n, Color("8fd8ff")) _sfx_to(peer, "pour", -9.0) func _carrying(peer: int) -> int: return int(players.get(peer, {}).get("carrying", 0)) func _set_carrying(peer: int, n: int) -> void: if not players.has(peer): players[peer] = _new_player_entry("Crew %d" % peer) players[peer]["carrying"] = n if peer == my_id: player.carrying = n func _srv_use_seat(peer: int, car_index: int, seat_index: int) -> void: if car_index < 0 or car_index >= train.cars.size(): return var car = train.cars[car_index] if seat_index < 0 or seat_index >= car.seats.size(): return var occ = car.seats[seat_index]["occupant"] if occ == null: return if not occ.ticket_checked: if upgrades.has("scanner"): _check_all_tickets(peer) else: _check_ticket(occ, peer) return if occ.wants_drink and not occ.served: if _carrying(peer) <= 0: _toast_to(peer, "Kein Getränk dabei. Ab in den Speisewagen!", Color("ffd978")) _sfx_to(peer, "deny", -12.0) return _set_carrying(peer, _carrying(peer) - 1) occ.served = true occ.wants_drink = false occ.mood = minf(occ.mood + 0.4, 1.0) money += DRINK_TIP _toast_to(peer, "Serviert. Trinkgeld +%d €" % DRINK_TIP, Color("aef2a0")) _sfx_to(peer, "clink", -7.0) occ._refresh_label() _push_state() return _toast_to(peer, "%s - %s" % [stations[occ.dest_station].station_name, occ.mood_word()], Color("cfd8e0")) 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")) _sfx_to(peer, "coin", -6.0) p._refresh_label() _push_state() func _check_all_tickets(peer: int) -> void: var sum := 0 var n := 0 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() money += sum _toast_to(peer, "%d Tickets gescannt +%d €" % [n, sum], Color("aef2a0")) _sfx_to(peer, "coin", -4.0) _push_state() func _handle_hold_actions(delta: float) -> void: if player.input_locked or player.hotspot.is_empty() \ or String(player.hotspot.get("kind", "")) != "load" \ or not Input.is_key_pressed(KEY_E): _load_timer = 0.0 return if train.docked_at == null or train.speed > 0.1: return _load_timer -= delta if _load_timer > 0.0: return _load_timer = LOAD_INTERVAL * (0.5 if upgrades.has("loader") else 1.0) request("load") func _srv_transfer_one(peer: int) -> void: var st = train.docked_at if st == null or train.speed > 0.1: return for t in st.accepts: if int(train.cargo.get(t, 0)) > 0: train.remove_cargo(int(t), 1) money += Cargo.PRICES[int(t)] delivered_units += 1 if delivered_units % 10 == 0: reputation += 1 _toast_to(peer, "%s geliefert +%d €" % [Cargo.NAMES[int(t)], Cargo.PRICES[int(t)]], Color("aef2a0")) _broadcast_sfx("thud", false, -11.0) _sfx_to(peer, "coin", -12.0) _push_state() return if st.produces >= 0 and st.stock >= 1.0: if train.total_cargo() >= train.cargo_capacity(): _toast_to(peer, "Kein Platz mehr im Güterwagen", Color("ffd978")) _sfx_to(peer, "deny", -12.0) return if train.add_cargo(st.produces, 1) > 0: st.stock -= 1.0 _broadcast_sfx("thud", false, -11.0) _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: player.input_locked = true player.capture_mouse(false) shop.open() func _on_shop_closed() -> void: player.input_locked = false player.capture_mouse(true) func can_buy(price: int, rep: int) -> bool: return money >= price and reputation >= rep func _srv_buy_loco(peer: int, id: String) -> void: if not Defs.LOCOS.has(id): return var d: Dictionary = Defs.LOCOS[id] if id == train.loco_id or not can_buy(int(d["price"]), int(d["rep"])): return money -= int(d["price"]) money += int(Defs.LOCOS[train.loco_id]["sell"]) train.loco_id = id _rebuild_train() _broadcast_toast("Neue Lok: %s" % String(d["name"]), Color("aef2a0")) _broadcast_sfx("coin", false, -5.0) _push_state() func _srv_buy_car(peer: int, id: String) -> void: if not Defs.CARS.has(id): return var d: Dictionary = Defs.CARS[id] if not can_buy(int(d["price"]), int(d["rep"])) or train.consist.size() >= 6: return money -= int(d["price"]) train.consist.append(id) _rebuild_train() _broadcast_toast("Angehaengt: %s" % String(d["name"]), Color("aef2a0")) _push_state() func _srv_sell_car(peer: int, index: int) -> void: if index < 0 or index >= train.consist.size(): return var id: String = train.consist[index] money += int(Defs.CARS[id]["sell"]) train.consist.remove_at(index) _rebuild_train() _broadcast_toast("Verkauft: %s" % Defs.display_name(id), Color("ffd978")) _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 var d: Dictionary = Defs.UPGRADES[id] if upgrades.has(id) or not can_buy(int(d["price"]), int(d["rep"])): return money -= int(d["price"]) upgrades[id] = true _broadcast_toast("Ausbau: %s" % String(d["name"]), Color("aef2a0")) _broadcast_sfx("coin", false, -5.0) _push_state() # Bequeme Namen für Tests und die Ladenoberflaeche. func buy_loco(id: String) -> bool: var before: String = train.loco_id request("buy_loco", id) return train.loco_id != before func buy_car(id: String) -> bool: var before: int = train.consist.size() request("buy_car", id) return train.consist.size() != before func sell_car(index: int) -> bool: var before: int = train.consist.size() request("sell_car", "", index) return train.consist.size() != before func buy_upgrade(id: String) -> bool: var before := upgrades.size() 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 = [] for p in passengers.duplicate(): if p.state == PassengerScript.State.SEATED or p.state == PassengerScript.State.BOARDING: p.reparent(self, true) riders.append(p) train.rebuild() for p in riders: var seat := _find_free_seat() if seat.is_empty(): passengers.erase(p) _by_pid.erase(p.pid) p.queue_free() continue p.seat_car = int(seat["car"]) p.seat_index = int(seat["index"]) train.cars[p.seat_car].seats[p.seat_index]["occupant"] = p _finish_boarding(p) cam_rig.target = train.cars[0] player.snap_to_cab() if driver_id == my_id: player.mode = PlayerScript.Mode.DRIVE