first commit

This commit is contained in:
team3
2026-08-29 11:44:31 +02:00
commit c9416ed3c6
66 changed files with 4821 additions and 0 deletions

157
scripts/audio.gd Normal file
View File

@@ -0,0 +1,157 @@
extends Node
## Spielt Musik und Klänge. Alles wird prozedural erzeugt - in einem
## Thread, damit der Start nicht hakt. Bis dahin bleibt es still.
const Sfx = preload("res://scripts/sfx.gd")
const Synth = preload("res://scripts/synth.gd")
const LOOPS := ["rumble", "squeal"] ## Klänge, die als Schleife laufen
const CLACK_DIST := 8.5 ## Meter zwischen zwei Radschlägen
const STEP_DIST := 1.75 ## Meter zwischen zwei Schritten
const MUSIC_DB := -15.0
const POOL := 10
const POOL_3D := 6
signal ready_for_play()
var sounds: Dictionary = {}
var music_on := true
var loaded := false
var clacks := 0 ## Zähler, damit sich das testen lässt
var last_played := "" ## zuletzt gestarteter Klang
var _music: AudioStreamPlayer
var _rumble: AudioStreamPlayer
var _squeal: AudioStreamPlayer
var _pool: Array[AudioStreamPlayer] = []
var _pool3d: Array[AudioStreamPlayer3D] = []
var _next := 0
var _next3d := 0
var _clack_acc := 0.0
var _clack_flip := false
var _step_acc := 0.0
var _thread: Thread
var _t0 := 0
func _ready() -> void:
_music = _make_player(MUSIC_DB)
_rumble = _make_player(-80.0)
_squeal = _make_player(-80.0)
for i in POOL:
_pool.append(_make_player(0.0))
for i in POOL_3D:
var p3 := AudioStreamPlayer3D.new()
p3.unit_size = 14.0
p3.max_distance = 160.0
add_child(p3)
_pool3d.append(p3)
_t0 = Time.get_ticks_msec()
_thread = Thread.new()
_thread.start(_build_async)
func _make_player(db: float) -> AudioStreamPlayer:
var p := AudioStreamPlayer.new()
p.volume_db = db
add_child(p)
return p
func _exit_tree() -> void:
if _thread != null and _thread.is_started():
_thread.wait_to_finish()
_thread = null
## 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)
func _finish_build(pcm: Dictionary) -> void:
if _thread != null:
_thread.wait_to_finish()
_thread = null
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()
_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])
ready_for_play.emit()
func toggle_music() -> bool:
music_on = not music_on
_music.volume_db = MUSIC_DB if music_on else -80.0
return music_on
# --- Abspielen ------------------------------------------------------------
func play(name: String, db := 0.0, pitch := 1.0) -> void:
if not loaded or not sounds.has(name):
return
last_played = name
var p := _pool[_next]
_next = (_next + 1) % POOL
p.stream = sounds[name]
p.volume_db = db
p.pitch_scale = pitch
p.play()
func play_at(name: String, pos: Vector3, db := 0.0, pitch := 1.0) -> void:
if not loaded or not sounds.has(name):
return
last_played = name
var p := _pool3d[_next3d]
_next3d = (_next3d + 1) % POOL_3D
p.stream = sounds[name]
p.global_position = pos
p.volume_db = db
p.pitch_scale = pitch
p.play()
# --- Laufende Geräusche ---------------------------------------------------
## speed in m/s, top als Bezugsgröße für Lautstärke und Tonhöhe.
func update_train(speed: float, top: float, braking: bool, delta: float) -> void:
if not loaded:
return
var f: float = clampf(speed / maxf(top, 1.0), 0.0, 1.4)
if speed < 0.15:
_rumble.volume_db = -80.0
else:
_rumble.volume_db = linear_to_db(clampf(0.10 + f * 0.65, 0.0, 1.0)) - 6.0
_rumble.pitch_scale = 0.75 + f * 0.55
var squealing: bool = braking and speed > 1.2
var target: float = linear_to_db(clampf(0.15 + f * 0.5, 0.0, 1.0)) - 8.0 if squealing else -80.0
_squeal.volume_db = move_toward(_squeal.volume_db, target, 260.0 * delta)
if squealing:
_squeal.pitch_scale = 0.8 + f * 0.5
if speed > 0.4:
_clack_acc += speed * delta
while _clack_acc >= CLACK_DIST:
_clack_acc -= CLACK_DIST
_clack_flip = not _clack_flip
clacks += 1
play("clack2" if _clack_flip else "clack",
linear_to_db(clampf(0.25 + f * 0.75, 0.0, 1.0)) - 8.0,
0.85 + f * 0.35)
else:
_clack_acc = 0.0
## Schritte, sobald sich der Spieler im Zug bewegt.
func update_steps(moving_speed: float, delta: float) -> void:
if not loaded or moving_speed < 0.4:
_step_acc = STEP_DIST * 0.55
return
_step_acc += moving_speed * delta
if _step_acc >= STEP_DIST:
_step_acc = 0.0
play("step", -19.0, randf_range(0.9, 1.12))

1
scripts/audio.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://u8s7xv36vrlk

76
scripts/avatar.gd Normal file
View File

@@ -0,0 +1,76 @@
extends Node3D
## Sichtbare Figur der Mitspieler.
const Mat = preload("res://scripts/mat.gd")
const Deco = preload("res://scripts/deco.gd")
const COLORS := ["d8a33c", "3c9dd8", "6fbf5a", "cf5f8f", "9b6fd8", "d87a3c"]
var _label: Label3D
var _tray: MeshInstance3D
func build(peer_id: int, label_text: String) -> void:
var col := Color(COLORS[abs(peer_id) % COLORS.size()])
var dark := Color("2b3138")
var legs := MeshInstance3D.new()
legs.mesh = Deco.box(Vector3(0.42, 0.82, 0.3))
legs.material_override = Mat.flat(dark)
legs.position = Vector3(0, 0.41, 0)
add_child(legs)
var torso := MeshInstance3D.new()
torso.mesh = Deco.box(Vector3(0.52, 0.64, 0.34))
torso.material_override = Mat.flat(col)
torso.position = Vector3(0, 1.14, 0)
add_child(torso)
for sx: float in [-1.0, 1.0]:
var arm := MeshInstance3D.new()
arm.mesh = Deco.box(Vector3(0.13, 0.6, 0.19))
arm.material_override = Mat.flat(col.darkened(0.12))
arm.position = Vector3(sx * 0.33, 1.12, 0)
add_child(arm)
var head := MeshInstance3D.new()
head.mesh = Deco.blob(0.18)
head.material_override = Mat.flat(Color("e8c39e"))
head.position = Vector3(0, 1.6, 0)
add_child(head)
var cap := MeshInstance3D.new()
cap.mesh = Deco.box(Vector3(0.38, 0.14, 0.38))
cap.material_override = Mat.flat(dark)
cap.position = Vector3(0, 1.75, 0)
add_child(cap)
var peak := MeshInstance3D.new()
peak.mesh = Deco.box(Vector3(0.38, 0.05, 0.16))
peak.material_override = Mat.flat(dark)
peak.position = Vector3(0, 1.7, 0.24)
add_child(peak)
_tray = MeshInstance3D.new()
_tray.mesh = Deco.box(Vector3(0.26, 0.3, 0.26))
_tray.material_override = Mat.flat(Color("8fd8ff"))
_tray.position = Vector3(0.2, 1.25, 0.3)
_tray.visible = false
add_child(_tray)
_label = Label3D.new()
_label.text = label_text
_label.font_size = 44
_label.pixel_size = 0.006
_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_label.no_depth_test = true
_label.outline_size = 12
_label.outline_modulate = Color(0, 0, 0, 0.7)
_label.modulate = col
_label.position = Vector3(0, 2.05, 0)
add_child(_label)
func set_carrying(n: int) -> void:
_tray.visible = n > 0
func set_label(text: String) -> void:
_label.text = text

1
scripts/avatar.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://l8juemk6wh5s

61
scripts/camera_rig.gd Normal file
View File

@@ -0,0 +1,61 @@
extends Node3D
## Aussenkamera: folgt dem Zug, drehbar und zoombar.
const MIN_DIST := 14.0
const MAX_DIST := 140.0
var target: Node3D
var yaw := 0.0
var pitch := deg_to_rad(-34.0)
var distance := 46.0
var _cam: Camera3D
var _focus := Vector3.ZERO
func _ready() -> void:
_cam = Camera3D.new()
_cam.fov = 50.0
_cam.far = 900.0
add_child(_cam)
func activate() -> void:
_cam.current = true
func is_active() -> bool:
return _cam.current
func _unhandled_input(event: InputEvent) -> void:
if not is_active():
return
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
distance = clampf(distance * 0.88, MIN_DIST, MAX_DIST)
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
distance = clampf(distance * 1.14, MIN_DIST, MAX_DIST)
func _process(delta: float) -> void:
if not is_active():
if target:
_focus = target.global_position
return
var turn := 0.0
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
turn += 1.0
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
turn -= 1.0
yaw += turn * 1.5 * delta
if Input.is_key_pressed(KEY_R):
pitch = clampf(pitch + 0.9 * delta, deg_to_rad(-82.0), deg_to_rad(-8.0))
if Input.is_key_pressed(KEY_F):
pitch = clampf(pitch - 0.9 * delta, deg_to_rad(-82.0), deg_to_rad(-8.0))
if target:
_focus = _focus.lerp(target.global_position, clampf(delta * 5.0, 0.0, 1.0))
position = _focus
var dir := Vector3(0, 0, 1).rotated(Vector3.RIGHT, pitch).rotated(Vector3.UP, yaw)
_cam.position = dir * distance
_cam.look_at(global_position + Vector3(0, 2.5, 0), Vector3.UP)
func snap_to(p: Vector3) -> void:
_focus = p
position = p

View File

@@ -0,0 +1 @@
uid://uk8lvq4xilb8

364
scripts/car.gd Normal file
View File

@@ -0,0 +1,364 @@
extends Node3D
## Ein Fahrzeug des Zugverbands: Aussenhuelle, begehbarer Innenraum,
## Sitzplätze und Interaktionspunkte. Alles in lokalen Koordinaten,
## +Z zeigt in Fahrtrichtung.
const Mat = preload("res://scripts/mat.gd")
const Deco = preload("res://scripts/deco.gd")
const Defs = preload("res://scripts/car_defs.gd")
const OUTER_HALF := 1.5
const INNER_HALF := 1.36
const FLOOR_Y := 1.10
const CEIL_Y := 3.50
const WALL_T := 0.12
const GANGWAY_HALF := 0.48
const UNDER := Color("3a3a40")
const FLOOR_COL := Color("8a6b4a")
const CEIL_COL := Color("efe9dc")
const SEAT_COL := Color("356b8f")
const SEAT_COL2 := Color("8f3550")
const METAL := Color("55575c")
const CONSOLE := Color("2f3338")
var id := ""
var kind := 0
var length := 12.0
var index := 0 ## Position im Verband, 0 = Lok
var center_s := 0.0 ## Laengsposition der Waggonmitte im Zug
var inner_z0 := 0.0 ## begehbarer Bereich, lokal
var inner_z1 := 0.0
var blockers: Array[AABB] = [] ## Hindernisse im Innenraum
var seats: Array = [] ## [{ "pos": Vector3, "yaw": float, "occupant": null }]
var hotspots: Array = [] ## [{ "pos": Vector3, "kind": String, "seat": int }]
var capacity := 0
var _cargo_stack: Array[MeshInstance3D] = []
func build(car_id: String) -> void:
id = car_id
kind = Defs.kind_of(car_id)
length = Defs.car_length(car_id)
var d: Dictionary = Defs.LOCOS[car_id] if Defs.LOCOS.has(car_id) else Defs.CARS[car_id]
var body := Color(String(d["color"]))
var trim := Color(String(d["trim"]))
capacity = int(d.get("capacity", 0))
inner_z0 = -length * 0.5 + 0.16
inner_z1 = length * 0.5 - 0.16
if kind == Defs.Kind.LOCO:
inner_z1 = length * 0.5 - 4.6 # vorne sitzt der Maschinenraum
_chassis(trim)
_floor_and_ceiling()
if kind == Defs.Kind.LOCO:
_loco_shell(body, trim)
elif kind == Defs.Kind.FREIGHT:
_freight_shell(body, trim)
else:
_coach_shell(body, trim)
_lights()
match kind:
Defs.Kind.LOCO: _fit_loco()
Defs.Kind.PASSENGER: _fit_passenger(int(d["seats"]))
Defs.Kind.DINING: _fit_dining(int(d["seats"]))
Defs.Kind.FREIGHT: _fit_freight()
## Deckenlampen - ohne sie waere vor allem der Güterwagen stockdunkel.
func _lights() -> void:
var n := maxi(1, int((inner_z1 - inner_z0) / 5.0))
for i in n:
var t := (i + 0.5) / float(n)
var z := lerpf(inner_z0, inner_z1, t)
var l := OmniLight3D.new()
l.position = Vector3(0, CEIL_Y - 0.42, z)
l.light_color = Color("ffe8c8")
l.light_energy = 2.6
l.omni_range = 8.5
l.omni_attenuation = 1.3
l.shadow_enabled = false
add_child(l)
_box(Vector3(0, CEIL_Y - 0.16, z), Vector3(0.55, 0.09, 0.55), Color("fff5e0"))
# --- Bausteine ------------------------------------------------------------
func _box(center: Vector3, size: Vector3, color: Color, block := false) -> MeshInstance3D:
var m := MeshInstance3D.new()
m.mesh = Deco.box(size)
m.material_override = Mat.flat(color)
m.position = center
add_child(m)
if block:
blockers.append(AABB(center - size * 0.5, size))
return m
func _chassis(trim: Color) -> void:
_box(Vector3(0, 0.72, 0), Vector3(2.86, 0.42, length - 0.5), UNDER)
for sz: float in [-1.0, 1.0]:
_bogie(sz * length * 0.31)
_box(Vector3(0, 0.72, sz * (length * 0.5 - 0.1)), Vector3(0.34, 0.26, 0.5), METAL)
# Puffer
for sz: float in [-1.0, 1.0]:
for sx: float in [-1.0, 1.0]:
_box(Vector3(sx * 0.75, 0.8, sz * (length * 0.5 + 0.06)),
Vector3(0.28, 0.28, 0.16), trim)
func _bogie(z: float) -> void:
_box(Vector3(0, 0.55, z), Vector3(1.9, 0.34, 2.5), UNDER)
for az: float in [-0.9, 0.9]:
for sx: float in [-1.0, 1.0]:
var w := MeshInstance3D.new()
w.mesh = Deco.cyl(0.42, 0.18, 10)
w.material_override = Mat.flat(METAL)
w.position = Vector3(sx * 0.78, 0.42, z + az)
w.rotate_z(PI * 0.5)
add_child(w)
func _floor_and_ceiling() -> void:
var iz0 := -length * 0.5 + 0.06
var iz1 := length * 0.5 - 0.06
var l := iz1 - iz0
_box(Vector3(0, FLOOR_Y - 0.06, (iz0 + iz1) * 0.5), Vector3(2.78, 0.12, l), FLOOR_COL)
if kind != Defs.Kind.LOCO:
_box(Vector3(0, CEIL_Y - 0.05, 0), Vector3(2.78, 0.1, l), CEIL_COL)
else:
_box(Vector3(0, CEIL_Y - 0.05, (inner_z0 + inner_z1) * 0.5),
Vector3(2.78, 0.1, inner_z1 - inner_z0), CEIL_COL)
## Stirnwand mit Durchgang. open = Tuer vorhanden.
func _end_wall(z: float, color: Color, open: bool) -> void:
var t := 0.14
if not open:
_box(Vector3(0, (0.94 + CEIL_Y) * 0.5, z), Vector3(2.9, CEIL_Y - 0.94, t), color, true)
return
var door_h := FLOOR_Y + 2.05
for sx: float in [-1.0, 1.0]:
var w := (1.45 - 0.5)
_box(Vector3(sx * (0.5 + w * 0.5), (0.94 + CEIL_Y) * 0.5, z),
Vector3(w, CEIL_Y - 0.94, t), color, true)
_box(Vector3(0, (door_h + CEIL_Y) * 0.5, z), Vector3(1.0, CEIL_Y - door_h, t), color)
# Faltenbalg nach hinten
_box(Vector3(0, FLOOR_Y - 0.05, z + sign(z) * 0.2), Vector3(1.1, 0.1, 0.4), UNDER)
func _roof(color: Color) -> void:
_box(Vector3(0, CEIL_Y + 0.12, 0), Vector3(3.02, 0.16, length), color)
_box(Vector3(0, CEIL_Y + 0.24, 0), Vector3(2.3, 0.1, length - 0.6), color.darkened(0.12))
## Seitenwand mit Fensterband.
func _windowed_side(color: Color, trim: Color, z0: float, z1: float) -> void:
var win_b := FLOOR_Y + 0.78
var win_t := FLOOR_Y + 1.86
var seg := z1 - z0
var mid := (z0 + z1) * 0.5
for sx: float in [-1.0, 1.0]:
var x := sx * (OUTER_HALF - WALL_T * 0.5)
_box(Vector3(x, (0.94 + win_b) * 0.5, mid), Vector3(WALL_T, win_b - 0.94, seg), color, true)
_box(Vector3(x, (win_t + CEIL_Y) * 0.5, mid), Vector3(WALL_T, CEIL_Y - win_t, seg), color)
_box(Vector3(x, win_b + 0.03, mid), Vector3(WALL_T + 0.08, 0.08, seg), trim)
var n := maxi(2, int(seg / 2.3))
var cell := seg / float(n)
for i in n + 1:
var pz := z0 + i * cell
var w := 0.52
if i == 0:
pz += 0.3
w = 0.6
elif i == n:
pz -= 0.3
w = 0.6
_box(Vector3(x, (win_b + win_t) * 0.5, pz), Vector3(WALL_T, win_t - win_b, w), color)
# Unsichtbare Wand ueber die volle Hoehe haelt Spieler drinnen
blockers.append(AABB(
Vector3(x - 0.1 if sx > 0 else x - 0.02, FLOOR_Y, z0),
Vector3(0.12, 2.2, seg)))
func _solid_side(color: Color, trim: Color, z0: float, z1: float) -> void:
var seg := z1 - z0
var mid := (z0 + z1) * 0.5
for sx: float in [-1.0, 1.0]:
var x := sx * (OUTER_HALF - WALL_T * 0.5)
_box(Vector3(x, (0.94 + CEIL_Y) * 0.5, mid), Vector3(WALL_T, CEIL_Y - 0.94, seg), color, true)
# Schiebetuer als Farbakzent
_box(Vector3(x + sx * 0.02, FLOOR_Y + 1.0, mid), Vector3(0.06, 1.9, 2.4), trim)
func _coach_shell(body: Color, trim: Color) -> void:
_windowed_side(body, trim, -length * 0.5 + 0.06, length * 0.5 - 0.06)
_end_wall(length * 0.5 - 0.07, body.darkened(0.1), true)
_end_wall(-length * 0.5 + 0.07, body.darkened(0.1), true)
_roof(trim)
func _freight_shell(body: Color, trim: Color) -> void:
_solid_side(body, trim, -length * 0.5 + 0.06, length * 0.5 - 0.06)
# Helle Innenverkleidung, damit sich die Ladung abhebt
for sx: float in [-1.0, 1.0]:
_box(Vector3(sx * (OUTER_HALF - WALL_T - 0.03), (FLOOR_Y + CEIL_Y) * 0.5, 0),
Vector3(0.05, CEIL_Y - FLOOR_Y, length - 0.4), Color("d9d2c4"))
_end_wall(length * 0.5 - 0.07, body.darkened(0.1), true)
_end_wall(-length * 0.5 + 0.07, body.darkened(0.1), true)
_roof(trim)
func _loco_shell(body: Color, trim: Color) -> void:
var cab0 := inner_z0
var cab1 := inner_z1
# Fuehrerhaus
_windowed_side(body, trim, cab0 + 0.06, cab1)
_end_wall(cab0 + 0.07, body.darkened(0.1), true)
# Frontscheibe: Wand mit grossem Ausschnitt
_box(Vector3(0, CEIL_Y - 0.25, cab1), Vector3(2.9, 0.5, 0.14), body, true)
for sx: float in [-1.0, 1.0]:
_box(Vector3(sx * 1.2, FLOOR_Y + 1.35, cab1), Vector3(0.5, 2.2, 0.14), body, true)
_box(Vector3(0, FLOOR_Y + 0.42, cab1), Vector3(2.9, 0.85, 0.14), body, true)
_box(Vector3(0, CEIL_Y + 0.12, (cab0 + cab1) * 0.5),
Vector3(3.02, 0.16, cab1 - cab0 + 0.2), trim)
# Maschinenraum vorne
var nz0 := cab1
var nz1 := length * 0.5
var nl := nz1 - nz0
_box(Vector3(0, FLOOR_Y + 0.42, nz1 + 0.08), Vector3(2.8, 0.5, 0.3), Color("d8b24a"))
_box(Vector3(0, FLOOR_Y - 0.05, (nz0 + nz1) * 0.5), Vector3(2.5, 0.3, nl), UNDER)
if id == "steam":
# Runder Kessel statt Kasten
var boiler := MeshInstance3D.new()
boiler.mesh = Deco.cyl(0.92, nl - 0.2, 12)
boiler.material_override = Mat.flat(body)
boiler.position = Vector3(0, FLOOR_Y + 0.55, (nz0 + nz1) * 0.5)
boiler.rotate_x(PI * 0.5)
add_child(boiler)
var front := MeshInstance3D.new()
front.mesh = Deco.cyl(0.99, 0.3, 12)
front.material_override = Mat.flat(trim)
front.position = Vector3(0, FLOOR_Y + 0.55, nz1 - 0.15)
front.rotate_x(PI * 0.5)
add_child(front)
var stack := MeshInstance3D.new()
stack.mesh = Deco.cyl(0.27, 1.0, 8)
stack.material_override = Mat.flat(UNDER)
stack.position = Vector3(0, FLOOR_Y + 1.95, nz1 - 0.9)
add_child(stack)
_box(Vector3(0, FLOOR_Y + 1.62, nz1 - 2.6), Vector3(0.66, 0.6, 0.66), trim)
else:
_box(Vector3(0, FLOOR_Y + 0.55, (nz0 + nz1) * 0.5), Vector3(2.6, 1.3, nl), body)
_box(Vector3(0, FLOOR_Y + 1.25, (nz0 + nz1) * 0.5), Vector3(2.2, 0.18, nl), trim)
_box(Vector3(0, FLOOR_Y + 1.5, (nz0 + nz1) * 0.5), Vector3(1.4, 0.3, nl - 1.2), UNDER)
# --- Innenausbau ----------------------------------------------------------
func _seat(pos: Vector3, yaw: float, col: Color) -> void:
var s := Node3D.new()
s.position = pos
s.rotation.y = yaw
add_child(s)
var base := MeshInstance3D.new()
base.mesh = Deco.box(Vector3(0.92, 0.4, 0.56))
base.material_override = Mat.flat(col)
base.position = Vector3(0, 0.2, 0)
s.add_child(base)
var back := MeshInstance3D.new()
back.mesh = Deco.box(Vector3(0.92, 0.62, 0.14))
back.material_override = Mat.flat(col.darkened(0.12))
back.position = Vector3(0, 0.71, -0.21)
s.add_child(back)
blockers.append(AABB(pos + Vector3(-0.46, 0, -0.32), Vector3(0.92, 0.72, 0.64)))
func _fit_passenger(seat_count: int) -> void:
var rows := int(ceil(seat_count / 2.0))
var span := inner_z1 - inner_z0 - 1.6
var gap := span / float(maxi(1, rows - 1)) if rows > 1 else 0.0
var z0 := inner_z0 + 0.8
var placed := 0
for r in rows:
var z := z0 + gap * r
for sx: float in [-1.0, 1.0]:
if placed >= seat_count:
break
var pos := Vector3(sx * 0.92, FLOOR_Y, z)
_seat(pos, 0.0, SEAT_COL if sx < 0 else SEAT_COL2)
seats.append({"pos": pos, "yaw": 0.0, "occupant": null})
hotspots.append({"pos": pos + Vector3(0, 0.9, 0), "kind": "seat", "seat": seats.size() - 1})
placed += 1
# Tisch am Fenster
if r % 2 == 1:
for sx: float in [-1.0, 1.0]:
_box(Vector3(sx * 1.0, FLOOR_Y + 0.62, z + 0.55), Vector3(0.7, 0.06, 0.5), Color("caa87a"), true)
func _fit_dining(seat_count: int) -> void:
var z0 := inner_z0 + 0.6
var z1 := inner_z1 - 0.6
# Theke links
_box(Vector3(-0.85, FLOOR_Y + 0.52, (z0 + z1) * 0.5 - 1.0),
Vector3(0.9, 1.04, (z1 - z0) * 0.55), Color("7a4f2c"), true)
_box(Vector3(-0.85, FLOOR_Y + 1.06, (z0 + z1) * 0.5 - 1.0),
Vector3(1.0, 0.06, (z1 - z0) * 0.55), Color("caa87a"))
# Zapfstelle
var tap_pos := Vector3(-0.45, FLOOR_Y + 1.1, (z0 + z1) * 0.5 - 1.0)
_box(tap_pos, Vector3(0.3, 0.5, 0.3), Color("c8b56a"))
hotspots.append({"pos": tap_pos + Vector3(0.35, 0.1, 0), "kind": "tap", "seat": -1})
# Regal
_box(Vector3(-1.15, FLOOR_Y + 1.7, (z0 + z1) * 0.5 - 1.0),
Vector3(0.35, 0.7, (z1 - z0) * 0.4), Color("8f6a45"))
# Sitzecke rechts
var placed := 0
for i in int(ceil(seat_count / 2.0)):
var z := z1 - 1.2 - i * 2.2
for sz: float in [-1.0, 1.0]:
if placed >= seat_count:
break
var pos := Vector3(0.95, FLOOR_Y, z + sz * 0.65)
_seat(pos, 0.0 if sz < 0 else PI, Color("6b8f55"))
seats.append({"pos": pos, "yaw": 0.0 if sz < 0 else PI, "occupant": null})
hotspots.append({"pos": pos + Vector3(0, 0.9, 0), "kind": "seat", "seat": seats.size() - 1})
placed += 1
_box(Vector3(0.95, FLOOR_Y + 0.62, z), Vector3(0.8, 0.06, 0.7), Color("caa87a"), true)
func _fit_freight() -> void:
var console_pos := Vector3(1.05, FLOOR_Y + 1.0, inner_z1 - 1.0)
_box(console_pos, Vector3(0.4, 0.8, 0.7), CONSOLE, true)
_box(console_pos + Vector3(-0.22, 0.25, 0), Vector3(0.06, 0.3, 0.5), Color("6ad0a0"))
hotspots.append({"pos": console_pos + Vector3(-0.4, 0.1, 0), "kind": "load", "seat": -1})
# Ladeplaetze: Stapel werden je nach Füllstand sichtbar
var slots := capacity
var per_row := 2
var rows := int(ceil(float(slots) / per_row))
var z0 := inner_z0 + 1.0
var span := (inner_z1 - 2.4) - z0
for i in slots:
var r := i / per_row
var c := i % per_row
var z: float = z0 + (span * r / float(maxi(1, rows - 1)) if rows > 1 else 0.0)
var m := _box(Vector3((-0.6 if c == 0 else 0.6), FLOOR_Y + 0.35, z),
Vector3(0.85, 0.7, 0.85), Color.WHITE)
m.visible = false
_cargo_stack.append(m)
func _fit_loco() -> void:
var z := inner_z1 - 0.75
_box(Vector3(0, FLOOR_Y + 0.5, z), Vector3(1.9, 1.0, 0.5), CONSOLE, true)
_box(Vector3(0, FLOOR_Y + 1.02, z), Vector3(2.0, 0.08, 0.7), Color("4a4f55"))
for sx: float in [-1.0, 1.0]:
_box(Vector3(sx * 0.55, FLOOR_Y + 1.14, z), Vector3(0.16, 0.3, 0.16), Color("d8b24a"))
hotspots.append({"pos": Vector3(0, FLOOR_Y + 1.2, z - 0.55), "kind": "drive", "seat": -1})
# Hocker
_box(Vector3(-0.9, FLOOR_Y + 0.25, z - 1.1), Vector3(0.5, 0.5, 0.5), Color("6b4a33"), true)
# Depot-Terminal für den Laden
var term := Vector3(1.15, FLOOR_Y + 1.1, inner_z0 + 1.2)
_box(term, Vector3(0.3, 0.9, 0.8), CONSOLE, true)
_box(term + Vector3(-0.17, 0.2, 0), Vector3(0.06, 0.45, 0.6), Color("7ab7e0"))
hotspots.append({"pos": term + Vector3(-0.4, 0.0, 0), "kind": "shop", "seat": -1})
# --- Laufzeit -------------------------------------------------------------
func show_cargo(count: int, color: Color) -> void:
for i in _cargo_stack.size():
var m := _cargo_stack[i]
m.visible = i < count
if m.visible:
m.material_override = Mat.flat(color)
func free_seat() -> int:
for i in seats.size():
if seats[i]["occupant"] == null:
return i
return -1

1
scripts/car.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://bsy0ylapauwtw

102
scripts/car_defs.gd Normal file
View File

@@ -0,0 +1,102 @@
extends RefCounted
## Stammdaten für Loks, Waggons und Ausbauten.
enum Kind { LOCO, PASSENGER, FREIGHT, DINING }
## Loks. "rep" = benoetigtes Ansehen zum Freischalten.
const LOCOS := {
"steam": {
"name": "Dampf-Veteran", "price": 0, "sell": 0, "rep": 0,
"length": 11.0, "max_speed": 15.0, "accel": 3.0, "brake": 8.0,
"color": "a83b30", "trim": "7d2a22",
"desc": "Gemütlich, laut, zuverlässig.",
},
"diesel": {
"name": "Diesel-Kurier", "price": 3200, "sell": 1900, "rep": 12,
"length": 12.0, "max_speed": 24.0, "accel": 4.6, "brake": 10.0,
"color": "2f6f4e", "trim": "24523b",
"desc": "Schneller anfahren, höhere Endgeschwindigkeit.",
},
"express": {
"name": "Elektro-Express", "price": 11000, "sell": 6600, "rep": 40,
"length": 13.0, "max_speed": 34.0, "accel": 6.8, "brake": 13.5,
"color": "2c4f8a", "trim": "1e3a68",
"desc": "Der Fernverkehr lässt grüßen.",
},
}
## Waggons.
const CARS := {
"coach": {
"name": "Personenwagen", "kind": Kind.PASSENGER, "price": 900, "sell": 540, "rep": 0,
"length": 12.0, "seats": 8, "capacity": 0,
"color": "c8ccd2", "trim": "8f959c",
"desc": "Acht Sitzplätze, schlichte Ausstattung.",
},
"saloon": {
"name": "Salonwagen", "kind": Kind.PASSENGER, "price": 2600, "sell": 1560, "rep": 18,
"length": 13.0, "seats": 10, "capacity": 0,
"color": "e8dcc0", "trim": "b09a6d",
"desc": "Zehn Plätze. Gäste zahlen 50 % mehr.",
"fare_bonus": 0.5,
},
"freight": {
"name": "Güterwagen", "kind": Kind.FREIGHT, "price": 700, "sell": 420, "rep": 0,
"length": 11.0, "seats": 0, "capacity": 10,
"color": "8a5c3c", "trim": "6b4630",
"desc": "Zehn Einheiten Ladung.",
},
"hopper": {
"name": "Großgüterwagen", "kind": Kind.FREIGHT, "price": 2100, "sell": 1260, "rep": 14,
"length": 13.0, "seats": 0, "capacity": 22,
"color": "6b7a52", "trim": "4f5c3c",
"desc": "22 Einheiten Ladung.",
},
"dining": {
"name": "Speisewagen", "kind": Kind.DINING, "price": 1900, "sell": 1140, "rep": 8,
"length": 12.0, "seats": 4, "capacity": 0,
"color": "b8874a", "trim": "8d6435",
"desc": "Hier gibt es Getränke für die Gäste.",
},
}
## Dauerhafte Ausbauten, einmal kaufbar.
const UPGRADES := {
"loader": {
"name": "Schnelllader", "price": 1400, "rep": 6,
"desc": "Be- und Entladen läuft doppelt so schnell.",
},
"scanner": {
"name": "Ticket-Scanner", "price": 1100, "rep": 10,
"desc": "Ticketkontrolle in einem Rutsch statt Gast für Gast.",
},
"trolley": {
"name": "Servierwagen", "price": 1800, "rep": 20,
"desc": "Trägt drei Getränke statt einem.",
},
"schedule": {
"name": "Fahrplan-Tafel", "price": 900, "rep": 4,
"desc": "Zeigt Ziel und Wunsch jedes Gastes im HUD.",
},
}
static func loco(id: String) -> Dictionary:
return LOCOS[id]
static func car(id: String) -> Dictionary:
return CARS[id]
static func car_length(id: String) -> float:
if LOCOS.has(id):
return float(LOCOS[id]["length"])
return float(CARS[id]["length"])
static func kind_of(id: String) -> int:
if LOCOS.has(id):
return Kind.LOCO
return int(CARS[id]["kind"])
static func display_name(id: String) -> String:
if LOCOS.has(id):
return String(LOCOS[id]["name"])
return String(CARS[id]["name"])

1
scripts/car_defs.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://6cosvd7thiko

12
scripts/cargo.gd Normal file
View File

@@ -0,0 +1,12 @@
extends RefCounted
## Warentypen.
enum Type { WOOD, COAL, GRAIN }
const NAMES := ["Holz", "Kohle", "Getreide"]
const PRICES := [12, 20, 9]
const COLORS := [
Color("9c6b3f"), # Holz
Color("2e2e33"), # Kohle
Color("e0b73f"), # Getreide
]

1
scripts/cargo.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dx8x0i2u3x75m

95
scripts/deco.gd Normal file
View File

@@ -0,0 +1,95 @@
extends RefCounted
## Low-Poly Bausteine aus Godot-Primitiven.
const Mat = preload("res://scripts/mat.gd")
const TRUNK := Color("6b4a2f")
const PINE := [Color("2f7a45"), Color("35894e"), Color("276b3c")]
const LEAF := [Color("58a844"), Color("e0932f"), Color("d8c33c"), Color("7cb85a")]
const ROCK := Color("8c8b86")
static func _mi(mesh: Mesh, color: Color, pos: Vector3) -> MeshInstance3D:
var m := MeshInstance3D.new()
m.mesh = mesh
m.material_override = Mat.flat(color)
m.position = pos
return m
static func cone(bottom_r: float, height: float, sides: int = 7) -> CylinderMesh:
var c := CylinderMesh.new()
c.top_radius = 0.0
c.bottom_radius = bottom_r
c.height = height
c.radial_segments = sides
c.rings = 1
return c
static func cyl(r: float, height: float, sides: int = 7) -> CylinderMesh:
var c := CylinderMesh.new()
c.top_radius = r
c.bottom_radius = r
c.height = height
c.radial_segments = sides
c.rings = 1
return c
static func box(size: Vector3) -> BoxMesh:
var b := BoxMesh.new()
b.size = size
return b
static func blob(r: float) -> SphereMesh:
var s := SphereMesh.new()
s.radius = r
s.height = r * 1.9
s.radial_segments = 7
s.rings = 4
return s
## Nadelbaum: Stamm + zwei bis drei Kegel-Etagen.
static func pine_tree(rng: RandomNumberGenerator) -> Node3D:
var n := Node3D.new()
var h := rng.randf_range(3.4, 6.2)
var col: Color = PINE[rng.randi() % PINE.size()]
n.add_child(_mi(cyl(0.18, h * 0.3, 6), TRUNK, Vector3(0, h * 0.15, 0)))
var tiers := 3 if h > 4.6 else 2
for i in tiers:
var f := float(i) / float(tiers)
var r := lerpf(h * 0.28, h * 0.13, f)
var th := h * 0.42
var y := h * 0.26 + f * h * 0.32 + th * 0.5
n.add_child(_mi(cone(r, th, 7), col, Vector3(0, y, 0)))
n.rotate_y(rng.randf() * TAU)
return n
## Laubbaum: Stamm + facettierte Krone.
static func round_tree(rng: RandomNumberGenerator) -> Node3D:
var n := Node3D.new()
var h := rng.randf_range(2.6, 4.4)
var col: Color = LEAF[rng.randi() % LEAF.size()]
n.add_child(_mi(cyl(0.16, h * 0.55, 6), TRUNK, Vector3(0, h * 0.275, 0)))
var r := h * 0.42
n.add_child(_mi(blob(r), col, Vector3(0, h * 0.55 + r * 0.55, 0)))
n.rotate_y(rng.randf() * TAU)
return n
static func rock(rng: RandomNumberGenerator) -> Node3D:
var n := Node3D.new()
var r := rng.randf_range(0.4, 1.1)
var m := _mi(blob(r), ROCK, Vector3(0, r * 0.35, 0))
m.scale = Vector3(1.0, rng.randf_range(0.4, 0.7), rng.randf_range(0.8, 1.2))
n.add_child(m)
n.rotate_y(rng.randf() * TAU)
return n
## Haus: Quader + Giebeldach (+ optionaler Schornstein).
static func house(wall: Color, roof: Color, w: float, d: float, h: float, chimney := false) -> Node3D:
var n := Node3D.new()
n.add_child(_mi(box(Vector3(w, h, d)), wall, Vector3(0, h * 0.5, 0)))
var p := PrismMesh.new()
p.size = Vector3(w * 1.14, h * 0.6, d * 1.06)
n.add_child(_mi(p, roof, Vector3(0, h + h * 0.3, 0)))
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)))
return n

1
scripts/deco.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://bqncfqu6smtcu

1011
scripts/game.gd Normal file

File diff suppressed because it is too large Load Diff

1
scripts/game.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://bh5vhe5gn4rd6

177
scripts/hud.gd Normal file
View File

@@ -0,0 +1,177 @@
extends CanvasLayer
## Fadenkreuz, Statusleiste, Interaktionshinweis und Meldungen.
const Cargo = preload("res://scripts/cargo.gd")
const Defs = preload("res://scripts/car_defs.gd")
const PassengerScript = preload("res://scripts/passenger.gd")
const PlayerScript = preload("res://scripts/player.gd")
var _stats := Label.new()
var _prompt := Label.new()
var _drive := Label.new()
var _cross := Panel.new()
var _toasts: Array = []
var _toast_box := VBoxContainer.new()
const PROMPTS := {
"drive": "E Steuerung übernehmen",
"shop": "E Depot-Terminal öffnen",
"tap": "E Getränke aufnehmen",
"load": "E halten Be- und entladen",
"seat": "E Fahrgast",
}
func _ready() -> void:
layer = 1
var panel := PanelContainer.new()
panel.position = Vector2(18, 16)
panel.add_theme_stylebox_override("panel", _bg(Color(0.08, 0.11, 0.09, 0.55)))
add_child(panel)
_stats.add_theme_font_size_override("font_size", 17)
panel.add_child(_stats)
_toast_box.add_theme_constant_override("separation", 4)
_toast_box.position = Vector2(18, 258)
add_child(_toast_box)
_prompt.add_theme_font_size_override("font_size", 20)
_prompt.add_theme_color_override("font_color", Color("ffe9a8"))
_prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(_prompt)
_drive.add_theme_font_size_override("font_size", 22)
_drive.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
add_child(_drive)
var cs := StyleBoxFlat.new()
cs.bg_color = Color(1, 1, 1, 0.75)
cs.set_corner_radius_all(3)
_cross.add_theme_stylebox_override("panel", cs)
_cross.size = Vector2(5, 5)
add_child(_cross)
# Kein HUD-Element darf Eingaben schlucken - sonst stirbt der Mausblick.
_ignore_mouse(self)
get_viewport().size_changed.connect(_layout)
_layout()
func _ignore_mouse(n: Node) -> void:
if n is Control:
(n as Control).mouse_filter = Control.MOUSE_FILTER_IGNORE
for c in n.get_children():
_ignore_mouse(c)
func _bg(c: Color) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = c
s.set_corner_radius_all(10)
s.set_content_margin_all(14)
return s
func _layout() -> void:
var v := get_viewport().get_visible_rect().size
_cross.position = v * 0.5 - Vector2(2.5, 2.5)
_prompt.size = Vector2(v.x, 30)
_prompt.position = Vector2(0, v.y * 0.58)
_drive.size = Vector2(v.x, 34)
_drive.position = Vector2(0, v.y - 96)
func refresh(game) -> void:
var t = game.train
var lines: Array[String] = []
lines.append("%d € Ansehen %d" % [game.money, game.reputation])
lines.append("")
lines.append("Zug %s + %d Wagen" % [Defs.display_name(t.loco_id), t.consist.size()])
lines.append("Tempo %3.0f km/h" % t.speed_kmh())
var seated := 0
var thirsty := 0
var unchecked := 0
for p in game.passengers:
if p.state == PassengerScript.State.SEATED:
seated += 1
if p.wants_drink and not p.served:
thirsty += 1
if not p.ticket_checked:
unchecked += 1
lines.append("Gäste %d / %d ohne Ticket %d Durst %d"
% [seated, t.seat_count(), unchecked, thirsty])
var cargo_txt := "leer"
if t.total_cargo() > 0:
var parts: Array[String] = []
for k in t.cargo.keys():
parts.append("%s %d" % [Cargo.NAMES[int(k)], int(t.cargo[k])])
cargo_txt = ", ".join(parts)
lines.append("Ladung %d / %d (%s)" % [t.total_cargo(), t.cargo_capacity(), cargo_txt])
if game.player.carrying > 0:
lines.append("Getränke dabei: %d" % game.player.carrying)
var st = t.docked_at
if st != null and t.speed < 0.1:
var extra := ""
if st.produces >= 0:
extra = " Lager: %s %d" % [Cargo.NAMES[st.produces], int(st.stock)]
lines.append("Halt: %s%s" % [st.station_name, extra])
else:
var nxt: Array = t.next_station()
lines.append("Nächster Halt: %s (%d m)" % [nxt[0].station_name, int(nxt[1])])
_stats.text = "\n".join(lines)
var h: Dictionary = game.player.hotspot
if game.player.mode == PlayerScript.Mode.DRIVE:
_prompt.text = "E Steuerung abgeben"
elif h.is_empty():
_prompt.text = ""
elif String(h["kind"]) == "seat":
_prompt.text = _seat_prompt(game, h)
else:
_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.add_theme_color_override("font_color",
Color("aef2a0") if t.speed > 0.2 else Color("ffd978"))
else:
_drive.text = ""
_cross.visible = not game.player.input_locked
_tick_toasts()
func _seat_prompt(game, h: Dictionary) -> String:
var car = game.train.cars[int(h["car"])]
var occ = car.seats[int(h["seat"])]["occupant"]
if occ == null:
return "Freier Platz"
if not occ.ticket_checked:
return "E Ticket kontrollieren (%d €)" % occ.fare
if occ.wants_drink and not occ.served:
return "E Getränk servieren" if game.player.carrying > 0 else "Braucht ein Getränk"
if game.upgrades.has("schedule"):
return "Ziel: %s - %s" % [game.stations[occ.dest_station].station_name, occ.mood_word()]
return "E Nachfragen"
# --- Meldungen ------------------------------------------------------------
func toast(text: String, color: Color = Color("aef2a0")) -> void:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 19)
l.add_theme_color_override("font_color", color)
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
l.add_theme_constant_override("outline_size", 5)
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
_toast_box.add_child(l)
_toasts.append({"node": l, "time": 3.4})
if _toasts.size() > 5:
var old: Dictionary = _toasts.pop_front()
old["node"].queue_free()
func _tick_toasts() -> void:
var dt := get_process_delta_time()
for e in _toasts.duplicate():
e["time"] -= dt
if e["time"] <= 0.0:
e["node"].queue_free()
_toasts.erase(e)
else:
e["node"].modulate.a = clampf(float(e["time"]) / 0.9, 0.0, 1.0)

1
scripts/hud.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://7ckhmyrrsbx8

16
scripts/mat.gd Normal file
View File

@@ -0,0 +1,16 @@
extends RefCounted
## Material-Cache: eine ShaderMaterial pro Farbe, flat shaded.
const FLAT_SHADER := preload("res://shaders/flat.gdshader")
static var _cache: Dictionary = {}
static func flat(color: Color) -> ShaderMaterial:
var key := color.to_rgba32()
if _cache.has(key):
return _cache[key]
var m := ShaderMaterial.new()
m.shader = FLAT_SHADER
m.set_shader_parameter("albedo", color)
_cache[key] = m
return m

1
scripts/mat.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://d0twbpthct6rv

101
scripts/menu.gd Normal file
View File

@@ -0,0 +1,101 @@
extends CanvasLayer
## Startmenue: Einzelspieler, Hosten oder einer Sitzung beitreten.
signal solo_chosen()
signal host_chosen(port: int)
signal join_chosen(address: String, port: int)
const DEFAULT_PORT := 7654
var _root: Control
var _status := Label.new()
var _addr := LineEdit.new()
var _port := LineEdit.new()
func _ready() -> void:
layer = 3
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(_root)
var bg := ColorRect.new()
bg.color = Color("14201c")
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(bg)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var vb := VBoxContainer.new()
vb.add_theme_constant_override("separation", 14)
vb.custom_minimum_size = Vector2(420, 0)
center.add_child(vb)
var title := Label.new()
title.text = "Cozy Train"
title.add_theme_font_size_override("font_size", 52)
title.add_theme_color_override("font_color", Color("ffd978"))
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vb.add_child(title)
var sub := Label.new()
sub.text = "Eine Crew, ein Zug, viele Fahrgäste."
sub.add_theme_font_size_override("font_size", 17)
sub.add_theme_color_override("font_color", Color(1, 1, 1, 0.7))
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vb.add_child(sub)
vb.add_child(_spacer(18))
vb.add_child(_button("Einzelspieler", func(): solo_chosen.emit()))
vb.add_child(_button("Sitzung hosten", func(): host_chosen.emit(_port_value())))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
_addr.text = "127.0.0.1"
_addr.placeholder_text = "Adresse"
_addr.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(_addr)
_port.text = str(DEFAULT_PORT)
_port.custom_minimum_size = Vector2(90, 0)
row.add_child(_port)
vb.add_child(row)
vb.add_child(_button("Beitreten", func(): join_chosen.emit(_addr.text, _port_value())))
_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status.add_theme_font_size_override("font_size", 16)
_status.add_theme_color_override("font_color", Color("ffb4a0"))
vb.add_child(_status)
var help := Label.new()
help.text = "Maus: umsehen WASD: laufen E: benutzen V: Außenkamera Esc: Maus frei"
help.add_theme_font_size_override("font_size", 14)
help.add_theme_color_override("font_color", Color(1, 1, 1, 0.45))
help.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
vb.add_child(help)
func _spacer(h: int) -> Control:
var c := Control.new()
c.custom_minimum_size = Vector2(0, h)
return c
func _button(text: String, cb: Callable) -> Button:
var b := Button.new()
b.text = text
b.custom_minimum_size = Vector2(0, 46)
b.add_theme_font_size_override("font_size", 20)
b.pressed.connect(cb)
return b
func _port_value() -> int:
var v := int(_port.text)
return v if v > 0 else DEFAULT_PORT
func set_status(text: String) -> void:
_status.text = text
func hide_menu() -> void:
_root.visible = false
func show_menu() -> void:
_root.visible = true

1
scripts/menu.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://ybrxme2724st

130
scripts/passenger.gd Normal file
View File

@@ -0,0 +1,130 @@
extends Node3D
## Ein Fahrgast. Wartet am Bahnsteig, faehrt mit, will kontrolliert
## und bedient werden und steigt am Ziel wieder aus.
const Mat = preload("res://scripts/mat.gd")
const Deco = preload("res://scripts/deco.gd")
const Car = preload("res://scripts/car.gd")
enum State { WAITING, BOARDING, SEATED, ALIGHTING, DONE }
const SHIRTS := ["c65b4a", "4a7cc6", "5fa86b", "c6a44a", "9a5fc6", "c66b9a", "4ab0a8"]
const SKIN := ["e8c39e", "c99a6e", "8d5f3f", "f0d5b8"]
const THIRST_AFTER := 26.0
const MOOD_DECAY := 0.055
var pid := 0
var state := State.WAITING
var origin_station := 0
var dest_station := 0
var fare := 0
var ticket_checked := false
var wants_drink := false
var served := false
var mood := 1.0
var seat_car := -1
var seat_index := -1
var _legs: Node3D
var _label: Label3D
var _seated_time := 0.0
var _lerp_from := Transform3D()
var _lerp_to := Transform3D()
var _lerp_t := 0.0
var _lerp_dur := 1.0
func build(rng: RandomNumberGenerator) -> void:
var shirt := Color(SHIRTS[rng.randi() % SHIRTS.size()])
var skin := Color(SKIN[rng.randi() % SKIN.size()])
var trousers := Color("3d4450").lightened(rng.randf() * 0.25)
_legs = Node3D.new()
add_child(_legs)
var lm := MeshInstance3D.new()
lm.mesh = Deco.box(Vector3(0.42, 0.8, 0.3))
lm.material_override = Mat.flat(trousers)
lm.position = Vector3(0, -0.4, 0)
_legs.add_child(lm)
_legs.position = Vector3(0, 0.8, 0)
var torso := MeshInstance3D.new()
torso.mesh = Deco.box(Vector3(0.5, 0.62, 0.32))
torso.material_override = Mat.flat(shirt)
torso.position = Vector3(0, 1.12, 0)
add_child(torso)
for sx: float in [-1.0, 1.0]:
var arm := MeshInstance3D.new()
arm.mesh = Deco.box(Vector3(0.13, 0.58, 0.18))
arm.material_override = Mat.flat(shirt.darkened(0.1))
arm.position = Vector3(sx * 0.32, 1.1, 0)
add_child(arm)
var head := MeshInstance3D.new()
head.mesh = Deco.blob(0.17)
head.material_override = Mat.flat(skin)
head.position = Vector3(0, 1.58, 0)
add_child(head)
var hair := MeshInstance3D.new()
hair.mesh = Deco.box(Vector3(0.34, 0.12, 0.34))
hair.material_override = Mat.flat(Color("3a2c22").lightened(rng.randf() * 0.4))
hair.position = Vector3(0, 1.72, 0)
add_child(hair)
_label = Label3D.new()
_label.font_size = 36
_label.pixel_size = 0.0042
_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_label.outline_size = 12
_label.outline_modulate = Color(0, 0, 0, 0.7)
_label.position = Vector3(0, 2.05, 0)
_label.visible = false
add_child(_label)
func set_seated(on: bool) -> void:
_legs.rotation.x = deg_to_rad(-82.0) if on else 0.0
func start_lerp(from: Transform3D, to: Transform3D, dur: float) -> void:
_lerp_from = from
_lerp_to = to
_lerp_t = 0.0
_lerp_dur = dur
## true, sobald die Bewegung fertig ist.
func advance_lerp(delta: float) -> bool:
_lerp_t = minf(_lerp_t + delta / _lerp_dur, 1.0)
var t := _lerp_t * _lerp_t * (3.0 - 2.0 * _lerp_t)
global_transform = _lerp_from.interpolate_with(_lerp_to, t)
return _lerp_t >= 1.0
func tick(delta: float, drinks_available: bool) -> void:
if state != State.SEATED:
return
_seated_time += delta
if drinks_available and not wants_drink and not served and _seated_time > THIRST_AFTER:
wants_drink = true
if wants_drink and not served:
mood = maxf(mood - MOOD_DECAY * delta, 0.15)
_refresh_label()
func _refresh_label() -> void:
if not ticket_checked:
_label.text = "Ticket"
_label.modulate = Color("ffd978")
_label.visible = true
elif wants_drink and not served:
_label.text = "Durst"
_label.modulate = Color("8fd8ff")
_label.visible = true
else:
_label.visible = false
func mood_word() -> String:
if mood > 0.85:
return "bestens"
if mood > 0.55:
return "zufrieden"
if mood > 0.3:
return "genervt"
return "sauer"

1
scripts/passenger.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dg24o5s0meuye

175
scripts/player.gd Normal file
View File

@@ -0,0 +1,175 @@
extends Node3D
## First-Person-Spieler. Bewegt sich in Zugkoordinaten (s laengs, x quer),
## damit der Innenraum auch bei voller Fahrt stabil begehbar bleibt.
const Mat = preload("res://scripts/mat.gd")
const Deco = preload("res://scripts/deco.gd")
const Car = preload("res://scripts/car.gd")
signal interact_pressed(hotspot: Dictionary)
enum Mode { WALK, DRIVE }
const RADIUS := 0.30
const HEIGHT := 1.75
const EYE := 1.62
const WALK_SPEED := 2.6
const RUN_SPEED := 4.6
const ACCEL := 14.0
const REACH := 2.0
const MOUSE_SENS := 0.0022
var s := 0.0
var x := 0.0
var yaw := 0.0
var pitch := 0.0
var mode := Mode.WALK
var carrying := 0 ## getragene Getränke
var hotspot: Dictionary = {} ## aktuell anvisierter Interaktionspunkt
var input_locked := false ## wenn ein Menue offen ist
var camera: Camera3D
var train
var _vel := Vector2.ZERO ## (quer, laengs)
func setup(train_node) -> void:
train = train_node
camera = Camera3D.new()
camera.fov = 75.0
camera.near = 0.05
camera.far = 900.0
add_child(camera)
snap_to_cab()
func snap_to_cab() -> void:
if train.cars.is_empty():
return
var loco = train.cars[0]
s = loco.center_s + loco.inner_z1 - 2.4
x = 0.0
yaw = 0.0
pitch = 0.0
# Falls dort etwas steht, nach hinten ausweichen.
var span: Vector2 = train.walk_range()
var tries := 0
while _blocked(x, s) and tries < 40:
s = maxf(s - 0.25, span.x)
tries += 1
## Aktuelles Lauftempo, für die Schrittgeräusche.
func move_speed() -> float:
return _vel.length()
func capture_mouse(on: bool) -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if on else Input.MOUSE_MODE_VISIBLE
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
yaw -= event.relative.x * MOUSE_SENS
pitch = clampf(pitch - event.relative.y * MOUSE_SENS, -1.35, 1.35)
elif event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_E and not input_locked:
if not hotspot.is_empty():
interact_pressed.emit(hotspot)
# --- Bewegung -------------------------------------------------------------
func forward_dir() -> Vector2:
## (quer, laengs) im Zugsystem
return Vector2(sin(yaw), cos(yaw))
## Bildschirm-rechts im Zugsystem. Godots Kamerabasis hat +X nach links,
## deshalb das umgekehrte Vorzeichen zu forward_dir().
func right_dir() -> Vector2:
return Vector2(-cos(yaw), sin(yaw))
func _physics_process(delta: float) -> void:
if train == null or train.cars.is_empty():
return
if mode == Mode.WALK and not input_locked:
_walk(delta)
else:
_vel = Vector2.ZERO
_update_transform()
_pick_hotspot()
func _walk(delta: float) -> void:
var move := Vector2.ZERO
if Input.is_key_pressed(KEY_W):
move += forward_dir()
if Input.is_key_pressed(KEY_S):
move -= forward_dir()
if Input.is_key_pressed(KEY_D):
move += right_dir()
if Input.is_key_pressed(KEY_A):
move -= right_dir()
if move.length() > 0.001:
move = move.normalized()
var target: float = RUN_SPEED if Input.is_key_pressed(KEY_SHIFT) else WALK_SPEED
_vel = _vel.move_toward(move * target, ACCEL * delta)
var step := _vel * delta
# Achsenweise schieben, damit man an Hindernissen entlanggleitet.
if not _blocked(x + step.x, s):
x += step.x
if not _blocked(x, s + step.y):
s += step.y
var span: Vector2 = train.walk_range()
s = clampf(s, span.x, span.y)
func _blocked(nx: float, ns: float) -> bool:
var loc: Dictionary = train.locate(ns)
var car = train.cars[int(loc["index"])]
var lz: float = float(loc["local_z"])
var half: float = Car.GANGWAY_HALF if bool(loc["gangway"]) else Car.INNER_HALF
if absf(nx) > half - RADIUS:
return true
var y0: float = Car.FLOOR_Y + 0.05
var y1: float = Car.FLOOR_Y + HEIGHT
for b in car.blockers:
var box: AABB = b
if box.position.y + box.size.y <= y0 or box.position.y >= y1:
continue
if nx + RADIUS <= box.position.x or nx - RADIUS >= box.position.x + box.size.x:
continue
if lz + RADIUS <= box.position.z or lz - RADIUS >= box.position.z + box.size.z:
continue
return true
return false
func _update_transform() -> void:
var loc: Dictionary = train.locate(s)
var xf: Transform3D = train.car_transform(int(loc["index"]))
var lz: float = float(loc["local_z"])
global_position = xf * Vector3(x, Car.FLOOR_Y, lz)
var look := Vector3(sin(yaw) * cos(pitch), sin(pitch), cos(yaw) * cos(pitch))
global_basis = xf.basis * Basis.looking_at(look, Vector3.UP)
camera.position = Vector3(0, EYE, 0)
# --- Interaktion ----------------------------------------------------------
func _pick_hotspot() -> void:
hotspot = {}
if input_locked:
return
var best_score := 0.35
var fwd := forward_dir()
for ci in train.cars.size():
var car = train.cars[ci]
for h in car.hotspots:
var p: Vector3 = h["pos"]
var hs: float = car.center_s + p.z
var d := Vector2(p.x - x, hs - s)
var dist := d.length()
if dist > REACH:
continue
if absf(p.y - (Car.FLOOR_Y + EYE)) > 1.7:
continue
var dir := d.normalized() if dist > 0.01 else fwd
var score := fwd.dot(dir) * (1.0 - dist / (REACH * 2.0))
if score > best_score:
best_score = score
hotspot = {"car": ci, "kind": String(h["kind"]), "seat": int(h["seat"]),
"pos": p, "dist": dist}

1
scripts/player.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://cetffj1gko2vf

213
scripts/sfx.gd Normal file
View File

@@ -0,0 +1,213 @@
extends RefCounted
## Alle Klänge des Spiels, einmal beim Start berechnet.
const Synth = preload("res://scripts/synth.gd")
const BPM := 72.0
const BEAT := 60.0 / BPM
const BAR := BEAT * 4.0
const BARS := 8
## Eine Stufe der Harmoniefolge. Alle Akkorde liegen in derselben Lage
## (F3..G4), damit kein Takt aus dem Klangbild springt. "melody" enthält
## nur Töne, die zum Akkord passen. Geprüft wird auf kleine Sekunde und
## kleine None gegen jede Stimme - genau die reiben hörbar. Über G6 fehlt
## deshalb das C, über Cmaj7 ebenfalls.
const PROGRESSION := [
{"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": "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": "Cmaj7", "pad": [196.00, 246.94, 329.63, 392.00], "bass": 130.81,
"melody": [493.88, 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": "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": "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": "G6", "pad": [196.00, 246.94, 293.66, 329.63], "bass": 98.00,
"melody": [440.00, 493.88, 587.33, 659.26, 783.99]},
]
static func build_all() -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = 20260828
return {
"clack": _clack(rng),
"clack2": _clack2(rng),
"rumble": _rumble(rng),
"squeal": _squeal(rng),
"whistle": _whistle(rng),
"hiss": _hiss(rng),
"bell": _bell(),
"coin": _coin(),
"pour": _pour(rng),
"clink": _clink(),
"thud": _thud(rng),
"step": _step(rng),
"click": _click(),
"deny": _deny(),
}
static func build_music() -> PackedByteArray:
var rng := RandomNumberGenerator.new()
rng.seed = 7411
var loop_len := BAR * BARS
var buf := Synth.buffer(loop_len + 3.0)
for b in BARS:
var t0 := b * BAR
var stage: Dictionary = PROGRESSION[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:
continue
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.
static func _impact(b: PackedFloat32Array, t: float, low: float, mid: float,
amp: float, rng: RandomNumberGenerator) -> void:
Synth.add_tone(b, t, low, 0.30, 0.85 * amp, 13.0, [1.0, 0.30, 0.10], 0.002)
Synth.add_tone(b, t, mid, 0.22, 0.42 * amp, 26.0, [1.0, 0.35], 0.002)
Synth.add_noise(b, t, 0.16, 0.30 * amp, 30.0, 0.14, rng, 0.001)
Synth.add_noise(b, t, 0.05, 0.20 * amp, 90.0, 0.55, rng, 0.001)
Synth.add_resonant_noise(b, t, 0.14, 1450.0, 0.985, 0.16 * amp, rng, 26.0)
## Doppelschlag der beiden Achsen eines Drehgestells.
static func _clack(rng: RandomNumberGenerator) -> PackedByteArray:
var b := Synth.buffer(0.62)
_impact(b, 0.0, 61.0, 196.0, 1.0, rng)
_impact(b, 0.155, 58.0, 183.0, 0.72, rng)
return Synth.to_pcm(b)
static func _clack2(rng: RandomNumberGenerator) -> PackedByteArray:
var b := Synth.buffer(0.62)
_impact(b, 0.0, 68.0, 213.0, 0.94, rng)
_impact(b, 0.168, 64.0, 172.0, 0.66, rng)
return Synth.to_pcm(b)
## Grundrauschen der Fahrt, als Schleife.
static func _rumble(rng: RandomNumberGenerator) -> PackedByteArray:
var loop := 1.5
var b := Synth.buffer(loop + 0.4)
var y := 0.0
var y2 := 0.0
for i in b.size():
y += (rng.randf_range(-1.0, 1.0) - y) * 0.06
y2 += (y - y2) * 0.09
b[i] = y2 * 3.4
for k in 3:
Synth.add_tone(b, 0.0, 46.0 + k * 23.0, loop + 0.4, 0.1 / (k + 1), 0.0, [1.0], 0.05)
return Synth.to_pcm(Synth.seamless(b, loop))
## Bremsenquietschen. Der tonale Teil ist exakt periodisch zur Schleifen-
## länge, der Rauschanteil wird übergeblendet - so klickt die Naht nicht.
static func _squeal(rng: RandomNumberGenerator) -> PackedByteArray:
var loop := 1.2
var unit := 1.0 / loop ## alles Vielfache davon
var noise := Synth.buffer(loop + 0.35)
Synth.add_resonant_noise(noise, 0.0, loop + 0.35, 1740.0, 0.9955, 0.55, rng)
Synth.add_resonant_noise(noise, 0.0, loop + 0.35, 2610.0, 0.9945, 0.28, rng)
Synth.add_noise(noise, 0.0, loop + 0.35, 0.05, 0.0, 0.5, rng, 0.02)
var b := Synth.seamless(noise, loop)
Synth.add_wobble(b, 0.0, unit * 1740.0, loop, 0.26, 0.010, unit * 4.0, 0.12)
Synth.add_wobble(b, 0.0, unit * 2610.0, loop, 0.13, 0.008, unit * 7.0)
return Synth.to_pcm(b)
static func _whistle(rng: RandomNumberGenerator) -> PackedByteArray:
var b := Synth.buffer(1.6)
for f in [523.0, 659.0, 784.0]:
Synth.add_tone(b, 0.0, float(f), 1.45, 0.30, 1.1,
[1.0, 0.45, 0.2, 0.08], 0.09, 0.012, 5.5)
Synth.add_tone(b, 0.0, float(f) * 1.004, 1.45, 0.22, 1.1, [1.0, 0.3], 0.11)
Synth.add_noise(b, 0.0, 1.4, 0.12, 1.4, 0.55, rng, 0.1)
return Synth.to_pcm(b)
static func _hiss(rng: RandomNumberGenerator) -> PackedByteArray:
var b := Synth.buffer(1.3)
Synth.add_noise(b, 0.0, 1.2, 0.7, 3.2, 0.75, rng, 0.03)
Synth.add_noise(b, 0.05, 1.0, 0.3, 2.4, 0.35, rng, 0.05)
return Synth.to_pcm(b)
static func _bell() -> PackedByteArray:
var b := Synth.buffer(1.9)
for k in 2:
var t := k * 0.34
Synth.add_tone(b, t, 987.77, 1.5, 0.34, 2.6, [1.0, 0.42, 0.18, 0.09], 0.004)
Synth.add_tone(b, t, 1318.51, 1.4, 0.20, 3.0, [1.0, 0.3], 0.004)
return Synth.to_pcm(b)
static func _coin() -> PackedByteArray:
var b := Synth.buffer(0.5)
Synth.add_tone(b, 0.0, 1174.66, 0.3, 0.4, 11.0, [1.0, 0.4, 0.15], 0.003)
Synth.add_tone(b, 0.07, 1567.98, 0.42, 0.4, 8.0, [1.0, 0.35, 0.12], 0.003)
Synth.add_tone(b, 0.07, 2349.32, 0.35, 0.15, 10.0, [1.0], 0.003)
return Synth.to_pcm(b)
static func _pour(rng: RandomNumberGenerator) -> PackedByteArray:
var b := Synth.buffer(0.95)
Synth.add_noise(b, 0.0, 0.9, 0.4, 1.1, 0.42, rng, 0.08)
for i in 9:
var t := 0.1 + i * 0.085
Synth.add_tone(b, t, rng.randf_range(700.0, 1500.0), 0.09, 0.1, 30.0, [1.0], 0.004)
return Synth.to_pcm(b)
static func _clink() -> PackedByteArray:
var b := Synth.buffer(0.6)
Synth.add_tone(b, 0.0, 2637.02, 0.5, 0.3, 7.0, [1.0, 0.3], 0.002)
Synth.add_tone(b, 0.0, 3520.0, 0.4, 0.16, 9.0, [1.0], 0.002)
Synth.add_tone(b, 0.11, 2793.83, 0.4, 0.2, 8.0, [1.0], 0.002)
return Synth.to_pcm(b)
static func _thud(rng: RandomNumberGenerator) -> PackedByteArray:
var b := Synth.buffer(0.4)
Synth.add_tone(b, 0.0, 68.0, 0.3, 0.65, 13.0, [1.0, 0.3], 0.003)
Synth.add_noise(b, 0.0, 0.14, 0.3, 34.0, 0.28, rng)
return Synth.to_pcm(b)
## Schritt: Ferse zuerst, dann das Abrollen. Ohne den tiefen Anteil
## bleibt nur ein heller Tick übrig.
static func _step(rng: RandomNumberGenerator) -> PackedByteArray:
var b := Synth.buffer(0.34)
Synth.add_tone(b, 0.0, 84.0, 0.26, 0.55, 15.0, [1.0, 0.28, 0.08], 0.003)
Synth.add_tone(b, 0.0, 137.0, 0.16, 0.22, 26.0, [1.0], 0.003)
Synth.add_noise(b, 0.0, 0.20, 0.26, 21.0, 0.12, rng, 0.003)
Synth.add_noise(b, 0.035, 0.12, 0.10, 34.0, 0.42, rng, 0.012)
return Synth.to_pcm(b)
static func _click() -> PackedByteArray:
var b := Synth.buffer(0.12)
Synth.add_tone(b, 0.0, 1720.0, 0.06, 0.28, 60.0, [1.0, 0.4], 0.001)
Synth.add_tone(b, 0.02, 1180.0, 0.07, 0.2, 45.0, [1.0], 0.001)
return Synth.to_pcm(b)
static func _deny() -> PackedByteArray:
var b := Synth.buffer(0.35)
Synth.add_tone(b, 0.0, 320.0, 0.14, 0.3, 16.0, [1.0, 0.4], 0.004)
Synth.add_tone(b, 0.12, 240.0, 0.2, 0.3, 13.0, [1.0, 0.4], 0.004)
return Synth.to_pcm(b)

1
scripts/sfx.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://bmk8l5ddth83t

211
scripts/shop.gd Normal file
View File

@@ -0,0 +1,211 @@
extends CanvasLayer
## Depot-Terminal: Loks, Waggons und Ausbauten kaufen oder verkaufen.
const Defs = preload("res://scripts/car_defs.gd")
signal closed()
var is_open := false
var _game
var _root: Control
var _tabs: TabContainer
var _money := Label.new()
func setup(game) -> void:
_game = game
layer = 2
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.visible = false
add_child(_root)
var dim := ColorRect.new()
dim.color = Color(0, 0, 0, 0.55)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(dim)
var panel := PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
panel.custom_minimum_size = Vector2(760, 520)
panel.grow_horizontal = Control.GROW_DIRECTION_BOTH
panel.grow_vertical = Control.GROW_DIRECTION_BOTH
var sb := StyleBoxFlat.new()
sb.bg_color = Color("1b2420")
sb.set_corner_radius_all(14)
sb.set_content_margin_all(18)
sb.border_color = Color("3d5a4c")
sb.set_border_width_all(2)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
var vb := VBoxContainer.new()
vb.add_theme_constant_override("separation", 10)
panel.add_child(vb)
var head := HBoxContainer.new()
var title := Label.new()
title.text = "Depot-Terminal"
title.add_theme_font_size_override("font_size", 26)
head.add_child(title)
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
head.add_child(spacer)
_money.add_theme_font_size_override("font_size", 22)
_money.add_theme_color_override("font_color", Color("ffd978"))
head.add_child(_money)
vb.add_child(head)
var hint := Label.new()
hint.text = "Esc schließt das Terminal."
hint.add_theme_font_size_override("font_size", 14)
hint.add_theme_color_override("font_color", Color(1, 1, 1, 0.6))
vb.add_child(hint)
_tabs = TabContainer.new()
_tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL
vb.add_child(_tabs)
func open() -> void:
is_open = true
_root.visible = true
_rebuild()
func close() -> void:
is_open = false
_root.visible = false
closed.emit()
func refresh() -> void:
_rebuild()
func _rebuild() -> void:
_money.text = "%d € Ansehen %d" % [_game.money, _game.reputation]
var tab := _tabs.current_tab
for c in _tabs.get_children():
c.queue_free()
_tabs.add_child(_page("Loks", _loco_rows()))
_tabs.add_child(_page("Waggons", _car_rows()))
_tabs.add_child(_page("Mein Zug", _consist_rows()))
_tabs.add_child(_page("Ausbauten", _upgrade_rows()))
if tab >= 0 and tab < 4:
_tabs.current_tab = tab
func _page(title: String, rows: Array) -> Control:
var scroll := ScrollContainer.new()
scroll.name = title
var vb := VBoxContainer.new()
vb.add_theme_constant_override("separation", 8)
vb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.add_child(vb)
for r in rows:
vb.add_child(r)
return scroll
func _row(title: String, desc: String, right_text: String, button_text: String,
enabled: bool, on_press: Callable) -> Control:
var pc := PanelContainer.new()
var sb := StyleBoxFlat.new()
sb.bg_color = Color(1, 1, 1, 0.05)
sb.set_corner_radius_all(8)
sb.set_content_margin_all(10)
pc.add_theme_stylebox_override("panel", sb)
var hb := HBoxContainer.new()
hb.add_theme_constant_override("separation", 12)
pc.add_child(hb)
var vb := VBoxContainer.new()
vb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var t := Label.new()
t.text = title
t.add_theme_font_size_override("font_size", 19)
vb.add_child(t)
var d := Label.new()
d.text = desc
d.add_theme_font_size_override("font_size", 14)
d.add_theme_color_override("font_color", Color(1, 1, 1, 0.65))
vb.add_child(d)
hb.add_child(vb)
var r := Label.new()
r.text = right_text
r.add_theme_font_size_override("font_size", 17)
r.custom_minimum_size = Vector2(190, 0)
r.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
hb.add_child(r)
var b := Button.new()
b.text = button_text
b.disabled = not enabled
b.custom_minimum_size = Vector2(120, 40)
b.pressed.connect(func():
if _game.audio != null:
_game.audio.play("click", -14.0)
on_press.call())
hb.add_child(b)
return pc
func _lock_note(rep_needed: int) -> String:
if _game.reputation >= rep_needed:
return ""
return " (ab Ansehen %d)" % rep_needed
func _loco_rows() -> Array:
var rows: Array = []
for id in Defs.LOCOS.keys():
var d: Dictionary = Defs.LOCOS[id]
var owned: bool = _game.train.loco_id == id
var rep: int = int(d["rep"])
var price: int = int(d["price"])
var can: bool = not owned and _game.can_buy(price, rep)
var right := "%d km/h %d €" % [int(float(d["max_speed"]) * 3.6), price]
var name: String = String(d["name"]) + (_lock_note(rep) if not owned else " [im Einsatz]")
rows.append(_row(name, String(d["desc"]), right,
"Im Einsatz" if owned else "Kaufen", can,
func(): _game.request("buy_loco", id); _rebuild()))
return rows
func _car_rows() -> Array:
var rows: Array = []
for id in Defs.CARS.keys():
var d: Dictionary = Defs.CARS[id]
var rep: int = int(d["rep"])
var price: int = int(d["price"])
var can: bool = _game.can_buy(price, rep) and _game.train.consist.size() < 6
rows.append(_row(String(d["name"]) + _lock_note(rep), String(d["desc"]),
"%d €" % price, "Kaufen", can,
func(): _game.request("buy_car", id); _rebuild()))
return rows
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 list: Array = _game.train.consist
for i in list.size():
var cid: String = list[i]
var cd: Dictionary = Defs.CARS[cid]
var idx := i
rows.append(_row("%d. %s" % [i + 1, String(cd["name"])], String(cd["desc"]),
"Rücknahme %d €" % int(cd["sell"]), "Verkaufen", true,
func(): _game.request("sell_car", "", idx); _rebuild()))
if list.is_empty():
rows.append(_row("Keine Waggons", "Kauf dir mindestens einen Wagen.", "", "", false,
func(): pass))
return rows
func _upgrade_rows() -> Array:
var rows: Array = []
for id in Defs.UPGRADES.keys():
var d: Dictionary = Defs.UPGRADES[id]
var owned: bool = _game.upgrades.has(id)
var rep: int = int(d["rep"])
var price: int = int(d["price"])
var can: bool = not owned and _game.can_buy(price, rep)
rows.append(_row(String(d["name"]) + (_lock_note(rep) if not owned else ""),
String(d["desc"]), "" if owned else "%d €" % price,
"Gekauft" if owned else "Kaufen", can,
func(): _game.request("buy_upgrade", id); _rebuild()))
return rows

1
scripts/shop.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://sg33v3inblwx

88
scripts/station.gd Normal file
View File

@@ -0,0 +1,88 @@
extends Node3D
## Bahnhof: Bahnsteig, Gebaeude, Warteplaetze, Warenlager.
const Mat = preload("res://scripts/mat.gd")
const Deco = preload("res://scripts/deco.gd")
const Cargo = preload("res://scripts/cargo.gd")
const PLATFORM_LEN := 44.0
const PLATFORM_H := 0.95
var station_name := ""
var progress := 0.0
var side := 1.0
var produces := -1
var accepts: Array[int] = []
var stock := 0.0
var max_stock := 30.0
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:
station_name = nm
progress = prog
produces = prod
accepts = acc
func tick(delta: float) -> void:
if produces >= 0:
stock = minf(stock + rate * delta, max_stock)
func _box(parent: Node3D, center: Vector3, size: Vector3, color: Color) -> MeshInstance3D:
var m := MeshInstance3D.new()
m.mesh = Deco.box(size)
m.material_override = Mat.flat(color)
m.position = center
parent.add_child(m)
return m
func build_visuals(track_xf: Transform3D, s: float, wall: Color, roof: Color, chimney: bool) -> void:
transform = track_xf
side = s
var edge := s * 2.4
# Bahnsteig
_box(self, Vector3(edge + s * 1.6, PLATFORM_H * 0.5, 0),
Vector3(3.2, PLATFORM_H, PLATFORM_LEN), Color("cfc3a8"))
_box(self, Vector3(edge + s * 0.06, PLATFORM_H - 0.04, 0),
Vector3(0.35, 0.1, PLATFORM_LEN), Color("e8e0cc"))
# Vordach
for i in 3:
var z := -PLATFORM_LEN * 0.22 + i * PLATFORM_LEN * 0.22
_box(self, Vector3(edge + s * 4.1, PLATFORM_H + 1.75, z),
Vector3(0.24, 3.5, 0.24), Color("6b5a45"))
_box(self, Vector3(edge + s * 3.0, PLATFORM_H + 3.6, 0),
Vector3(3.4, 0.22, PLATFORM_LEN * 0.5), roof)
# Empfangsgebaeude
var h := Deco.house(wall, roof, 11.0, 8.0, 4.6, chimney)
h.position = Vector3(edge + s * 11.5, 0.0, -PLATFORM_LEN * 0.18)
h.rotate_y(PI * 0.5 * -s)
add_child(h)
# Baenke
for i in 3:
var z := -PLATFORM_LEN * 0.25 + i * PLATFORM_LEN * 0.25
_box(self, Vector3(edge + s * 2.5, PLATFORM_H + 0.28, z),
Vector3(0.55, 0.12, 1.6), Color("8a6b4a"))
_box(self, Vector3(edge + s * 2.75, PLATFORM_H + 0.6, z),
Vector3(0.1, 0.55, 1.6), Color("8a6b4a"))
# Namensschild
var label := Label3D.new()
label.text = station_name
label.font_size = 64
label.pixel_size = 0.014
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.outline_size = 18
label.outline_modulate = Color(0, 0, 0, 0.7)
label.position = Vector3(edge + s * 3.0, PLATFORM_H + 4.9, 0)
add_child(label)
# Warteplaetze auf dem Bahnsteig
for i in 10:
var lz := -PLATFORM_LEN * 0.34 + i * PLATFORM_LEN * 0.075
var lx := edge + s * (1.1 + (0.9 if i % 2 == 0 else 0.0))
waiting_spots.append(to_global(Vector3(lx, PLATFORM_H, lz)))

1
scripts/station.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dcpyfw5eg0apq

163
scripts/synth.gd Normal file
View File

@@ -0,0 +1,163 @@
extends RefCounted
## Kleiner Synthesizer. Erzeugt PCM-Puffer und daraus AudioStreamWAV.
## Alles wird beim Start berechnet, zur Laufzeit kostet es nichts mehr.
const RATE := 22050
const TABLE_BITS := 11
const TABLE_SIZE := 1 << TABLE_BITS
const TABLE_MASK := TABLE_SIZE - 1
static var _sine: PackedFloat32Array
static func _table() -> PackedFloat32Array:
if _sine.is_empty():
_sine.resize(TABLE_SIZE)
for i in TABLE_SIZE:
_sine[i] = sin(TAU * float(i) / float(TABLE_SIZE))
return _sine
## Sinus über eine Phase in Umdrehungen (0..1). Schneller als sin().
static func osc(phase: float) -> float:
var t := _table()
return t[int(phase * TABLE_SIZE) & TABLE_MASK]
static func buffer(seconds: float) -> PackedFloat32Array:
var b := PackedFloat32Array()
b.resize(int(seconds * RATE))
return b
## Ton mit Obertönen und exponentiellem Abfall.
static func add_tone(buf: PackedFloat32Array, start: float, freq: float, dur: float,
amp: float, decay: float, harmonics: Array = [1.0], attack := 0.005,
vibrato := 0.0, vib_hz := 5.0) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var step := freq / float(RATE)
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var t := float(i) / float(RATE)
var env := exp(-t * decay)
if t < attack:
env *= t / attack
var ph := float(i) * step
if vibrato > 0.0:
# Integrierte Phasenauslenkung. Die Phase selbst zu skalieren
# würde die Tonhöhe über die Zeit davonlaufen lassen.
ph += vibrato * freq / (TAU * vib_hz) * (1.0 + osc(t * vib_hz - 0.25))
var v := 0.0
var h := 1.0
for a in harmonics:
v += float(a) * osc(ph * h)
h += 1.0
buf[idx] += v * env * amp
## Weiches Pad: langsam an, langsam aus.
static func add_pad(buf: PackedFloat32Array, start: float, freq: float, dur: float,
amp: float, fade: float) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var step := freq / float(RATE)
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var t := float(i) / float(RATE)
var env: float = clampf(t / fade, 0.0, 1.0) * clampf((dur - t) / fade, 0.0, 1.0)
env = env * env * (3.0 - 2.0 * env)
var p := float(i) * step
var v := osc(p) + 0.30 * osc(p * 2.0) + 0.09 * osc(p * 3.0)
# Leichtes Schweben durch minimale Verstimmung
v += 0.26 * osc(p * 1.003)
buf[idx] += v * env * amp * 0.5
## Gefiltertes Rauschen mit Hüllkurve.
static func add_noise(buf: PackedFloat32Array, start: float, dur: float, amp: float,
decay: float, lp: float, rng: RandomNumberGenerator, attack := 0.002) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var y := 0.0
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var t := float(i) / float(RATE)
var env := exp(-t * decay)
if t < attack:
env *= t / attack
y += (rng.randf_range(-1.0, 1.0) - y) * lp
buf[idx] += y * env * amp
## Ton mit echtem Frequenzwobbeln. Die Phase wird aufsummiert, nicht
## skaliert - nur so bleibt die Tonhöhe da, wo sie hingehört.
static func add_wobble(buf: PackedFloat32Array, start: float, freq: float, dur: float,
amp: float, depth: float, rate: float, harm2 := 0.0) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var ph := 0.0
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var t := float(i) / float(RATE)
ph += freq * (1.0 + depth * osc(t * rate)) / float(RATE)
buf[idx] += (osc(ph) + harm2 * osc(ph * 2.0)) * amp
## Rauschen durch einen schmalen Resonanzfilter - klingt metallisch,
## ohne die Härte eines reinen Sinus.
static func add_resonant_noise(buf: PackedFloat32Array, start: float, dur: float,
freq: float, r: float, amp: float, rng: RandomNumberGenerator,
decay := 0.0) -> void:
var i0 := int(start * RATE)
var n := int(dur * RATE)
var b1 := 2.0 * r * cos(TAU * freq / float(RATE))
var b2 := -r * r
var y1 := 0.0
var y2 := 0.0
var gain := (1.0 - r) * 1.6
for i in n:
var idx := i0 + i
if idx < 0 or idx >= buf.size():
continue
var y := gain * rng.randf_range(-1.0, 1.0) + b1 * y1 + b2 * y2
y2 = y1
y1 = y
var env := 1.0 if decay <= 0.0 else exp(-float(i) / float(RATE) * decay)
buf[idx] += y * amp * env
## Legt den Ausklang auf den Anfang, damit die Schleife nahtlos wird.
static func seamless(buf: PackedFloat32Array, loop_len: float) -> PackedFloat32Array:
var n := int(loop_len * RATE)
var tail: int = mini(buf.size() - n, n)
var out := buf.slice(0, n)
for i in tail:
var w := 1.0 - float(i) / float(tail)
out[i] += buf[n + i] * w
return out
## Begrenzt sanft und wandelt in rohes PCM. Läuft auch in einem Thread.
static func to_pcm(buf: PackedFloat32Array) -> PackedByteArray:
var peak := 0.0
for v in buf:
peak = maxf(peak, absf(v))
var gain: float = 0.92 / peak if peak > 0.001 else 1.0
var data := PackedByteArray()
data.resize(buf.size() * 2)
for i in buf.size():
var v: float = clampf(buf[i] * gain, -1.0, 1.0)
data.encode_s16(i * 2, int(v * 32767.0))
return data
## Baut den abspielbaren Stream. Nur im Hauptthread aufrufen.
static func to_stream(pcm: PackedByteArray, loop := false) -> AudioStreamWAV:
var w := AudioStreamWAV.new()
w.format = AudioStreamWAV.FORMAT_16_BITS
w.mix_rate = RATE
w.stereo = false
w.data = pcm
if loop:
w.loop_mode = AudioStreamWAV.LOOP_FORWARD
w.loop_begin = 0
w.loop_end = pcm.size() / 2
return w

1
scripts/synth.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://dkcl8lgogwxb2

61
scripts/track.gd Normal file
View File

@@ -0,0 +1,61 @@
extends Node3D
## Baut aus 2D-Stuetzpunkten eine geschlossene Gleisschleife inkl. Optik.
const Mat = preload("res://scripts/mat.gd")
const Deco = preload("res://scripts/deco.gd")
const BALLAST := Color("d98d4a")
const SLEEPER := Color("5e4130")
const RAIL := Color("9a9da2")
const STEP := 1.2
var path: Path3D
func build(points: PackedVector2Array) -> void:
path = Path3D.new()
path.name = "TrackPath"
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]
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()
func length() -> float:
return path.curve.get_baked_length()
func sample(offset: float) -> Transform3D:
return path.curve.sample_baked_with_rotation(fposmod(offset, length()), true, true)
func _build_visuals() -> void:
var count := int(length() / STEP)
var xf: Array[Transform3D] = []
for i in count:
xf.append(sample(i * STEP))
_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,
[Vector3(-0.62, 0.29, 0), Vector3(0.62, 0.29, 0)])
func _multi(mesh: Mesh, color: Color, xf: Array[Transform3D], offsets: Array) -> void:
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.mesh = mesh
mm.instance_count = xf.size() * offsets.size()
var i := 0
for t in xf:
for o in offsets:
mm.set_instance_transform(i, t.translated_local(o))
i += 1
var mmi := MultiMeshInstance3D.new()
mmi.multimesh = mm
mmi.material_override = Mat.flat(color)
add_child(mmi)

1
scripts/track.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://c4v1pl34pv0wk

244
scripts/train.gd Normal file
View File

@@ -0,0 +1,244 @@
extends Node3D
## Der Zugverband: Lok + Waggons auf der Strecke, Fahrphysik,
## und die Abbildung "Laengsposition im Zug" -> Weltkoordinate.
const CarScene = preload("res://scripts/car.gd")
const Defs = preload("res://scripts/car_defs.gd")
const Cargo = preload("res://scripts/cargo.gd")
signal consist_changed()
const GAP := 0.75 ## Abstand zwischen zwei Waggons
const DRAG := 0.6
const DOCK_RANGE := 3.0
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 progress := 0.0 ## Position der Zugspitze auf der Kurve
var speed := 0.0
var throttle := 0.0
var braking := false
var cargo: Dictionary = {} ## Cargo.Type -> int
var stations: Array = []
var docked_at: Node = null
var _track
var _stand_time := 0.0
func setup(track, station_list: Array) -> void:
_track = track
stations = station_list
func max_speed() -> float:
return float(Defs.LOCOS[loco_id]["max_speed"])
func accel() -> float:
return float(Defs.LOCOS[loco_id]["accel"])
func brake_power() -> float:
return float(Defs.LOCOS[loco_id]["brake"])
func speed_kmh() -> float:
return speed * 3.6
func cargo_capacity() -> int:
var n := 0
for c in cars:
n += c.capacity
return n
func total_cargo() -> int:
var n := 0
for v in cargo.values():
n += int(v)
return n
func has_dining() -> bool:
for c in cars:
if c.kind == Defs.Kind.DINING:
return true
return false
func seat_count() -> int:
var n := 0
for c in cars:
n += c.seats.size()
return n
# --- Verband aufbauen -----------------------------------------------------
func rebuild() -> void:
for f in followers:
f.queue_free()
followers.clear()
cars.clear()
var ids: Array[String] = [loco_id]
ids.append_array(consist)
var s := 0.0
for i in ids.size():
var car = CarScene.new()
car.build(ids[i])
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)
cars.append(car)
_sync_followers()
refresh_cargo_visuals()
consist_changed.emit()
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)
## Begehbarer Bereich als [hinten, vorne] in Zug-Laengskoordinaten.
func walk_range() -> Vector2:
if cars.is_empty():
return Vector2.ZERO
var front = cars[0]
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
for i in cars.size():
var c = cars[i]
var lo: float = c.center_s + c.inner_z0
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))
if d < best_d:
best_d = d
best = i
var c2 = cars[best]
return {"index": best, "local_z": s - c2.center_s, "gangway": true}
func car_transform(i: int) -> Transform3D:
return followers[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"])))
func _sync_followers() -> void:
var l: float = _track.length()
for i in cars.size():
followers[i].progress = fposmod(progress + cars[i].center_s, l)
# --- Ladung ---------------------------------------------------------------
func refresh_cargo_visuals() -> void:
var units: Array[int] = []
for t in cargo.keys():
for i in int(cargo[t]):
units.append(int(t))
var idx := 0
for c in cars:
if c.capacity <= 0:
continue
var take: int = mini(c.capacity, units.size() - idx)
if take <= 0:
c.show_cargo(0, Color.WHITE)
continue
c.show_cargo(take, Cargo.COLORS[units[idx]])
idx += take
func add_cargo(t: int, amount: int) -> int:
var free: int = cargo_capacity() - total_cargo()
var take: int = mini(free, amount)
if take > 0:
cargo[t] = int(cargo.get(t, 0)) + take
refresh_cargo_visuals()
return take
func remove_cargo(t: int, amount: int) -> int:
var have: int = int(cargo.get(t, 0))
var take: int = mini(have, amount)
if take > 0:
cargo[t] = have - take
if cargo[t] <= 0:
cargo.erase(t)
refresh_cargo_visuals()
return take
# --- Fahrt ----------------------------------------------------------------
func drive(delta: float) -> void:
if braking:
speed = maxf(speed - brake_power() * delta, 0.0)
elif throttle > 0.0:
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()
_update_dock(delta)
## Nur Anzeige aktualisieren (Clients im Netzspiel).
func apply_snapshot(p: float, v: float) -> void:
progress = p
speed = v
_sync_followers()
_update_dock(0.0)
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:
_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.
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:
return 0.0
return minf(behind - len_train, l - behind)
func nearest_station() -> Array:
var best: Node = null
var best_d := INF
for s in stations:
var d: float = station_gap(s)
if d < best_d:
best_d = d
best = s
return [best, best_d]
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:
best_d = d
best = s
return [best, best_d]

1
scripts/train.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://bs0xp741xc3pg

113
scripts/world.gd Normal file
View File

@@ -0,0 +1,113 @@
extends Node3D
## Gelaende, Vegetation und Umgebung. Baeume laufen ueber MultiMesh,
## damit auch mehrere hundert Stueck guenstig bleiben.
const Mat = preload("res://scripts/mat.gd")
const Deco = preload("res://scripts/deco.gd")
const GRASS := Color("7cb85a")
const GRASS_DARK := Color("74b054")
const WATER := Color("4f9fd6")
const SAND := Color("e0c98a")
const EXTENT := 360.0
var _rng: RandomNumberGenerator
func build(rng: RandomNumberGenerator, track, stations: Array) -> void:
_rng = rng
_ground()
_scatter(track, stations)
func _flat_disc(pos: Vector3, radius: float, color: Color, height := 0.06) -> void:
var m := MeshInstance3D.new()
m.mesh = Deco.cyl(radius, height, 24)
m.material_override = Mat.flat(color)
m.position = pos
add_child(m)
func _ground() -> void:
var plane := PlaneMesh.new()
plane.size = Vector2(1000, 1000)
var g := MeshInstance3D.new()
g.mesh = plane
g.material_override = Mat.flat(GRASS)
add_child(g)
_flat_disc(Vector3(-230, 0.02, 150), 72.0, SAND, 0.05)
_flat_disc(Vector3(-230, 0.04, 150), 60.0, WATER, 0.05)
for i in 18:
var p := Vector2(_rng.randf_range(-EXTENT, EXTENT), _rng.randf_range(-EXTENT, EXTENT))
_flat_disc(Vector3(p.x, 0.01, p.y), _rng.randf_range(18.0, 44.0), GRASS_DARK, 0.03)
func _multi(mesh: Mesh, items: Array) -> void:
if items.is_empty():
return
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.use_colors = true
mm.mesh = mesh
mm.instance_count = items.size()
for i in items.size():
var it: Dictionary = items[i]
mm.set_instance_transform(i, it["xf"])
# Instanzfarben gehen roh in den Shader, deshalb hier nach Linear wandeln.
mm.set_instance_color(i, (it["color"] as Color).srgb_to_linear())
var mmi := MultiMeshInstance3D.new()
mmi.multimesh = mm
mmi.material_override = Mat.flat(Color.WHITE)
add_child(mmi)
func _scatter(track, stations: Array) -> void:
var trunks: Array = []
var cones: Array = []
var blobs: Array = []
var rocks: Array = []
var placed := 0
var tries := 0
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:
continue
if p.distance_to(Vector2(-230, 150)) < 78.0:
continue
var blocked := false
for st in stations:
if p.distance_to(Vector2(st.global_position.x, st.global_position.z)) < 30.0:
blocked = true
break
if blocked:
continue
placed += 1
var yaw := _rng.randf() * TAU
if _rng.randf() < 0.6:
var h := _rng.randf_range(4.0, 8.5)
var col: Color = Deco.PINE[_rng.randi() % Deco.PINE.size()]
trunks.append(_item(p, h * 0.16, Vector3(1, h * 0.3, 1), yaw, Deco.TRUNK))
var tiers := 3 if h > 6.0 else 2
for i in tiers:
var f := float(i) / float(tiers)
var r := lerpf(h * 0.3, h * 0.14, f)
var th := h * 0.44
var y := h * 0.26 + f * h * 0.32 + th * 0.5
cones.append(_item(p, y, Vector3(r, th, r), yaw, col))
elif _rng.randf() < 0.85:
var h := _rng.randf_range(3.2, 6.0)
var col: Color = Deco.LEAF[_rng.randi() % Deco.LEAF.size()]
trunks.append(_item(p, h * 0.28, Vector3(0.9, h * 0.55, 0.9), yaw, Deco.TRUNK))
var r := h * 0.44
blobs.append(_item(p, h * 0.55 + r * 0.5, Vector3(r, r, r), yaw, col))
else:
var r := _rng.randf_range(0.7, 2.0)
rocks.append(_item(p, r * 0.35, Vector3(r, r * 0.55, r * 1.1), yaw, Deco.ROCK))
_multi(Deco.cyl(0.18, 1.0, 6), trunks)
_multi(Deco.cone(1.0, 1.0, 7), cones)
_multi(Deco.blob(1.0), blobs)
_multi(Deco.blob(1.0), rocks)
func _item(p: Vector2, y: float, scale: Vector3, yaw: float, color: Color) -> Dictionary:
var b := Basis(Vector3.UP, yaw).scaled(scale)
return {"xf": Transform3D(b, Vector3(p.x, y, p.y)), "color": color}

1
scripts/world.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://br0aw8liqwhyj