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

1
.godot/.gdignore Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,35 @@
78098b89c88a74ebe8c20117b8754620
::res://::1787937085
anforderungen.md::TextFile::-1::1787934757::0::1::::<><><>0<>0<><>::
icon.svg::CompressedTexture2D::7737592856653423699::1787929933::1787930104::1::::<><><>0<>0<>c92e997e10ccb6e7087e553fb2cd553b<>res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex::
README.md::TextFile::-1::1787939150::0::1::::<><><>0<>0<><>::
::res://scenes/::1787929933
main.tscn::PackedScene::-1::1787929933::0::1::::<><><>0<>0<><>::res://scripts/game.gd
::res://scripts/::1787940490
audio.gd::GDScript::1473052631711783068::1787940425::0::1::::<>Node<><>0<>0<><>::
avatar.gd::GDScript::840784143843032970::1787936105::0::1::::<>Node3D<><>0<>0<><>::
camera_rig.gd::GDScript::1426445083851406527::1787935731::0::1::::<>Node3D<><>0<>0<><>::
car.gd::GDScript::3700926297587192820::1787937372::0::1::::<>Node3D<><>0<>0<><>::
cargo.gd::GDScript::8843171519162859968::1787929955::0::1::::<>RefCounted<><>0<>0<><>::
car_defs.gd::GDScript::2180866199580517034::1787937010::0::1::::<>RefCounted<><>0<>0<><>::
deco.gd::GDScript::3536410328362190212::1787930131::0::1::::<>RefCounted<><>0<>0<><>::
game.gd::GDScript::2940961735915228609::1787940490::0::1::::<>Node3D<><>0<>0<><>::
hud.gd::GDScript::2250792090561491827::1787938602::0::1::::<>CanvasLayer<><>0<>0<><>::
mat.gd::GDScript::8954544696515055531::1787929955::0::1::::<>RefCounted<><>0<>0<><>::
menu.gd::GDScript::1687670524933713443::1787937010::0::1::::<>CanvasLayer<><>0<>0<><>::
passenger.gd::GDScript::7637920710831094020::1787936843::0::1::::<>Node3D<><>0<>0<><>::
player.gd::GDScript::5093132946360448395::1787940461::0::1::::<>Node3D<><>0<>0<><>::
sfx.gd::GDScript::3251355469881263063::1787940394::0::1::::<>RefCounted<><>0<>0<><>::
shop.gd::GDScript::1277536676661524871::1787937010::0::1::::<>CanvasLayer<><>0<>0<><>::
station.gd::GDScript::7332072743510762678::1787936843::0::1::::<>Node3D<><>0<>0<><>::
synth.gd::GDScript::7866006648757722601::1787940394::0::1::::<>RefCounted<><>0<>0<><>::
track.gd::GDScript::6853275816794800658::1787929985::0::1::::<>Node3D<><>0<>0<><>::
train.gd::GDScript::3702854317530081396::1787938804::0::1::::<>Node3D<><>0<>0<><>::
world.gd::GDScript::3631281507506195397::1787936903::0::1::::<>Node3D<><>0<>0<><>::
::res://shaders/::1787930104
flat.gdshader::Shader::6556643066483047207::1787929933::0::1::::<><><>0<>0<><>::
::res://tests/::1787939150
direction_test.gd::GDScript::3301838628255199644::1787939150::0::1::::<>Node<><>0<>0<><>::
input_test.gd::GDScript::2980569713397680850::1787938805::0::1::::<>Node<><>0<>0<><>::
net_test.gd::GDScript::8148792816805061468::1787937085::0::1::::<>Node<><>0<>0<><>::
solo_test.gd::GDScript::3890389993535287616::1787937208::0::1::::<>Node<><>0<>0<><>::

View File

@@ -0,0 +1,6 @@
[folding]
node_unfolds=[NodePath("."), PackedStringArray("Visibility")]
resource_unfolds=[]
nodes_folded=[]
animation_groups_folded=[]

View File

@@ -0,0 +1,11 @@
[game_view]
select_mode=0
[editor_metadata]
executable_path="/home/arbeit/bin/godot"
[recent_files]
scenes=["res://scenes/main.tscn"]

View File

@@ -0,0 +1 @@
list=[]

View File

@@ -0,0 +1,3 @@
source_md5="b1eeaf2ccc749c08e6dadcc63f4d5f6b"
dest_md5="58824d0134c19cd8a07eff6bad3c043a"

BIN
.godot/uid_cache.bin Normal file

Binary file not shown.

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# Cozy Train
Low-Poly-Zugspiel in Godot 4.7. Eine Crew verwaltet gemeinsam einen Zug:
einer fährt, die anderen kontrollieren Tickets, servieren Getränke und
laden Güter um. First-Person, entspannter Ton, alles aus Primitiven gebaut.
## Starten
~/bin/godot --path . # Spiel
~/bin/godot --editor --path . # Editor
./tests/run.sh # Tests (Spiellogik, Eingabe, Netzwerk)
Im Menü: **Einzelspieler**, **Sitzung hosten** oder **Beitreten** mit
Adresse und Port (Standard 7654). Der Host simuliert alles, Clients
schicken Wünsche und bekommen den Zustand zurück.
Die Eingabe-Tests brauchen ein Fenster, die anderen laufen headless.
Sie schieben echte Input-Events durch die Godot-Kette (GUI -> `_unhandled_input`)
und prüfen die Wirkung im Spiel, nicht nur den Aufruf. `direction_test.gd`
vergleicht jede Laufrichtung mit den Achsen der Kamera – ein Vorzeichenfehler
beim Seitwärtsgehen fällt damit sofort auf.
## Steuerung
| Taste | Wirkung |
|-------------------|------------------------------------------|
| Maus | Umsehen |
| W A S D | Laufen (im Führerstand: W Gas, S Bremse) |
| Shift | Rennen |
| E | Benutzen; am Güterwagen halten |
| H | Pfeife (nur wer fährt) |
| M | Musik an/aus |
| V | Außenkamera (A/D drehen, R/F neigen, Rad zoomen) |
| Esc | Maus freigeben / Terminal schließen |
## Was es zu tun gibt
**Fahren.** Im Führerstand am Pult E drücken. Nur ein Spieler kann fahren;
E gibt die Steuerung wieder frei. Am Bahnsteig anhalten – der Zug gilt als
angekommen, wenn seine Mitte nah genug am Bahnsteig steht.
**Fahrgäste.** Sie warten am Bahnsteig, steigen beim Halt ein und wollen
kontrolliert werden. Ein Blick auf den Gast, E – das Ticket bringt sofort
Geld. Wer bis zum Ziel unkontrolliert bleibt, fährt schwarz und kostet
Ansehen.
**Servieren.** Mit einem Speisewagen bekommen Gäste nach einer Weile Durst.
Getränk an der Theke holen (E), zum Gast, E. Bringt Trinkgeld und hält die
Laune oben. Schlechte Laune kostet beim Aussteigen Ansehen.
**Güter.** Im Güterwagen steht eine Ladekonsole. Im Halt E gedrückt halten:
Erst wird abgeliefert, was der Bahnhof annimmt, danach geladen, was er
produziert.
| Bahnhof | produziert | nimmt an |
|-------------|-----------|-------------------|
| Waldkirch | Holz | Getreide |
| Kohlberg | Kohle | – |
| Eisenwerk | – | Kohle, Holz |
| Grüntal | Getreide | – |
| Ahornstadt | – | Getreide, Holz |
**Depot-Terminal.** Hinten im Führerstand. Dort kaufst und verkaufst du
Waggons, tauschst die Lok und schaltest Ausbauten frei. Bessere Loks und
Waggons brauchen **Ansehen** – das steigt mit zufriedenen Fahrgästen und
gelieferten Waren.
## Aufbau
Die Welt entsteht komplett zur Laufzeit, `scenes/main.tscn` ist nur der
Einstiegspunkt.
| Datei | Inhalt |
|-------|--------|
| `shaders/flat.gdshader` | Facettierte Optik über Screen-Space-Normalen |
| `scripts/game.gd` | Weltaufbau, Spielstand, Aktionen, Netzwerk |
| `scripts/car.gd` | Ein Fahrzeug: Hülle, Innenraum, Sitze, Hotspots |
| `scripts/car_defs.gd` | Stammdaten für Loks, Waggons, Ausbauten |
| `scripts/train.gd` | Verband, Fahrphysik, Zug- zu Weltkoordinaten |
| `scripts/player.gd` | First-Person-Bewegung in Zugkoordinaten |
| `scripts/passenger.gd` | Fahrgast mit Ticket, Wunsch und Laune |
| `scripts/station.gd` | Bahnsteig, Lager, Warteplätze |
| `scripts/world.gd` | Gelände und Vegetation (MultiMesh) |
| `scripts/track.gd` | Gleiskurve und Gleisoptik |
| `scripts/shop.gd`, `hud.gd`, `menu.gd` | Oberflächen |
| `scripts/camera_rig.gd` | Außenkamera |
| `scripts/synth.gd` | Wavetable-Synth: Töne, Rauschen, Hüllkurven |
| `scripts/sfx.gd` | Alle Klänge und das Musikstück als Rezept |
| `scripts/audio.gd` | Wiedergabe, Fahrgeräusch, Radschläge, Schritte |
### Zugkoordinaten
Spieler bewegen sich nicht in Weltkoordinaten, sondern in einer
Längsposition `s` entlang des Zuges plus einer Querposition `x`.
`train.locate(s)` liefert den passenden Waggon, dessen Transform die
Weltposition ergibt. Dadurch bleibt der Innenraum auch bei voller Fahrt
und in Kurven stabil begehbar, ohne Physik-Plattformen.
### Klang
Es gibt keine Audiodateien. `sfx.gd` beschreibt jeden Klang als Rezept aus
Sinustönen, Rauschen und Hüllkurven; `synth.gd` rechnet daraus PCM. Das
läuft beim Start in einem Thread (rund drei Sekunden), bis dahin bleibt es
still – das Spiel ruckelt dabei nicht.
Die Musik ist ein 27-Sekunden-Stück in 72 bpm: Flächen über Am7–Fmaj7–
Cmaj7–G6, dazu Bass, sparsame Glockentöne und ein leiser Shaker. Der
Ausklang wird auf den Anfang gemischt, dadurch ist die Schleife nahtlos.
Das Fahrgeräusch ist eine Rauschschleife, deren Lautstärke und Tonhöhe am
Tempo hängen. Die Radschläge kommen nicht per Timer, sondern alle 8,5
gefahrene Meter – deshalb passen sie automatisch zur Geschwindigkeit.
### Netzwerk
Godot-Hochsprachen-RPCs über ENet. Der Host ist maßgeblich für Zug,
Fahrgäste, Lager und Kasse. Clients senden ihre Pose (15 Hz) und
Aktionswünsche; der Host verteilt Zustandspakete (4 Hz). Die Welt wird auf
allen Rechnern aus demselben Seed erzeugt und muss deshalb nicht
übertragen werden.

16
anforderungen.md Normal file
View File

@@ -0,0 +1,16 @@
Funktionen
- Züge kaufen und verkaufen
- Zug steuern
- Im Zug mit fahren
- Passagiere transportieren
- Güter transportieren
- Multiplayer P2P
- Ein Spieler steuer
- Mehrere Spieler verwalten den Zug: Beladen, entladen, Gäste kontrollieren, servieren, ...
- Primitive Assets
- Kamera First-Person
Kern
- Mehrere Spieler verwalten einen Zug
- Bessere Züge und ausstattung kaufen
- Besseres transportieren und verwalten freischalten

8
icon.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="20" fill="#7cb85a"/>
<rect x="20" y="86" width="88" height="8" fill="#d98d4a"/>
<rect x="30" y="46" width="52" height="34" rx="4" fill="#a83b30"/>
<rect x="72" y="30" width="18" height="20" rx="3" fill="#a83b30"/>
<circle cx="44" cy="84" r="9" fill="#3a3a3a"/>
<circle cx="72" cy="84" r="9" fill="#3a3a3a"/>
</svg>

After

Width:  |  Height:  |  Size: 450 B

43
icon.svg.import Normal file
View File

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://diifa2uj3rd0n"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

20
project.godot Normal file
View File

@@ -0,0 +1,20 @@
; Engine configuration file.
config_version=5
[application]
config/name="Cozy Train"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
[rendering]
anti_aliasing/quality/msaa_3d=2
lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3
lights_and_shadows/directional_shadow/size=4096

6
scenes/main.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/game.gd" id="1_game"]
[node name="Main" type="Node3D"]
script = ExtResource("1_game")

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

11
shaders/flat.gdshader Normal file
View File

@@ -0,0 +1,11 @@
shader_type spatial;
render_mode diffuse_lambert, specular_disabled;
uniform vec4 albedo : source_color = vec4(1.0);
void fragment() {
ALBEDO = albedo.rgb * COLOR.rgb;
ROUGHNESS = 1.0;
// Facettierte (flat shaded) Optik, unabhaengig von den Mesh-Normalen.
NORMAL = normalize(cross(dFdy(VERTEX), dFdx(VERTEX)));
}

View File

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

245
tests/audio_test.gd Normal file
View File

@@ -0,0 +1,245 @@
extends Node
## Prüft Klangerzeugung und Wiedergabe.
var main
var step := 0
var wait := 0.0
var ok := 0
var fail := 0
var worst := 0.0
var elapsed := 0.0
func chk(n: String, c: bool, i := "") -> void:
if c: ok += 1; print(" OK %s %s" % [n, i])
else: fail += 1; print(" FAIL %s %s" % [n, i])
func _key(c: int, d: bool) -> void:
var e := InputEventKey.new(); e.physical_keycode = c; e.keycode = c; e.pressed = d
Input.parse_input_event(e)
func _process(d):
if main == null:
main = get_tree().current_scene
return
if not main.started:
main._start_solo()
return
if not main.audio.loaded:
elapsed += d
worst = maxf(worst, d)
return
if step == 0:
chk("Kein Ruckler beim Erzeugen", worst < 0.2,
"längster Frame %.0f ms in %.1f s Ladezeit" % [worst * 1000.0, elapsed])
if wait > 0.0:
wait -= d
return
var a = main.audio
match step:
0:
_check_harmony()
_check_shapes()
_check_loops()
var names := ["clack", "clack2", "rumble", "squeal", "whistle", "hiss",
"bell", "coin", "pour", "clink", "thud", "step", "click", "deny"]
var missing: Array[String] = []
for n in names:
if not a.sounds.has(n):
missing.append(n)
chk("Alle Klänge da", missing.is_empty(), "fehlt: %s" % [missing])
var silent: Array[String] = []
for n in a.sounds.keys():
var w: AudioStreamWAV = a.sounds[n]
if w.data.size() < 200 or _peak(w) < 1000:
silent.append(String(n))
chk("Kein Klang ist stumm", silent.is_empty(), "still: %s" % [silent])
chk("Musik erzeugt", a._music.stream != null and a._music.playing)
chk("Thread beendet", a._thread == null)
var m: AudioStreamWAV = a._music.stream
chk("Musik ist eine Schleife", m.loop_mode == AudioStreamWAV.LOOP_FORWARD)
chk("Musik lang genug", m.data.size() / 2 > 20 * 22050,
"%.1f s" % (m.data.size() / 2.0 / 22050.0))
chk("Musik nicht stumm", _peak(m) > 3000, "peak=%d" % _peak(m))
chk("Rumble läuft leise", a._rumble.playing and a._rumble.volume_db < -40.0,
"db=%.0f" % a._rumble.volume_db)
wait = 0.3
1:
# Losfahren: Rumble muss lauter werden, Radschläge kommen
main._srv_toggle_drive(1)
a.clacks = 0
_key(KEY_W, true)
wait = 9.0
2:
chk("Rumble folgt dem Tempo", a._rumble.volume_db > -30.0,
"v=%.1f db=%.0f" % [main.train.speed, a._rumble.volume_db])
chk("Radschläge kommen", a.clacks >= 3, "clacks=%d" % a.clacks)
_key(KEY_W, false)
_key(KEY_S, true)
wait = 0.5
3:
chk("Bremsen quietscht", a._squeal.volume_db > -60.0,
"db=%.0f" % a._squeal.volume_db)
# Pfeife
_key(KEY_H, true); _key(KEY_H, false)
wait = 0.2
4:
var whistling := false
for p in a._pool3d:
if p.playing and p.stream == a.sounds["whistle"]:
whistling = true
chk("H pfeift", whistling)
wait = 3.0
5:
_key(KEY_S, false)
chk("Zug steht", main.train.speed < 0.2, "v=%.2f" % main.train.speed)
chk("Rumble wieder still", a._rumble.volume_db < -40.0,
"db=%.0f" % a._rumble.volume_db)
_key(KEY_M, true); _key(KEY_M, false)
wait = 0.2
6:
chk("M schaltet Musik aus", not a.music_on and a._music.volume_db < -60.0)
_key(KEY_M, true); _key(KEY_M, false)
wait = 0.2
7:
chk("M schaltet Musik wieder an", a.music_on and a._music.volume_db > -30.0)
# Ankunftsglocke und Auszischen gezielt auslösen
main._last_dock = null
main._park_at(main.stations[1])
a.last_played = ""
main._update_audio(0.016)
chk("Glocke bei Ankunft", a.last_played == "bell", "zuletzt=%s" % a.last_played)
main._last_speed = 6.0
main.train.speed = 0.0
a.last_played = ""
main._update_audio(0.016)
chk("Zischen beim Halten", a.last_played == "hiss", "zuletzt=%s" % a.last_played)
wait = 0.2
8:
print("== %d ok, %d fail ==" % [ok, fail])
get_tree().quit(1 if fail > 0 else 0)
step += 1
func _peak(w: AudioStreamWAV) -> int:
var d := w.data
var n: int = mini(d.size() / 2, 300000)
var peak := 0
for i in range(0, n, 7):
var v: int = absi(d.decode_s16(i * 2))
if v > peak:
peak = v
return peak
## Kein Ton darf eine kleine Sekunde oder kleine None gegen eine andere
## Stimme bilden. Große Septimen sind erlaubt - die gehören zum Klang.
## An genau dieser Regel ist das C über G6 aufgefallen.
func _check_harmony() -> void:
var Sfx = load("res://scripts/sfx.gd")
var bad: Array[String] = []
for stage in Sfx.PROGRESSION:
var voices: Array[float] = []
for f in stage["pad"]:
voices.append(float(f))
voices.append(float(stage["bass"]))
# Flächen und Bass untereinander
for i in voices.size():
for j in range(i + 1, voices.size()):
_flag(bad, String(stage["name"]), voices[i], voices[j])
# Melodie gegen jede Stimme
for f in stage["melody"]:
for v in voices:
_flag(bad, String(stage["name"]), float(f), v)
chk("Keine Halbton- oder None-Reibung", bad.is_empty(), "%s" % [bad])
func _flag(bad: Array[String], chord: String, a: float, b: float) -> void:
var d: int = absi(int(round(12.0 * (log(a / b) / log(2.0)))))
if d == 1 or d == 13:
bad.append("%s: %s gegen %s (%d HT)" % [chord, _name(_pc(a)), _name(_pc(b)), d])
func _pc(f: float) -> int:
return int(round(69.0 + 12.0 * (log(f / 440.0) / log(2.0)))) % 12
func _name(pc: int) -> String:
return ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"][pc]
## Form der Schlaggeräusche: lang genug und mit Tiefe, nicht nur ein Tick.
func _check_shapes() -> void:
for n in ["clack", "clack2", "step", "thud"]:
var x := _samples(n)
chk("%s klingt aus" % n, _tail(x) > 0.18, "%.2f s hörbar" % _tail(x))
chk("%s hat Tiefe" % n, _zcr(x, 0, x.size()) < 0.22,
"Nulldurchgänge %.3f je Sample" % _zcr(x, 0, x.size()))
for n in ["clack", "clack2"]:
var r := _double_hit(_samples(n))
chk("%s ist ein Doppelschlag" % n, bool(r["ok"]),
"zweiter Schlag bei %.3f s, Einbruch davor auf %.0f %%"
% [r["at"], float(r["dip"]) * 100.0])
## Schleifen dürfen an der Naht nicht knicken, und das Quietschen darf
## seine Tonhöhe nicht weglaufen lassen.
func _check_loops() -> void:
for n in ["squeal", "rumble"]:
var x := _samples(n)
var kink: float = absf(x[0] - (2.0 * x[x.size() - 1] - x[x.size() - 2]))
var worst := 0.0
for i in range(1, x.size()):
worst = maxf(worst, absf(x[i] - x[i - 1]))
chk("%s läuft nahtlos" % n, kink < worst * 1.5,
"Knick %.4f, größter Schritt %.4f" % [kink, worst])
var sq := _samples("squeal")
var h: int = sq.size() / 2
var a := _zcr(sq, 0, h)
var b := _zcr(sq, h, sq.size())
chk("Quietschen bleibt auf Tonhöhe", absf(a - b) / maxf(a, 0.0001) < 0.15,
"Nulldurchgänge %.3f vorne, %.3f hinten" % [a, b])
func _samples(name: String) -> PackedFloat32Array:
var d: PackedByteArray = (main.audio.sounds[name] as AudioStreamWAV).data
var out := PackedFloat32Array()
out.resize(d.size() / 2)
for i in out.size():
out[i] = d.decode_s16(i * 2) / 32768.0
return out
## Sekunde, bis der Klang unter 4 % seines Höchstwerts fällt.
func _tail(x: PackedFloat32Array) -> float:
var peak := 0.0
for v in x:
peak = maxf(peak, absf(v))
var last := 0
for i in x.size():
if absf(x[i]) > peak * 0.04:
last = i
return float(last) / 22050.0
func _zcr(x: PackedFloat32Array, a: int, b: int) -> float:
var c := 0
for i in range(a + 1, b):
if (x[i] >= 0.0) != (x[i - 1] >= 0.0):
c += 1
return float(c) / float(maxi(b - a, 1))
## Sucht einen zweiten, deutlich getrennten Schlag: eine Spitze im
## Bereich 0,10-0,25 s, davor ein echter Einbruch der Hüllkurve.
func _double_hit(x: PackedFloat32Array) -> Dictionary:
var win := 66
var env := PackedFloat32Array()
env.resize(x.size() / win)
var peak := 0.0
for i in env.size():
var m := 0.0
for k in win:
m = maxf(m, absf(x[i * win + k]))
env[i] = m
peak = maxf(peak, m)
var a: int = int(0.10 * 22050.0 / win)
var b: int = mini(int(0.25 * 22050.0 / win), env.size() - 1)
var best := 0.0
var best_i := a
for i in range(a, b + 1):
if env[i] > best:
best = env[i]
best_i = i
var dip := peak
for i in range(int(0.03 * 22050.0 / win), best_i):
dip = minf(dip, env[i])
var ok: bool = best > peak * 0.22 and dip < best * 0.6
return {"ok": ok, "at": float(best_i * win) / 22050.0, "dip": dip / maxf(best, 0.0001)}

97
tests/direction_test.gd Normal file
View File

@@ -0,0 +1,97 @@
extends Node
## Prüft Bewegungs- und Blickrichtung gegen die Kamera-Achsen.
var main
var step := 0
var wait := 0.0
var ok := 0
var fail := 0
var pos0 := Vector3.ZERO
var fwd0 := Vector3.ZERO
var right0 := Vector3.ZERO
func chk(n: String, c: bool, i := "") -> void:
if c: ok += 1; print(" OK %s %s" % [n, i])
else: fail += 1; print(" FAIL %s %s" % [n, i])
func _key(c: int, d: bool) -> void:
var e := InputEventKey.new(); e.physical_keycode = c; e.keycode = c; e.pressed = d
Input.parse_input_event(e)
func _mouse(dx: float, dy: float) -> void:
var e := InputEventMouseMotion.new()
e.relative = Vector2(dx, dy)
e.position = get_viewport().get_visible_rect().size * 0.5
e.global_position = e.position
Input.parse_input_event(e)
func snap() -> void:
var c = main.player.camera
pos0 = main.player.global_position
fwd0 = -c.global_basis.z # Blickrichtung
right0 = c.global_basis.x # Bildschirm rechts
func moved() -> Vector3:
return main.player.global_position - pos0
func _process(d):
if main == null:
main = get_tree().current_scene
return
if not main.started:
main._start_solo()
return
if wait > 0.0:
wait -= d
return
var p = main.player
match step:
0:
# Freie Fläche im Personenwagen, quer zur Fahrtrichtung schauen
var car = main.train.cars[1]
p.s = car.center_s
p.x = 0.0
p.yaw = 0.0
p.pitch = 0.0
wait = 0.3
1:
snap(); _key(KEY_W, true); wait = 0.5
2:
_key(KEY_W, false)
chk("W läuft nach vorn", moved().dot(fwd0) > 0.2,
"%.2f" % moved().dot(fwd0))
wait = 0.3
3:
snap(); _key(KEY_S, true); wait = 0.5
4:
_key(KEY_S, false)
chk("S läuft rückwärts", moved().dot(fwd0) < -0.2,
"%.2f" % moved().dot(fwd0))
wait = 0.3
5:
snap(); _key(KEY_D, true); wait = 0.4
6:
_key(KEY_D, false)
chk("D läuft nach rechts", moved().dot(right0) > 0.15,
"rechts=%.2f (negativ = falsche Seite)" % moved().dot(right0))
wait = 0.3
7:
snap(); _key(KEY_A, true); wait = 0.4
8:
_key(KEY_A, false)
chk("A läuft nach links", moved().dot(right0) < -0.15,
"rechts=%.2f" % moved().dot(right0))
wait = 0.3
9:
snap(); _mouse(150.0, 0.0); wait = 0.3
10:
var f = -p.camera.global_basis.z
chk("Maus rechts dreht nach rechts", f.dot(right0) > 0.1,
"%.2f" % f.dot(right0))
snap(); _mouse(0.0, 150.0); wait = 0.3
11:
var f2 = -p.camera.global_basis.z
chk("Maus runter schaut runter", f2.y < fwd0.y - 0.05,
"y %.2f -> %.2f" % [fwd0.y, f2.y])
print("== %d ok, %d fail ==" % [ok, fail])
get_tree().quit(1 if fail > 0 else 0)
step += 1

View File

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

289
tests/input_test.gd Normal file
View File

@@ -0,0 +1,289 @@
extends Node
## Prüft die tatsächliche Eingabekette: Events -> GUI -> _unhandled_input.
var main
var step := 0
var wait := 0.0
var ok := 0
var fail := 0
var y0 := 0.0
var p0 := 0.0
var s0 := 0.0
var x0 := 0.0
var m0 := 0
var n0 := 0
func chk(name: String, cond: bool, info := "") -> void:
if cond:
ok += 1
print(" OK %s %s" % [name, info])
else:
fail += 1
print(" FAIL %s %s" % [name, info])
func _key(c: int, d: bool) -> void:
var e := InputEventKey.new()
e.physical_keycode = c
e.keycode = c
e.pressed = d
Input.parse_input_event(e)
func _mouse(dx: float, dy: float) -> void:
var e := InputEventMouseMotion.new()
e.relative = Vector2(dx, dy)
e.position = get_viewport().get_visible_rect().size * 0.5
e.global_position = e.position
Input.parse_input_event(e)
## Stellt den Spieler vor einen Hotspot und laesst ihn hinsehen.
func goto(car_index: int, kind: String) -> bool:
var car = main.train.cars[car_index]
for h in car.hotspots:
if String(h["kind"]) != kind:
continue
var pos: Vector3 = h["pos"]
var hs: float = car.center_s + pos.z
var p = main.player
for dz in [-1.0, 1.0, -1.4, 1.4, -0.7, 0.7]:
for px in [pos.x * 0.35, 0.0, pos.x * 0.6, -pos.x * 0.35]:
if p._blocked(px, hs + dz):
continue
p.x = px
p.s = hs + dz
p.yaw = atan2(pos.x - px, hs - p.s)
p.pitch = clampf(atan2(pos.y - (2.72), Vector2(pos.x - px, hs - p.s).length()), -1.2, 1.2)
return true
return false
func _process(d):
if main == null:
main = get_tree().current_scene
return
if not main.started:
main._start_solo()
return
if wait > 0.0:
wait -= d
return
var p = main.player
match step:
0:
chk("Maus gefangen", Input.mouse_mode == Input.MOUSE_MODE_CAPTURED,
"mode=%d" % Input.mouse_mode)
y0 = p.yaw
p0 = p.pitch
_mouse(120.0, 60.0)
wait = 0.3
1:
chk("Mausblick horizontal", absf(p.yaw - y0) > 0.05,
"yaw %.3f -> %.3f" % [y0, p.yaw])
chk("Mausblick vertikal", absf(p.pitch - p0) > 0.05,
"pitch %.3f -> %.3f" % [p0, p.pitch])
p.yaw = 0.0
p.pitch = 0.0
s0 = p.s
_key(KEY_W, true)
wait = 1.0
2:
_key(KEY_W, false)
chk("Vorwärts laufen", p.s - s0 > 0.5, "ds=%.2f" % (p.s - s0))
x0 = p.x
_key(KEY_D, true)
wait = 0.6
3:
_key(KEY_D, false)
chk("Seitwärts laufen", absf(p.x - x0) > 0.2, "dx=%.2f" % (p.x - x0))
# Ans Fahrpult stellen und E drücken
var loco = main.train.cars[0]
p.s = loco.center_s + loco.inner_z1 - 1.7
p.x = 0.0
p.yaw = 0.0
p.pitch = 0.0
wait = 0.3
4:
chk("Hotspot erkannt", String(p.hotspot.get("kind", "")) == "drive",
"kind=%s" % String(p.hotspot.get("kind", "-")))
_key(KEY_E, true)
wait = 0.3
5:
_key(KEY_E, false)
chk("E übernimmt Steuerung", main.driver_id == 1, "driver=%d" % main.driver_id)
chk("Spieler im Fahrmodus", p.mode == 1, "mode=%d" % p.mode)
_key(KEY_W, true)
wait = 1.5
6:
_key(KEY_W, false)
chk("W gibt Gas", main.train.speed > 1.0, "v=%.1f" % main.train.speed)
_key(KEY_S, true)
wait = 3.0
7:
_key(KEY_S, false)
chk("S bremst", main.train.speed < 0.05, "v=%.2f" % main.train.speed)
_key(KEY_E, true)
wait = 0.3
8:
_key(KEY_E, false)
chk("E gibt Steuerung frei", main.driver_id == 0)
_key(KEY_V, true)
_key(KEY_V, false)
wait = 0.3
9:
chk("V schaltet auf Außenkamera", main.cam_rig.is_active())
_key(KEY_V, true)
_key(KEY_V, false)
wait = 0.3
10:
chk("V schaltet zurück", p.camera.current)
_key(KEY_ESCAPE, true)
_key(KEY_ESCAPE, false)
wait = 0.3
11:
chk("Esc gibt Maus frei", Input.mouse_mode != Input.MOUSE_MODE_CAPTURED)
p.capture_mouse(true)
# Zweiter Spieler darf nicht dazwischenfunken
main._srv_toggle_drive(1)
main._srv_toggle_drive(2)
chk("Führerstand exklusiv", main.driver_id == 1, "driver=%d" % main.driver_id)
main._srv_toggle_drive(1)
# Zug mit Speisewagen und Gästen ausstatten
main.train.consist = ["coach", "dining", "freight"] as Array[String]
main._rebuild_train()
main._park_at(main.stations[0])
for i in 4:
main.spawn_passenger(0, 2)
wait = 7.0
12:
chk("Gäste eingestiegen", _seated() >= 4, "seated=%d" % _seated())
chk("Zum Depot-Terminal gelaufen", goto(0, "shop"))
wait = 0.3
13:
chk("Terminal im Blick", String(p.hotspot.get("kind", "")) == "shop",
"kind=%s" % String(p.hotspot.get("kind", "-")))
_key(KEY_E, true); _key(KEY_E, false)
wait = 0.4
14:
chk("Terminal offen", main.shop.is_open)
main.shop._tabs.current_tab = 1
wait = 0.3
15:
var btn := _find_button(main.shop, "Kaufen")
chk("Kaufen-Knopf sichtbar", btn != null)
m0 = main.money
n0 = main.train.consist.size()
if btn != null:
_click(btn.get_global_rect().get_center())
wait = 0.5
16:
chk("Klick im Laden kauft", main.train.consist.size() == n0 + 1 and main.money < m0,
"consist %d->%d, geld %d->%d" % [n0, main.train.consist.size(), m0, main.money])
_key(KEY_ESCAPE, true); _key(KEY_ESCAPE, false)
wait = 0.4
17:
chk("Terminal geschlossen", not main.shop.is_open)
chk("Gäste überleben den Umbau", _seated() >= 4, "seated=%d" % _seated())
chk("Maus wieder gefangen", Input.mouse_mode == Input.MOUSE_MODE_CAPTURED)
chk("Zur Theke gelaufen", goto(_car_of("tap"), "tap"))
wait = 0.3
18:
chk("Theke im Blick", String(p.hotspot.get("kind", "")) == "tap",
"kind=%s" % String(p.hotspot.get("kind", "-")))
_key(KEY_E, true); _key(KEY_E, false)
wait = 0.3
19:
chk("Getränk aufgenommen", p.carrying > 0, "carrying=%d" % p.carrying)
chk("Zum Gast gelaufen", _goto_occupied_seat())
wait = 0.3
20:
chk("Gast im Blick", String(p.hotspot.get("kind", "")) == "seat",
"kind=%s" % String(p.hotspot.get("kind", "-")))
m0 = main.money
_key(KEY_E, true); _key(KEY_E, false)
wait = 0.3
21:
chk("Ticket bringt Geld", main.money > m0, "+%d €" % (main.money - m0))
var occ = _seat_occupant()
chk("Gast erreichbar", occ != null)
if occ != null:
occ.wants_drink = true
occ.served = false
m0 = main.money
_key(KEY_E, true); _key(KEY_E, false)
wait = 0.3
22:
var occ2 = _seat_occupant()
chk("Serviert", occ2 != null and occ2.served)
chk("Trinkgeld", main.money > m0, "+%d €" % (main.money - m0))
chk("Getränk verbraucht", p.carrying == 0, "carrying=%d" % p.carrying)
chk("Zur Ladekonsole gelaufen", goto(_car_of("load"), "load"))
wait = 0.3
23:
chk("Konsole im Blick", String(p.hotspot.get("kind", "")) == "load",
"kind=%s" % String(p.hotspot.get("kind", "-")))
main.stations[0].stock = 10.0
_key(KEY_E, true)
wait = 2.5
24:
_key(KEY_E, false)
chk("E halten lädt Ware", main.train.total_cargo() > 0,
"cargo=%d" % main.train.total_cargo())
print("== %d ok, %d fail ==" % [ok, fail])
get_tree().quit(1 if fail > 0 else 0)
step += 1
func _seated() -> int:
var n := 0
for pa in main.passengers:
if pa.state == 2:
n += 1
return n
func _seat_occupant():
var h = main.player.hotspot
if String(h.get("kind", "")) != "seat":
return null
return main.train.cars[int(h["car"])].seats[int(h["seat"])]["occupant"]
func _click(pos: Vector2) -> void:
for pressed in [true, false]:
var e := InputEventMouseButton.new()
e.button_index = MOUSE_BUTTON_LEFT
e.pressed = pressed
e.position = pos
e.global_position = pos
Input.parse_input_event(e)
func _find_button(n: Node, text: String) -> Button:
if n is Button and (n as Button).text == text and not (n as Button).disabled:
return n
for c in n.get_children():
var r := _find_button(c, text)
if r != null:
return r
return null
func _car_of(kind: String) -> int:
for ci in main.train.cars.size():
for h in main.train.cars[ci].hotspots:
if String(h["kind"]) == kind:
return ci
return -1
func _goto_occupied_seat() -> bool:
var pl = main.player
for ci in main.train.cars.size():
var car = main.train.cars[ci]
for h in car.hotspots:
if String(h["kind"]) != "seat":
continue
if car.seats[int(h["seat"])]["occupant"] == null:
continue
var pos: Vector3 = h["pos"]
var hs: float = car.center_s + pos.z
for dz in [0.0, -0.8, 0.8, -1.2, 1.2]:
if pl._blocked(0.0, hs + dz):
continue
pl.x = 0.0
pl.s = hs + dz
pl.yaw = atan2(pos.x, hs - pl.s)
pl.pitch = 0.0
return true
return false

1
tests/input_test.gd.uid Normal file
View File

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

101
tests/net_test.gd Normal file
View File

@@ -0,0 +1,101 @@
extends Node
## Netzwerktest. Rolle über: -- --host bzw. -- --client
var main
var role := ""
var step := 0
var wait := 0.0
var ok := 0
var fail := 0
var m0 := 0
func _ready() -> void:
for a in OS.get_cmdline_user_args():
if a == "--host":
role = "host"
elif a == "--client":
role = "client"
func chk(name: String, cond: bool, info := "") -> void:
if cond:
ok += 1
print("[%s] OK %s %s" % [role, name, info])
else:
fail += 1
print("[%s] FAIL %s %s" % [role, name, info])
func _process(d):
if main == null:
main = get_tree().current_scene
return
if not main.started:
if role == "host":
main._start_host(7788)
elif role == "client" and step == 0:
main._start_join("127.0.0.1", 7788)
step = 1
return
if wait > 0.0:
wait -= d
return
if role == "host":
_host_step()
else:
_client_step()
func _host_step() -> void:
match step:
0:
print("[host] Sitzung gestartet")
for i in 4:
main.spawn_passenger(0, 2)
wait = 30.0
1:
print("[host] Ende. money=%d driver=%d" % [main.money, main.driver_id])
get_tree().quit()
step += 1
func _client_step() -> void:
match step:
1:
wait = 6.0
2:
chk("Welt gebaut", main.stations.size() == 5)
chk("Zug übernommen", main.train.cars.size() == 3, "cars=%d" % main.train.cars.size())
chk("Eigene Peer-ID", main.my_id > 1)
chk("Fahrgäste sichtbar", main.passengers.size() >= 4, "n=%d" % main.passengers.size())
var seated := 0
for p in main.passengers:
if p.state == 2:
seated += 1
chk("Gäste sitzen", seated >= 4, "seated=%d" % seated)
chk("Geld synchron", main.money == 1500)
main.request("drive")
wait = 1.5
3:
chk("Steuerung erhalten", main.driver_id == main.my_id)
m0 = main.money
var done := false
for ci in main.train.cars.size():
for si in main.train.cars[ci].seats.size():
var occ = main.train.cars[ci].seats[si]["occupant"]
if occ != null and not occ.ticket_checked:
main.request("seat", "", ci, si)
done = true
break
if done:
break
chk("Ticket-Anfrage gesendet", done)
wait = 1.5
4:
chk("Geld gestiegen", main.money > m0, "%d -> %d" % [m0, main.money])
main.request("buy_car", "coach")
wait = 1.5
5:
chk("Kauf synchron", main.train.consist.size() == 3)
chk("Zug neu gebaut", main.train.cars.size() == 4)
wait = 2.0
6:
chk("Kein Selbstlauf ohne Gas", main.train.speed < 0.01)
print("[client] == %d ok, %d fail ==" % [ok, fail])
get_tree().quit(1 if fail > 0 else 0)
step += 1

1
tests/net_test.gd.uid Normal file
View File

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

54
tests/run.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/bin/bash
# Führt die Testskripte aus. Sie werden als Autoload eingehängt und danach
# wird das Projekt wieder in den Ausgangszustand gebracht.
set -u
GODOT="${GODOT:-$HOME/bin/godot}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
restore() { git checkout project.godot 2>/dev/null || cp /tmp/_pg.bak project.godot; rm -f scripts/_test.gd /tmp/_pg.bak; }
cp project.godot /tmp/_pg.bak
trap restore EXIT
install_test() {
cp "$1" scripts/_test.gd
cp /tmp/_pg.bak project.godot
printf '\n[autoload]\n\nTest="*res://scripts/_test.gd"\n' >> project.godot
}
echo "=== Einzelspieler-Tests ==="
install_test tests/solo_test.gd
"$GODOT" --headless --path . --fixed-fps 60 2>&1 | grep -E "OK|FAIL|=="
SOLO=${PIPESTATUS[0]}
echo
echo "=== Eingabe-Tests (braucht ein Fenster) ==="
install_test tests/input_test.gd
"$GODOT" --path . --resolution 1280x720 2>&1 | grep -E "OK|FAIL|=="
INPUT=${PIPESTATUS[0]}
echo
echo "=== Klang-Tests ==="
install_test tests/audio_test.gd
"$GODOT" --path . --resolution 1280x720 2>&1 | grep -E "OK|FAIL|=="
AUDIO=${PIPESTATUS[0]}
echo
echo "=== Richtungs-Tests ==="
install_test tests/direction_test.gd
"$GODOT" --path . --resolution 1280x720 2>&1 | grep -E "OK|FAIL|=="
DIR=${PIPESTATUS[0]}
echo
echo "=== Netzwerk-Tests ==="
install_test tests/net_test.gd
"$GODOT" --headless --path . -- --host > /tmp/_host.log 2>&1 &
HOST=$!
sleep 6
"$GODOT" --headless --path . -- --client 2>&1 | grep -E "OK|FAIL|=="
NET=${PIPESTATUS[0]}
wait $HOST 2>/dev/null
echo
[ "$SOLO" -eq 0 ] && [ "$INPUT" -eq 0 ] && [ "$DIR" -eq 0 ] && [ "$AUDIO" -eq 0 ] && [ "$NET" -eq 0 ] && echo "Alle Tests bestanden." || echo "Es sind Tests fehlgeschlagen."
exit $(( SOLO || INPUT || DIR || AUDIO || NET ))

158
tests/solo_test.gd Normal file
View File

@@ -0,0 +1,158 @@
extends Node
var main
var step := 0
var wait := 0.0
var ok := 0
var fail := 0
var m0 := 0
func chk(name: String, cond: bool, info := "") -> void:
if cond:
ok += 1
print(" OK %s %s" % [name, info])
else:
fail += 1
print(" FAIL %s %s" % [name, info])
func seated() -> int:
var n := 0
for p in main.passengers:
if p.state == 2:
n += 1
return n
func _unchecked() -> int:
var n := 0
for p in main.passengers:
if p.state == 2 and not p.ticket_checked:
n += 1
return n
func _key(code: int, down: bool) -> void:
var e := InputEventKey.new()
e.physical_keycode = code
e.keycode = code
e.pressed = down
Input.parse_input_event(e)
func _process(d):
if main == null:
main = get_tree().current_scene
return
if not main.started:
main._start_solo()
return
if wait > 0.0:
wait -= d
return
match step:
0:
print("== Test start ==")
chk("Zug gebaut", main.train.cars.size() == 3, "cars=%d" % main.train.cars.size())
chk("Sitze", main.train.seat_count() == 8)
chk("Ladekapazität", main.train.cargo_capacity() == 10)
chk("Am Bahnhof", main.train.docked_at == main.stations[0])
var wr = main.train.walk_range()
chk("Begehbarer Bereich", wr.y - wr.x > 25.0, "%.1f m" % (wr.y - wr.x))
chk("Spieler startet im Führerstand", main.train.locate(main.player.s)["index"] == 0)
for i in 6:
main.spawn_passenger(0, 2)
chk("Wartende", main.stations[0].waiting.size() == 6)
wait = 8.0
1:
chk("Eingestiegen", seated() == 6, "seated=%d" % seated())
m0 = main.money
for p in main.passengers:
if p.state == 2:
main._check_ticket(p, 1)
chk("Ticketgeld", main.money > m0, "+%d €" % (main.money - m0))
chk("Alle kontrolliert", _unchecked() == 0)
2:
m0 = main.money
var st = main.stations[0]
st.stock = 12.0
for i in 10:
main._srv_transfer_one(1)
chk("Ladung aufgenommen", main.train.total_cargo() == 10)
chk("Lager geleert", st.stock < 3.0, "stock=%.1f" % st.stock)
3:
main._park_at(main.stations[2])
wait = 0.3
4:
chk("Am Eisenwerk", main.train.docked_at == main.stations[2])
m0 = main.money
for i in 12:
main._srv_transfer_one(1)
chk("Ware verkauft", main.train.total_cargo() == 0)
chk("Erlös", main.money - m0 >= 100, "+%d €" % (main.money - m0))
wait = 8.0
5:
var still := 0
for p in main.passengers:
if p.state == 2 and p.dest_station == 2:
still += 1
chk("Ausgestiegen", still == 0)
chk("Ansehen gestiegen", main.reputation > 0, "rep=%d" % main.reputation)
main.money = 20000
main.reputation = 50
var n0 = main.train.consist.size()
main.buy_car("dining")
chk("Speisewagen gekauft", main.train.consist.size() == n0 + 1)
chk("Speisewagen erkannt", main.train.has_dining())
main.buy_car("saloon")
chk("Salonwagen gekauft", main.train.seat_count() == 22)
wait = 0.2
6:
var v0 = main.train.max_speed()
main.buy_loco("express")
chk("Lok getauscht", main.train.loco_id == "express")
chk("Schneller", main.train.max_speed() > v0)
chk("Ausbau", main.buy_upgrade("scanner"))
chk("Ausbau nicht doppelt", not main.buy_upgrade("scanner"))
var before = main.train.consist.size()
main.sell_car(0)
chk("Wagen verkauft", main.train.consist.size() == before - 1)
wait = 0.3
7:
chk("Spieler im Zug", main.train.locate(main.player.s)["index"] == 0)
main._srv_toggle_drive(1)
chk("Steuerung übernommen", main.driver_id == 1)
_key(KEY_W, true)
wait = 3.0
8:
chk("Zug fährt", main.train.speed > 3.0, "v=%.1f m/s" % main.train.speed)
_key(KEY_W, false)
_key(KEY_S, true)
wait = 4.0
9:
chk("Zug bremst", main.train.speed < 0.01, "v=%.2f" % main.train.speed)
_key(KEY_S, false)
# Durch den ganzen Zug laufen: hinten anfangen, nach vorn gehen.
main._srv_toggle_drive(1)
chk("Steuerung abgegeben", main.driver_id == 0)
var span = main.train.walk_range()
main.player.s = span.x + 0.2
main.player.x = 0.0
main.player.yaw = 0.0
_key(KEY_W, true)
_key(KEY_SHIFT, true)
wait = 25.0
10:
_key(KEY_W, false)
_key(KEY_SHIFT, false)
var span2 = main.train.walk_range()
chk("Ganzer Zug begehbar", main.player.s > span2.y - 1.0,
"s=%.1f von %.1f" % [main.player.s, span2.y])
chk("Im Führerstand angekommen", main.train.locate(main.player.s)["index"] == 0)
main.player.x = 0.0
_key(KEY_D, true)
wait = 3.0
11:
_key(KEY_D, false)
chk("Wand hält den Spieler", absf(main.player.x) < 1.1, "x=%.2f" % main.player.x)
main.money = 200
chk("Kein Kauf ohne Geld", not main.buy_car("saloon"))
chk("Kein Kauf ohne Ansehen", not main.buy_upgrade("trolley") or main.reputation >= 20)
print("== %d ok, %d fail ==" % [ok, fail])
get_tree().quit(1 if fail > 0 else 0)
step += 1

1
tests/solo_test.gd.uid Normal file
View File

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