Files
train-game/scripts/game.gd
2026-09-16 17:55:38 +02:00

1241 lines
38 KiB
GDScript

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