commit c9416ed3c656538a96f8527216e81321c98574e7 Author: team3 Date: Sat Aug 29 11:44:31 2026 +0200 first commit diff --git a/.godot/.gdignore b/.godot/.gdignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.godot/.gdignore @@ -0,0 +1 @@ + diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 new file mode 100644 index 0000000..e721f7c --- /dev/null +++ b/.godot/editor/filesystem_cache10 @@ -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<><>:: diff --git a/.godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg b/.godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg new file mode 100644 index 0000000..ae3387e --- /dev/null +++ b/.godot/editor/main.tscn-folding-3ad5c15c4f3250da0cc7c1af1770d85f.cfg @@ -0,0 +1,6 @@ +[folding] + +node_unfolds=[NodePath("."), PackedStringArray("Visibility")] +resource_unfolds=[] +nodes_folded=[] +animation_groups_folded=[] diff --git a/.godot/editor/project_metadata.cfg b/.godot/editor/project_metadata.cfg new file mode 100644 index 0000000..0f52a37 --- /dev/null +++ b/.godot/editor/project_metadata.cfg @@ -0,0 +1,11 @@ +[game_view] + +select_mode=0 + +[editor_metadata] + +executable_path="/home/arbeit/bin/godot" + +[recent_files] + +scenes=["res://scenes/main.tscn"] diff --git a/.godot/global_script_class_cache.cfg b/.godot/global_script_class_cache.cfg new file mode 100644 index 0000000..32c2394 --- /dev/null +++ b/.godot/global_script_class_cache.cfg @@ -0,0 +1 @@ +list=[] diff --git a/.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex b/.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex new file mode 100644 index 0000000..607ebc3 Binary files /dev/null and b/.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex differ diff --git a/.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.md5 b/.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.md5 new file mode 100644 index 0000000..4f1c20a --- /dev/null +++ b/.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.md5 @@ -0,0 +1,3 @@ +source_md5="b1eeaf2ccc749c08e6dadcc63f4d5f6b" +dest_md5="58824d0134c19cd8a07eff6bad3c043a" + diff --git a/.godot/uid_cache.bin b/.godot/uid_cache.bin new file mode 100644 index 0000000..e0f20d9 Binary files /dev/null and b/.godot/uid_cache.bin differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a651173 --- /dev/null +++ b/README.md @@ -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. diff --git a/anforderungen.md b/anforderungen.md new file mode 100644 index 0000000..b75782a --- /dev/null +++ b/anforderungen.md @@ -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 diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..30fadcd --- /dev/null +++ b/icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..9662d03 --- /dev/null +++ b/icon.svg.import @@ -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 diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..bc52ae2 --- /dev/null +++ b/project.godot @@ -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 diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..c5ec485 --- /dev/null +++ b/scenes/main.tscn @@ -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") diff --git a/scripts/audio.gd b/scripts/audio.gd new file mode 100644 index 0000000..6698be5 --- /dev/null +++ b/scripts/audio.gd @@ -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)) diff --git a/scripts/audio.gd.uid b/scripts/audio.gd.uid new file mode 100644 index 0000000..81639be --- /dev/null +++ b/scripts/audio.gd.uid @@ -0,0 +1 @@ +uid://u8s7xv36vrlk diff --git a/scripts/avatar.gd b/scripts/avatar.gd new file mode 100644 index 0000000..35e197b --- /dev/null +++ b/scripts/avatar.gd @@ -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 diff --git a/scripts/avatar.gd.uid b/scripts/avatar.gd.uid new file mode 100644 index 0000000..f3387f3 --- /dev/null +++ b/scripts/avatar.gd.uid @@ -0,0 +1 @@ +uid://l8juemk6wh5s diff --git a/scripts/camera_rig.gd b/scripts/camera_rig.gd new file mode 100644 index 0000000..88c9342 --- /dev/null +++ b/scripts/camera_rig.gd @@ -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 diff --git a/scripts/camera_rig.gd.uid b/scripts/camera_rig.gd.uid new file mode 100644 index 0000000..9a3999e --- /dev/null +++ b/scripts/camera_rig.gd.uid @@ -0,0 +1 @@ +uid://uk8lvq4xilb8 diff --git a/scripts/car.gd b/scripts/car.gd new file mode 100644 index 0000000..c24f354 --- /dev/null +++ b/scripts/car.gd @@ -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 diff --git a/scripts/car.gd.uid b/scripts/car.gd.uid new file mode 100644 index 0000000..70c912f --- /dev/null +++ b/scripts/car.gd.uid @@ -0,0 +1 @@ +uid://bsy0ylapauwtw diff --git a/scripts/car_defs.gd b/scripts/car_defs.gd new file mode 100644 index 0000000..b78fe15 --- /dev/null +++ b/scripts/car_defs.gd @@ -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"]) diff --git a/scripts/car_defs.gd.uid b/scripts/car_defs.gd.uid new file mode 100644 index 0000000..6bb59e7 --- /dev/null +++ b/scripts/car_defs.gd.uid @@ -0,0 +1 @@ +uid://6cosvd7thiko diff --git a/scripts/cargo.gd b/scripts/cargo.gd new file mode 100644 index 0000000..5e855b5 --- /dev/null +++ b/scripts/cargo.gd @@ -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 +] diff --git a/scripts/cargo.gd.uid b/scripts/cargo.gd.uid new file mode 100644 index 0000000..1f39665 --- /dev/null +++ b/scripts/cargo.gd.uid @@ -0,0 +1 @@ +uid://dx8x0i2u3x75m diff --git a/scripts/deco.gd b/scripts/deco.gd new file mode 100644 index 0000000..3532889 --- /dev/null +++ b/scripts/deco.gd @@ -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 diff --git a/scripts/deco.gd.uid b/scripts/deco.gd.uid new file mode 100644 index 0000000..eaa292e --- /dev/null +++ b/scripts/deco.gd.uid @@ -0,0 +1 @@ +uid://bqncfqu6smtcu diff --git a/scripts/game.gd b/scripts/game.gd new file mode 100644 index 0000000..963f70a --- /dev/null +++ b/scripts/game.gd @@ -0,0 +1,1011 @@ +extends Node3D +## Weltaufbau, Spielstand, Crew-Aktionen und Netzwerk. +## Der Host ist massgeblich: er simuliert Zug, Fahrgäste und Kasse. +## Clients schicken Wuensche und bekommen Zustaende zurueck. + +const Mat = preload("res://scripts/mat.gd") +const Deco = preload("res://scripts/deco.gd") +const Cargo = preload("res://scripts/cargo.gd") +const Defs = preload("res://scripts/car_defs.gd") +const TrackScript = preload("res://scripts/track.gd") +const WorldScript = preload("res://scripts/world.gd") +const StationScript = preload("res://scripts/station.gd") +const TrainScript = preload("res://scripts/train.gd") +const PlayerScript = preload("res://scripts/player.gd") +const PassengerScript = preload("res://scripts/passenger.gd") +const AvatarScript = preload("res://scripts/avatar.gd") +const CameraRig = preload("res://scripts/camera_rig.gd") +const AudioScript = preload("res://scripts/audio.gd") +const HudScript = preload("res://scripts/hud.gd") +const ShopScript = preload("res://scripts/shop.gd") +const MenuScript = preload("res://scripts/menu.gd") + +enum Mode { MENU, SOLO, HOST, CLIENT } + +const TRACK_POINTS := [ + Vector2(-139, -77), Vector2(-53, -101), Vector2(38, -86), Vector2(110, -48), + Vector2(144, 14), Vector2(101, 77), Vector2(14, 96), Vector2(-62, 77), + Vector2(-101, 34), Vector2(-154, -14), +] + +const STATION_DEFS := [ + ["Waldkirch", 0.04, Cargo.Type.WOOD, [Cargo.Type.GRAIN], 1.0, "c9a06a", "8a4b2f", false], + ["Kohlberg", 0.22, Cargo.Type.COAL, [], -1.0, "6f6a63", "40393a", true], + ["Eisenwerk", 0.40, -1, [Cargo.Type.COAL, Cargo.Type.WOOD], 1.0, "9aa0a6", "55606b", true], + ["Grüntal", 0.60, Cargo.Type.GRAIN, [], -1.0, "e8ddc4", "b4562f", false], + ["Ahornstadt", 0.80, -1, [Cargo.Type.GRAIN, Cargo.Type.WOOD], 1.0, "f0e6d2", "a83b30", true], +] + +const WORLD_SEED := 20260828 +const SPAWN_INTERVAL := 3.5 +const BOARD_INTERVAL := 0.45 +const LOAD_INTERVAL := 0.35 +const DRINK_TIP := 14 +const START_MONEY := 1500 +const POSE_HZ := 15.0 +const STATE_HZ := 4.0 + +var mode := Mode.MENU +var started := false +var my_id := 1 +var driver_id := 0 +var money := START_MONEY +var reputation := 0 +var upgrades: Dictionary = {} +var delivered_units := 0 + +var track: Node3D +var world: Node3D +var train: Node3D +var player: Node3D +var hud: CanvasLayer +var shop: CanvasLayer +var menu: CanvasLayer +var cam_rig: Node3D +var audio: Node +var stations: Array = [] +var passengers: Array = [] +var players: Dictionary = {} ## peer_id -> { "s", "x", "yaw", "carrying", "avatar" } + +var _rng := RandomNumberGenerator.new() +var _spawn_timer := 0.0 +var _board_timer := 0.0 +var _load_timer := 0.0 +var _pose_timer := 0.0 +var _state_timer := 0.0 +var _next_pid := 1 +var _by_pid: Dictionary = {} +var _exterior := false +var _last_dock: Node = null +var _last_speed := 0.0 + +func _ready() -> void: + menu = MenuScript.new() + add_child(menu) + menu.solo_chosen.connect(_start_solo) + menu.host_chosen.connect(_start_host) + menu.join_chosen.connect(_start_join) + multiplayer.peer_connected.connect(_on_peer_connected) + multiplayer.peer_disconnected.connect(_on_peer_disconnected) + multiplayer.connected_to_server.connect(_on_connected) + multiplayer.connection_failed.connect(_on_connect_failed) + multiplayer.server_disconnected.connect(_on_server_gone) + +func is_server() -> bool: + return mode == Mode.SOLO or mode == Mode.HOST + +func networked() -> bool: + return mode == Mode.HOST or mode == Mode.CLIENT + +# --- Start ---------------------------------------------------------------- + +func _start_solo() -> void: + mode = Mode.SOLO + my_id = 1 + _begin() + +func _start_host(port: int) -> void: + var peer := ENetMultiplayerPeer.new() + var err := peer.create_server(port, 8) + if err != OK: + menu.set_status("Port %d belegt (Fehler %d)." % [port, err]) + return + multiplayer.multiplayer_peer = peer + mode = Mode.HOST + my_id = 1 + _begin() + hud.toast("Sitzung läuft auf Port %d" % port, Color("8fd8ff")) + +func _start_join(address: String, port: int) -> void: + var peer := ENetMultiplayerPeer.new() + var err := peer.create_client(address, port) + if err != OK: + menu.set_status("Verbindung nicht möglich (Fehler %d)." % err) + return + multiplayer.multiplayer_peer = peer + mode = Mode.CLIENT + menu.set_status("Verbinde mit %s:%d ..." % [address, port]) + +func _on_connected() -> void: + my_id = multiplayer.get_unique_id() + _begin() + +func _on_connect_failed() -> void: + mode = Mode.MENU + multiplayer.multiplayer_peer = null + menu.set_status("Host nicht erreichbar.") + +func _on_server_gone() -> void: + if hud: + hud.toast("Verbindung zum Host verloren.", Color("ff9a8a")) + +func _begin() -> void: + _rng.seed = WORLD_SEED + _build_environment() + _build_track() + _build_stations() + world = WorldScript.new() + add_child(world) + world.build(_rng, track, stations) + _build_train() + _build_player() + _build_ui() + audio = AudioScript.new() + add_child(audio) + players[my_id] = {"s": player.s, "x": player.x, "yaw": 0.0, "carrying": 0, "avatar": null} + menu.hide_menu() + player.capture_mouse(true) + started = true + +# --- Aufbau --------------------------------------------------------------- + +func _build_environment() -> void: + var sky_mat := ProceduralSkyMaterial.new() + sky_mat.sky_top_color = Color("5fa8e0") + sky_mat.sky_horizon_color = Color("cfe6f0") + sky_mat.ground_bottom_color = Color("bdb6a6") + sky_mat.ground_horizon_color = Color("d8e2e6") + sky_mat.sun_angle_max = 30.0 + var sky := Sky.new() + sky.sky_material = sky_mat + var env := Environment.new() + env.background_mode = Environment.BG_SKY + env.sky = sky + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color("d5dde2") + env.ambient_light_energy = 0.62 + env.tonemap_mode = Environment.TONE_MAPPER_FILMIC + env.tonemap_white = 4.0 + var we := WorldEnvironment.new() + we.environment = env + add_child(we) + + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-48, -125, 0) + sun.light_energy = 1.5 + sun.light_color = Color("fff2dd") + sun.shadow_enabled = true + sun.light_angular_distance = 1.4 + sun.directional_shadow_max_distance = 260.0 + sun.shadow_bias = 0.06 + add_child(sun) + +func _build_track() -> void: + track = TrackScript.new() + add_child(track) + var pts := PackedVector2Array() + for p in TRACK_POINTS: + pts.append(p) + track.build(pts) + +func _build_stations() -> void: + var l: float = track.length() + for d in STATION_DEFS: + var st := StationScript.new() + add_child(st) + var acc: Array[int] = [] + for t in d[3]: + acc.append(int(t)) + st.setup(String(d[0]), l * float(d[1]), int(d[2]), acc) + st.build_visuals(track.sample(st.progress), float(d[4]), + Color(String(d[5])), Color(String(d[6])), bool(d[7])) + if st.produces >= 0: + st.stock = 8.0 + stations.append(st) + +func _build_train() -> void: + train = TrainScript.new() + add_child(train) + train.setup(track, stations) + train.consist = ["coach", "freight"] as Array[String] + train.rebuild() + _park_at(stations[0]) + +## Stellt den Zug mittig an einen Bahnsteig. +func _park_at(st) -> void: + train.progress = fposmod(st.progress + train.total_length() * 0.5, track.length()) + train.apply_snapshot(train.progress, 0.0) + +func _build_player() -> void: + player = PlayerScript.new() + add_child(player) + player.setup(train) + player.interact_pressed.connect(_on_interact) + + cam_rig = CameraRig.new() + add_child(cam_rig) + cam_rig.target = train.followers[0] + cam_rig.snap_to(track.sample(train.progress).origin) + player.camera.current = true + +func _build_ui() -> void: + hud = HudScript.new() + add_child(hud) + shop = ShopScript.new() + add_child(shop) + shop.setup(self) + shop.closed.connect(_on_shop_closed) + +# --- Ablauf --------------------------------------------------------------- + +func _process(delta: float) -> void: + if not started: + return + if is_server(): + for st in stations: + st.tick(delta) + _spawn_passengers(delta) + _update_passengers(delta) + _handle_station_stop(delta) + _handle_hold_actions(delta) + _update_audio(delta) + _sync_out(delta) + _update_avatars() + hud.refresh(self) + +func _physics_process(delta: float) -> void: + if not started: + return + var driving: bool = driver_id == my_id and not player.input_locked + var th := 0.0 + var br := false + if driving: + th = 1.0 if Input.is_key_pressed(KEY_W) else 0.0 + br = Input.is_key_pressed(KEY_S) or Input.is_key_pressed(KEY_SPACE) + if is_server(): + if driver_id == my_id: + train.throttle = th + train.braking = br + train.drive(delta) + elif driving: + rpc_id(1, "net_controls", th, br) + +func _unhandled_input(event: InputEvent) -> void: + if not started: + return + if event is InputEventKey and event.pressed and not event.echo: + match event.keycode: + KEY_H: + if driver_id == my_id: + request("whistle") + KEY_M: + hud.toast("Musik %s" % ("an" if audio.toggle_music() else "aus"), + Color("8fd8ff")) + KEY_V: + _toggle_camera() + KEY_ESCAPE: + if shop.is_open: + shop.close() + else: + player.capture_mouse(Input.mouse_mode != Input.MOUSE_MODE_CAPTURED) + +func _toggle_camera() -> void: + _exterior = not _exterior + if _exterior: + cam_rig.activate() + else: + player.camera.current = true + +# --- Klang ---------------------------------------------------------------- + +func _update_audio(delta: float) -> void: + audio.update_train(train.speed, train.max_speed(), train.braking, delta) + audio.update_steps(player.move_speed(), delta) + # Ankunft und Auszischen beim Halten + if train.docked_at != _last_dock: + if train.docked_at != null and train.speed < 0.1: + audio.play_at("bell", train.docked_at.global_position, -6.0) + _last_dock = train.docked_at + if _last_speed > 2.0 and train.speed < 0.2: + audio.play_at("hiss", _loco_pos(), -10.0) + _last_speed = train.speed + +func _loco_pos() -> Vector3: + return train.cars[0].global_position if not train.cars.is_empty() else global_position + +func _play_sfx(name: String, at_loco := false, db := 0.0) -> void: + if at_loco: + audio.play_at(name, _loco_pos(), db) + else: + audio.play(name, db) + +## Klang bei allen Mitspielern auslösen. +func _broadcast_sfx(name: String, at_loco := false, db := 0.0) -> void: + _play_sfx(name, at_loco, db) + if mode == Mode.HOST: + rpc("net_sfx", name, at_loco, db) + +## Klang nur bei einem bestimmten Spieler. +func _sfx_to(peer: int, name: String, db := 0.0) -> void: + if peer == my_id: + _play_sfx(name, false, db) + elif networked(): + rpc_id(peer, "net_sfx", name, false, db) + +# --- Netzwerk: Ausgang ---------------------------------------------------- + +func _sync_out(delta: float) -> void: + if not networked(): + players[my_id]["s"] = player.s + players[my_id]["x"] = player.x + players[my_id]["yaw"] = player.yaw + return + _pose_timer -= delta + if _pose_timer <= 0.0: + _pose_timer = 1.0 / POSE_HZ + if is_server(): + players[my_id]["s"] = player.s + players[my_id]["x"] = player.x + players[my_id]["yaw"] = player.yaw + rpc("net_poses", _pose_payload()) + else: + rpc_id(1, "net_pose", player.s, player.x, player.yaw) + if is_server(): + _state_timer -= delta + if _state_timer <= 0.0: + _state_timer = 1.0 / STATE_HZ + rpc("net_train", train.progress, train.speed) + rpc("net_state", _state_payload()) + rpc("net_passengers", _passenger_payload()) + +func _pose_payload() -> Array: + var out: Array = [] + for id in players.keys(): + var e: Dictionary = players[id] + out.append([int(id), float(e["s"]), float(e["x"]), float(e["yaw"]), int(e["carrying"])]) + return out + +func _state_payload() -> Dictionary: + var stock: Array = [] + for st in stations: + stock.append(st.stock) + return { + "money": money, "rep": reputation, "upg": upgrades.keys(), + "driver": driver_id, "cargo": train.cargo.duplicate(), "stock": stock, + "loco": train.loco_id, "consist": train.consist.duplicate(), + } + +func _passenger_payload() -> Array: + var out: Array = [] + for p in passengers: + out.append({ + "id": p.pid, "st": p.state, "dest": p.dest_station, "fare": p.fare, + "chk": p.ticket_checked, "want": p.wants_drink, "srv": p.served, + "mood": p.mood, "car": p.seat_car, "seat": p.seat_index, + "pos": p.global_position, "yaw": p.global_rotation.y, + }) + return out + +# --- Netzwerk: Eingang ---------------------------------------------------- + +@rpc("any_peer", "call_remote", "unreliable_ordered") +func net_pose(s: float, x: float, yaw: float) -> void: + var id := multiplayer.get_remote_sender_id() + if not players.has(id): + players[id] = {"s": s, "x": x, "yaw": yaw, "carrying": 0, "avatar": null} + players[id]["s"] = s + players[id]["x"] = x + players[id]["yaw"] = yaw + +@rpc("authority", "call_remote", "unreliable_ordered") +func net_poses(list: Array) -> void: + var seen := {} + for e in list: + var id := int(e[0]) + seen[id] = true + if not players.has(id): + players[id] = {"s": 0.0, "x": 0.0, "yaw": 0.0, "carrying": 0, "avatar": null} + if id != my_id: + players[id]["s"] = float(e[1]) + players[id]["x"] = float(e[2]) + players[id]["yaw"] = float(e[3]) + players[id]["carrying"] = int(e[4]) + if id == my_id: + player.carrying = int(e[4]) + for id in players.keys(): + if not seen.has(id): + _drop_player(int(id)) + +@rpc("authority", "call_remote", "unreliable_ordered") +func net_train(p: float, v: float) -> void: + train.apply_snapshot(p, v) + +@rpc("authority", "call_remote", "reliable") +func net_state(d: Dictionary) -> void: + money = int(d["money"]) + reputation = int(d["rep"]) + upgrades.clear() + for k in d["upg"]: + upgrades[String(k)] = true + driver_id = int(d["driver"]) + if driver_id != my_id and player.mode == PlayerScript.Mode.DRIVE: + player.mode = PlayerScript.Mode.WALK + elif driver_id == my_id: + player.mode = PlayerScript.Mode.DRIVE + var stock: Array = d["stock"] + for i in mini(stock.size(), stations.size()): + stations[i].stock = float(stock[i]) + var loco := String(d["loco"]) + var consist: Array = d["consist"] + if loco != train.loco_id or consist != Array(train.consist): + train.loco_id = loco + var typed: Array[String] = [] + for c in consist: + typed.append(String(c)) + train.consist = typed + _rebuild_train() + train.cargo = (d["cargo"] as Dictionary).duplicate() + train.refresh_cargo_visuals() + if shop.is_open: + shop.refresh() + +@rpc("authority", "call_remote", "reliable") +func net_passengers(list: Array) -> void: + for c in train.cars: + for sd in c.seats: + sd["occupant"] = null + var seen := {} + for e in list: + var pid := int(e["id"]) + seen[pid] = true + var p = _by_pid.get(pid) + if p == null: + p = _make_passenger(pid) + p.state = int(e["st"]) + p.dest_station = int(e["dest"]) + p.fare = int(e["fare"]) + p.ticket_checked = bool(e["chk"]) + p.wants_drink = bool(e["want"]) + p.served = bool(e["srv"]) + p.mood = float(e["mood"]) + p.seat_car = int(e["car"]) + p.seat_index = int(e["seat"]) + _place_remote_passenger(p, e) + p._refresh_label() + for pid in _by_pid.keys(): + if not seen.has(pid): + var p = _by_pid[pid] + passengers.erase(p) + p.queue_free() + _by_pid.erase(pid) + +@rpc("authority", "call_remote", "reliable") +func net_toast(text: String, color: Color) -> void: + hud.toast(text, color) + +@rpc("any_peer", "call_remote", "reliable") +func net_sfx(name: String, at_loco: bool, db: float) -> void: + _play_sfx(name, at_loco, db) + +@rpc("any_peer", "call_remote", "unreliable_ordered") +func net_controls(th: float, br: bool) -> void: + if not is_server(): + return + if multiplayer.get_remote_sender_id() != driver_id: + return + train.throttle = th + train.braking = br + +@rpc("any_peer", "call_remote", "reliable") +func net_request(action: String, a: String, b: int, c: int) -> void: + if not is_server(): + return + _do_request(multiplayer.get_remote_sender_id(), action, a, b, c) + +func _place_remote_passenger(p, e: Dictionary) -> void: + if p.state == PassengerScript.State.SEATED or p.state == PassengerScript.State.BOARDING: + if p.seat_car < 0 or p.seat_car >= train.cars.size(): + return + var car = train.cars[p.seat_car] + if p.seat_index < 0 or p.seat_index >= car.seats.size(): + return + if p.get_parent() != car: + p.reparent(car, false) + var seat: Dictionary = car.seats[p.seat_index] + seat["occupant"] = p + p.position = Vector3(seat["pos"].x, seat["pos"].y + 0.42 - 0.8, seat["pos"].z) + p.rotation = Vector3(0, float(seat["yaw"]), 0) + p.set_seated(true) + else: + if p.get_parent() != self: + p.reparent(self, false) + p.global_position = e["pos"] + p.global_rotation = Vector3(0, float(e["yaw"]), 0) + p.set_seated(false) + +func _make_passenger(pid: int): + var p := PassengerScript.new() + var r := RandomNumberGenerator.new() + r.seed = pid * 7919 + 13 + p.build(r) + p.pid = pid + add_child(p) + passengers.append(p) + _by_pid[pid] = p + return p + +# --- Peers ---------------------------------------------------------------- + +func _on_peer_connected(id: int) -> void: + if not is_server() or not started: + return + players[id] = {"s": 0.0, "x": 0.0, "yaw": 0.0, "carrying": 0, "avatar": null} + rpc_id(id, "net_state", _state_payload()) + rpc_id(id, "net_passengers", _passenger_payload()) + hud.toast("Spieler %d ist an Bord" % id, Color("8fd8ff")) + +func _on_peer_disconnected(id: int) -> void: + _drop_player(id) + if driver_id == id: + driver_id = 0 + if hud: + hud.toast("Spieler %d hat den Zug verlassen" % id, Color("ffd978")) + +func _drop_player(id: int) -> void: + if id == my_id or not players.has(id): + return + var av = players[id]["avatar"] + if av != null: + av.queue_free() + players.erase(id) + +func _update_avatars() -> void: + for id in players.keys(): + if int(id) == my_id: + continue + var e: Dictionary = players[id] + if e["avatar"] == null: + var av := AvatarScript.new() + av.build(int(id), "Crew %d" % int(id)) + add_child(av) + e["avatar"] = av + var av2 = e["avatar"] + var loc: Dictionary = train.locate(float(e["s"])) + var xf: Transform3D = train.car_transform(int(loc["index"])) + av2.global_position = xf * Vector3(float(e["x"]), 1.10, float(loc["local_z"])) + av2.global_rotation = Vector3(0, xf.basis.get_euler().y + float(e["yaw"]), 0) + av2.set_carrying(int(e["carrying"])) + av2.set_label("Crew %d%s" % [int(id), " (Führerstand)" if int(id) == driver_id else ""]) + +# --- Fahrgäste ----------------------------------------------------------- + +func _spawn_passengers(delta: float) -> void: + _spawn_timer -= delta + if _spawn_timer > 0.0: + return + _spawn_timer = SPAWN_INTERVAL + spawn_passenger(_rng.randi() % stations.size()) + +func spawn_passenger(idx: int, forced_dest := -1) -> bool: + var st = stations[idx] + if st.waiting.size() >= st.waiting_spots.size(): + return false + var dest := forced_dest + if dest < 0: + dest = idx + while dest == idx: + dest = _rng.randi() % stations.size() + var pid := _next_pid + _next_pid += 1 + var p = _make_passenger(pid) + p.origin_station = idx + p.dest_station = dest + p.fare = _fare(idx, dest) + var spot: Vector3 = st.waiting_spots[st.waiting.size()] + p.global_position = spot + p.global_rotation = Vector3(0, track.sample(st.progress).basis.get_euler().y + PI * 0.5 * st.side, 0) + st.waiting.append(p) + return true + +func _fare(a: int, b: int) -> int: + var l: float = track.length() + var d: float = fposmod(stations[b].progress - stations[a].progress, l) + var bonus := 1.0 + for c in train.cars: + if c.id == "saloon": + bonus = 1.5 + break + return int(round((14.0 + d * 0.11) * bonus)) + +func _update_passengers(delta: float) -> void: + var has_dining: bool = train.has_dining() + for p in passengers.duplicate(): + match p.state: + PassengerScript.State.SEATED: + p.tick(delta, has_dining) + PassengerScript.State.BOARDING: + if p.advance_lerp(delta): + _finish_boarding(p) + PassengerScript.State.ALIGHTING: + if p.advance_lerp(delta): + _finish_alighting(p) + +func _finish_boarding(p) -> void: + var car = train.cars[p.seat_car] + p.reparent(car, true) + var seat: Dictionary = car.seats[p.seat_index] + p.position = Vector3(seat["pos"].x, seat["pos"].y + 0.42 - 0.8, seat["pos"].z) + p.rotation = Vector3(0, float(seat["yaw"]), 0) + p.set_seated(true) + p.state = PassengerScript.State.SEATED + +func _finish_alighting(p) -> void: + passengers.erase(p) + _by_pid.erase(p.pid) + p.queue_free() + +func _handle_station_stop(delta: float) -> void: + var st = train.docked_at + if st == null or train.speed > 0.1: + _board_timer = 0.0 + return + _board_timer -= delta + if _board_timer > 0.0: + return + _board_timer = BOARD_INTERVAL + for p in passengers: + if p.state == PassengerScript.State.SEATED and stations[p.dest_station] == st: + _alight(p, st) + return + if st.waiting.is_empty(): + return + var seat := _find_free_seat() + if seat.is_empty(): + return + var p2 = st.waiting.pop_front() + _reindex_waiting(st) + p2.seat_car = int(seat["car"]) + p2.seat_index = int(seat["index"]) + var car = train.cars[p2.seat_car] + var sd: Dictionary = car.seats[p2.seat_index] + sd["occupant"] = p2 + var target_xf: Transform3D = car.global_transform.translated_local( + Vector3(sd["pos"].x, sd["pos"].y + 0.42 - 0.8, sd["pos"].z)) + target_xf.basis = car.global_transform.basis.rotated(Vector3.UP, float(sd["yaw"])) + p2.set_seated(true) + p2.state = PassengerScript.State.BOARDING + p2.start_lerp(p2.global_transform, target_xf, 1.4) + +func _alight(p, st) -> void: + var car = train.cars[p.seat_car] + car.seats[p.seat_index]["occupant"] = null + p.reparent(self, true) + p.set_seated(false) + var spot: Vector3 = st.waiting_spots[_rng.randi() % st.waiting_spots.size()] + p.state = PassengerScript.State.ALIGHTING + p.start_lerp(p.global_transform, Transform3D(p.global_transform.basis, spot), 1.4) + _settle_fare(p) + +func _settle_fare(p) -> void: + if not p.ticket_checked: + reputation = maxi(0, reputation - 2) + _broadcast_toast("Schwarzfahrer entkommen. Ansehen -2", Color("ff9a8a")) + _broadcast_sfx("deny", false, -10.0) + return + if p.mood > 0.55: + reputation += 1 + elif p.mood < 0.35: + reputation = maxi(0, reputation - 1) + +func _reindex_waiting(st) -> void: + for i in st.waiting.size(): + st.waiting[i].global_position = st.waiting_spots[i] + +func _find_free_seat() -> Dictionary: + for ci in train.cars.size(): + var idx: int = train.cars[ci].free_seat() + if idx >= 0: + return {"car": ci, "index": idx} + return {} + +# --- Aktionen ------------------------------------------------------------- + +func _on_interact(h: Dictionary) -> void: + var kind := String(h["kind"]) + audio.play("click", -16.0) + match kind: + "shop": + _open_shop() + "drive": + request("drive", "", 0, 0) + "tap": + request("tap", "", 0, 0) + "seat": + request("seat", "", int(h["car"]), int(h["seat"])) + +## Fuehrt eine Aktion aus - lokal beim Host, sonst als Anfrage. +func request(action: String, a := "", b := 0, c := 0) -> void: + if is_server(): + _do_request(my_id, action, a, b, c) + else: + rpc_id(1, "net_request", action, a, b, c) + +func _do_request(peer: int, action: String, a: String, b: int, c: int) -> void: + match action: + "drive": _srv_toggle_drive(peer) + "tap": _srv_take_drinks(peer) + "seat": _srv_use_seat(peer, b, c) + "load": _srv_transfer_one(peer) + "buy_loco": _srv_buy_loco(peer, a) + "buy_car": _srv_buy_car(peer, a) + "sell_car": _srv_sell_car(peer, b) + "buy_upgrade": _srv_buy_upgrade(peer, a) + "whistle": _srv_whistle(peer) + +func _toast_to(peer: int, text: String, color: Color) -> void: + if peer == my_id: + hud.toast(text, color) + elif networked(): + rpc_id(peer, "net_toast", text, color) + +func _broadcast_toast(text: String, color: Color) -> void: + hud.toast(text, color) + if networked(): + rpc("net_toast", text, color) + +func _srv_toggle_drive(peer: int) -> void: + if driver_id == peer: + driver_id = 0 + train.throttle = 0.0 + _toast_to(peer, "Steuerung abgegeben", Color("cfd8e0")) + elif driver_id == 0: + driver_id = peer + _toast_to(peer, "Du fährst jetzt. W = Gas, S = Bremse", Color("aef2a0")) + else: + _toast_to(peer, "Der Führerstand ist besetzt", Color("ffd978")) + _sfx_to(peer, "deny", -12.0) + if peer == my_id: + player.mode = PlayerScript.Mode.DRIVE if driver_id == my_id else PlayerScript.Mode.WALK + _push_state() + +func _srv_whistle(peer: int) -> void: + if peer != driver_id: + return + _broadcast_sfx("whistle", true, -6.0) + +func _srv_take_drinks(peer: int) -> void: + if not train.has_dining(): + return + var n := 3 if upgrades.has("trolley") else 1 + _set_carrying(peer, n) + _toast_to(peer, "%d Getränk(e) aufgenommen" % n, Color("8fd8ff")) + _sfx_to(peer, "pour", -9.0) + +func _carrying(peer: int) -> int: + return int(players.get(peer, {}).get("carrying", 0)) + +func _set_carrying(peer: int, n: int) -> void: + if not players.has(peer): + players[peer] = {"s": 0.0, "x": 0.0, "yaw": 0.0, "carrying": 0, "avatar": null} + players[peer]["carrying"] = n + if peer == my_id: + player.carrying = n + +func _srv_use_seat(peer: int, car_index: int, seat_index: int) -> void: + if car_index < 0 or car_index >= train.cars.size(): + return + var car = train.cars[car_index] + if seat_index < 0 or seat_index >= car.seats.size(): + return + var occ = car.seats[seat_index]["occupant"] + if occ == null: + return + if not occ.ticket_checked: + if upgrades.has("scanner"): + _check_all_tickets(peer) + else: + _check_ticket(occ, peer) + return + if occ.wants_drink and not occ.served: + if _carrying(peer) <= 0: + _toast_to(peer, "Kein Getränk dabei. Ab in den Speisewagen!", Color("ffd978")) + _sfx_to(peer, "deny", -12.0) + return + _set_carrying(peer, _carrying(peer) - 1) + occ.served = true + occ.wants_drink = false + occ.mood = minf(occ.mood + 0.4, 1.0) + money += DRINK_TIP + _toast_to(peer, "Serviert. Trinkgeld +%d €" % DRINK_TIP, Color("aef2a0")) + _sfx_to(peer, "clink", -7.0) + occ._refresh_label() + _push_state() + return + _toast_to(peer, "%s - %s" % [stations[occ.dest_station].station_name, occ.mood_word()], + Color("cfd8e0")) + +func _check_ticket(p, peer: int) -> void: + p.ticket_checked = true + money += p.fare + _toast_to(peer, "Ticket nach %s +%d €" % [stations[p.dest_station].station_name, p.fare], + Color("aef2a0")) + _sfx_to(peer, "coin", -6.0) + p._refresh_label() + _push_state() + +func _check_all_tickets(peer: int) -> void: + var sum := 0 + var n := 0 + for p in passengers: + if p.state == PassengerScript.State.SEATED and not p.ticket_checked: + p.ticket_checked = true + sum += p.fare + n += 1 + p._refresh_label() + money += sum + _toast_to(peer, "%d Tickets gescannt +%d €" % [n, sum], Color("aef2a0")) + _sfx_to(peer, "coin", -4.0) + _push_state() + +func _handle_hold_actions(delta: float) -> void: + if player.input_locked or player.hotspot.is_empty() \ + or String(player.hotspot.get("kind", "")) != "load" \ + or not Input.is_key_pressed(KEY_E): + _load_timer = 0.0 + return + if train.docked_at == null or train.speed > 0.1: + return + _load_timer -= delta + if _load_timer > 0.0: + return + _load_timer = LOAD_INTERVAL * (0.5 if upgrades.has("loader") else 1.0) + request("load") + +func _srv_transfer_one(peer: int) -> void: + var st = train.docked_at + if st == null or train.speed > 0.1: + return + for t in st.accepts: + if int(train.cargo.get(t, 0)) > 0: + train.remove_cargo(int(t), 1) + money += Cargo.PRICES[int(t)] + delivered_units += 1 + if delivered_units % 10 == 0: + reputation += 1 + _toast_to(peer, "%s geliefert +%d €" % [Cargo.NAMES[int(t)], Cargo.PRICES[int(t)]], + Color("aef2a0")) + _broadcast_sfx("thud", false, -11.0) + _sfx_to(peer, "coin", -12.0) + _push_state() + return + if st.produces >= 0 and st.stock >= 1.0: + if train.total_cargo() >= train.cargo_capacity(): + _toast_to(peer, "Kein Platz mehr im Güterwagen", Color("ffd978")) + _sfx_to(peer, "deny", -12.0) + return + if train.add_cargo(st.produces, 1) > 0: + st.stock -= 1.0 + _broadcast_sfx("thud", false, -11.0) + _push_state() + +func _push_state() -> void: + if mode == Mode.HOST: + rpc("net_state", _state_payload()) + +# --- Laden ---------------------------------------------------------------- + +func _open_shop() -> void: + player.input_locked = true + player.capture_mouse(false) + shop.open() + +func _on_shop_closed() -> void: + player.input_locked = false + player.capture_mouse(true) + +func can_buy(price: int, rep: int) -> bool: + return money >= price and reputation >= rep + +func _srv_buy_loco(peer: int, id: String) -> void: + if not Defs.LOCOS.has(id): + return + var d: Dictionary = Defs.LOCOS[id] + if id == train.loco_id or not can_buy(int(d["price"]), int(d["rep"])): + return + money -= int(d["price"]) + money += int(Defs.LOCOS[train.loco_id]["sell"]) + train.loco_id = id + _rebuild_train() + _broadcast_toast("Neue Lok: %s" % String(d["name"]), Color("aef2a0")) + _broadcast_sfx("coin", false, -5.0) + _push_state() + +func _srv_buy_car(peer: int, id: String) -> void: + if not Defs.CARS.has(id): + return + var d: Dictionary = Defs.CARS[id] + if not can_buy(int(d["price"]), int(d["rep"])) or train.consist.size() >= 6: + return + money -= int(d["price"]) + train.consist.append(id) + _rebuild_train() + _broadcast_toast("Angehaengt: %s" % String(d["name"]), Color("aef2a0")) + _push_state() + +func _srv_sell_car(peer: int, index: int) -> void: + if index < 0 or index >= train.consist.size(): + return + var id: String = train.consist[index] + money += int(Defs.CARS[id]["sell"]) + train.consist.remove_at(index) + _rebuild_train() + _broadcast_toast("Verkauft: %s" % Defs.display_name(id), Color("ffd978")) + _broadcast_sfx("coin", false, -5.0) + _push_state() + +func _srv_buy_upgrade(peer: int, id: String) -> void: + if not Defs.UPGRADES.has(id): + return + var d: Dictionary = Defs.UPGRADES[id] + if upgrades.has(id) or not can_buy(int(d["price"]), int(d["rep"])): + return + money -= int(d["price"]) + upgrades[id] = true + _broadcast_toast("Ausbau: %s" % String(d["name"]), Color("aef2a0")) + _broadcast_sfx("coin", false, -5.0) + _push_state() + +# Bequeme Namen für Tests und die Ladenoberflaeche. +func buy_loco(id: String) -> bool: + var before: String = train.loco_id + request("buy_loco", id) + return train.loco_id != before + +func buy_car(id: String) -> bool: + var before: int = train.consist.size() + request("buy_car", id) + return train.consist.size() != before + +func sell_car(index: int) -> bool: + var before: int = train.consist.size() + request("sell_car", "", index) + return train.consist.size() != before + +func buy_upgrade(id: String) -> bool: + var before := upgrades.size() + request("buy_upgrade", id) + return upgrades.size() != before + +func _rebuild_train() -> void: + # Sitzende Gaeste zwischenparken, sonst verschwinden sie mit dem alten Wagen. + var riders: Array = [] + for p in passengers.duplicate(): + if p.state == PassengerScript.State.SEATED or p.state == PassengerScript.State.BOARDING: + p.reparent(self, true) + riders.append(p) + train.rebuild() + for p in riders: + var seat := _find_free_seat() + if seat.is_empty(): + passengers.erase(p) + _by_pid.erase(p.pid) + p.queue_free() + continue + p.seat_car = int(seat["car"]) + p.seat_index = int(seat["index"]) + train.cars[p.seat_car].seats[p.seat_index]["occupant"] = p + _finish_boarding(p) + cam_rig.target = train.followers[0] + player.snap_to_cab() + if driver_id == my_id: + player.mode = PlayerScript.Mode.DRIVE diff --git a/scripts/game.gd.uid b/scripts/game.gd.uid new file mode 100644 index 0000000..c2c651d --- /dev/null +++ b/scripts/game.gd.uid @@ -0,0 +1 @@ +uid://bh5vhe5gn4rd6 diff --git a/scripts/hud.gd b/scripts/hud.gd new file mode 100644 index 0000000..23d5059 --- /dev/null +++ b/scripts/hud.gd @@ -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) diff --git a/scripts/hud.gd.uid b/scripts/hud.gd.uid new file mode 100644 index 0000000..20dd261 --- /dev/null +++ b/scripts/hud.gd.uid @@ -0,0 +1 @@ +uid://7ckhmyrrsbx8 diff --git a/scripts/mat.gd b/scripts/mat.gd new file mode 100644 index 0000000..ce34a03 --- /dev/null +++ b/scripts/mat.gd @@ -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 diff --git a/scripts/mat.gd.uid b/scripts/mat.gd.uid new file mode 100644 index 0000000..50c6df1 --- /dev/null +++ b/scripts/mat.gd.uid @@ -0,0 +1 @@ +uid://d0twbpthct6rv diff --git a/scripts/menu.gd b/scripts/menu.gd new file mode 100644 index 0000000..eb3aa37 --- /dev/null +++ b/scripts/menu.gd @@ -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 diff --git a/scripts/menu.gd.uid b/scripts/menu.gd.uid new file mode 100644 index 0000000..6af4717 --- /dev/null +++ b/scripts/menu.gd.uid @@ -0,0 +1 @@ +uid://ybrxme2724st diff --git a/scripts/passenger.gd b/scripts/passenger.gd new file mode 100644 index 0000000..93e63fd --- /dev/null +++ b/scripts/passenger.gd @@ -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" diff --git a/scripts/passenger.gd.uid b/scripts/passenger.gd.uid new file mode 100644 index 0000000..f547327 --- /dev/null +++ b/scripts/passenger.gd.uid @@ -0,0 +1 @@ +uid://dg24o5s0meuye diff --git a/scripts/player.gd b/scripts/player.gd new file mode 100644 index 0000000..093271e --- /dev/null +++ b/scripts/player.gd @@ -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} diff --git a/scripts/player.gd.uid b/scripts/player.gd.uid new file mode 100644 index 0000000..be30e9b --- /dev/null +++ b/scripts/player.gd.uid @@ -0,0 +1 @@ +uid://cetffj1gko2vf diff --git a/scripts/sfx.gd b/scripts/sfx.gd new file mode 100644 index 0000000..2f19931 --- /dev/null +++ b/scripts/sfx.gd @@ -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) diff --git a/scripts/sfx.gd.uid b/scripts/sfx.gd.uid new file mode 100644 index 0000000..169419a --- /dev/null +++ b/scripts/sfx.gd.uid @@ -0,0 +1 @@ +uid://bmk8l5ddth83t diff --git a/scripts/shop.gd b/scripts/shop.gd new file mode 100644 index 0000000..83f77ba --- /dev/null +++ b/scripts/shop.gd @@ -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 diff --git a/scripts/shop.gd.uid b/scripts/shop.gd.uid new file mode 100644 index 0000000..84a8bc5 --- /dev/null +++ b/scripts/shop.gd.uid @@ -0,0 +1 @@ +uid://sg33v3inblwx diff --git a/scripts/station.gd b/scripts/station.gd new file mode 100644 index 0000000..02f380d --- /dev/null +++ b/scripts/station.gd @@ -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))) diff --git a/scripts/station.gd.uid b/scripts/station.gd.uid new file mode 100644 index 0000000..4a42602 --- /dev/null +++ b/scripts/station.gd.uid @@ -0,0 +1 @@ +uid://dcpyfw5eg0apq diff --git a/scripts/synth.gd b/scripts/synth.gd new file mode 100644 index 0000000..1a7a65f --- /dev/null +++ b/scripts/synth.gd @@ -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 diff --git a/scripts/synth.gd.uid b/scripts/synth.gd.uid new file mode 100644 index 0000000..a1d478b --- /dev/null +++ b/scripts/synth.gd.uid @@ -0,0 +1 @@ +uid://dkcl8lgogwxb2 diff --git a/scripts/track.gd b/scripts/track.gd new file mode 100644 index 0000000..b62e33f --- /dev/null +++ b/scripts/track.gd @@ -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) diff --git a/scripts/track.gd.uid b/scripts/track.gd.uid new file mode 100644 index 0000000..1496cc2 --- /dev/null +++ b/scripts/track.gd.uid @@ -0,0 +1 @@ +uid://c4v1pl34pv0wk diff --git a/scripts/train.gd b/scripts/train.gd new file mode 100644 index 0000000..95c03e8 --- /dev/null +++ b/scripts/train.gd @@ -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] diff --git a/scripts/train.gd.uid b/scripts/train.gd.uid new file mode 100644 index 0000000..062f484 --- /dev/null +++ b/scripts/train.gd.uid @@ -0,0 +1 @@ +uid://bs0xp741xc3pg diff --git a/scripts/world.gd b/scripts/world.gd new file mode 100644 index 0000000..b6d2bc5 --- /dev/null +++ b/scripts/world.gd @@ -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} diff --git a/scripts/world.gd.uid b/scripts/world.gd.uid new file mode 100644 index 0000000..7c89d22 --- /dev/null +++ b/scripts/world.gd.uid @@ -0,0 +1 @@ +uid://br0aw8liqwhyj diff --git a/shaders/flat.gdshader b/shaders/flat.gdshader new file mode 100644 index 0000000..e48e95d --- /dev/null +++ b/shaders/flat.gdshader @@ -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))); +} diff --git a/shaders/flat.gdshader.uid b/shaders/flat.gdshader.uid new file mode 100644 index 0000000..327ce6c --- /dev/null +++ b/shaders/flat.gdshader.uid @@ -0,0 +1 @@ +uid://c0oc7odfhv66v diff --git a/tests/audio_test.gd b/tests/audio_test.gd new file mode 100644 index 0000000..c2d8971 --- /dev/null +++ b/tests/audio_test.gd @@ -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)} diff --git a/tests/direction_test.gd b/tests/direction_test.gd new file mode 100644 index 0000000..5b1f87e --- /dev/null +++ b/tests/direction_test.gd @@ -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 diff --git a/tests/direction_test.gd.uid b/tests/direction_test.gd.uid new file mode 100644 index 0000000..bc5ea5e --- /dev/null +++ b/tests/direction_test.gd.uid @@ -0,0 +1 @@ +uid://bnbo2gtpe5mtw diff --git a/tests/input_test.gd b/tests/input_test.gd new file mode 100644 index 0000000..07eca41 --- /dev/null +++ b/tests/input_test.gd @@ -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 diff --git a/tests/input_test.gd.uid b/tests/input_test.gd.uid new file mode 100644 index 0000000..3c79a4a --- /dev/null +++ b/tests/input_test.gd.uid @@ -0,0 +1 @@ +uid://bip2svfktigjy diff --git a/tests/net_test.gd b/tests/net_test.gd new file mode 100644 index 0000000..7234680 --- /dev/null +++ b/tests/net_test.gd @@ -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 diff --git a/tests/net_test.gd.uid b/tests/net_test.gd.uid new file mode 100644 index 0000000..e09001d --- /dev/null +++ b/tests/net_test.gd.uid @@ -0,0 +1 @@ +uid://dodlor1js5f2c diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..8659cb9 --- /dev/null +++ b/tests/run.sh @@ -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 )) diff --git a/tests/solo_test.gd b/tests/solo_test.gd new file mode 100644 index 0000000..b01a34b --- /dev/null +++ b/tests/solo_test.gd @@ -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 diff --git a/tests/solo_test.gd.uid b/tests/solo_test.gd.uid new file mode 100644 index 0000000..3ccc2e4 --- /dev/null +++ b/tests/solo_test.gd.uid @@ -0,0 +1 @@ +uid://bvosfi7jnndvs