first commit
This commit is contained in:
157
scripts/audio.gd
Normal file
157
scripts/audio.gd
Normal file
@@ -0,0 +1,157 @@
|
||||
extends Node
|
||||
## Spielt Musik und Klänge. Alles wird prozedural erzeugt - in einem
|
||||
## Thread, damit der Start nicht hakt. Bis dahin bleibt es still.
|
||||
|
||||
const Sfx = preload("res://scripts/sfx.gd")
|
||||
const Synth = preload("res://scripts/synth.gd")
|
||||
|
||||
const LOOPS := ["rumble", "squeal"] ## Klänge, die als Schleife laufen
|
||||
const CLACK_DIST := 8.5 ## Meter zwischen zwei Radschlägen
|
||||
const STEP_DIST := 1.75 ## Meter zwischen zwei Schritten
|
||||
const MUSIC_DB := -15.0
|
||||
const POOL := 10
|
||||
const POOL_3D := 6
|
||||
|
||||
signal ready_for_play()
|
||||
|
||||
var sounds: Dictionary = {}
|
||||
var music_on := true
|
||||
var loaded := false
|
||||
var clacks := 0 ## Zähler, damit sich das testen lässt
|
||||
var last_played := "" ## zuletzt gestarteter Klang
|
||||
|
||||
var _music: AudioStreamPlayer
|
||||
var _rumble: AudioStreamPlayer
|
||||
var _squeal: AudioStreamPlayer
|
||||
var _pool: Array[AudioStreamPlayer] = []
|
||||
var _pool3d: Array[AudioStreamPlayer3D] = []
|
||||
var _next := 0
|
||||
var _next3d := 0
|
||||
var _clack_acc := 0.0
|
||||
var _clack_flip := false
|
||||
var _step_acc := 0.0
|
||||
var _thread: Thread
|
||||
var _t0 := 0
|
||||
|
||||
func _ready() -> void:
|
||||
_music = _make_player(MUSIC_DB)
|
||||
_rumble = _make_player(-80.0)
|
||||
_squeal = _make_player(-80.0)
|
||||
for i in POOL:
|
||||
_pool.append(_make_player(0.0))
|
||||
for i in POOL_3D:
|
||||
var p3 := AudioStreamPlayer3D.new()
|
||||
p3.unit_size = 14.0
|
||||
p3.max_distance = 160.0
|
||||
add_child(p3)
|
||||
_pool3d.append(p3)
|
||||
|
||||
_t0 = Time.get_ticks_msec()
|
||||
_thread = Thread.new()
|
||||
_thread.start(_build_async)
|
||||
|
||||
func _make_player(db: float) -> AudioStreamPlayer:
|
||||
var p := AudioStreamPlayer.new()
|
||||
p.volume_db = db
|
||||
add_child(p)
|
||||
return p
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if _thread != null and _thread.is_started():
|
||||
_thread.wait_to_finish()
|
||||
_thread = null
|
||||
|
||||
## Läuft im Thread: nur Rechnen, keine Godot-Knoten anfassen.
|
||||
func _build_async() -> void:
|
||||
var pcm: Dictionary = Sfx.build_all()
|
||||
pcm["music"] = Sfx.build_music()
|
||||
call_deferred("_finish_build", pcm)
|
||||
|
||||
func _finish_build(pcm: Dictionary) -> void:
|
||||
if _thread != null:
|
||||
_thread.wait_to_finish()
|
||||
_thread = null
|
||||
for name in pcm.keys():
|
||||
var loop: bool = name == "music" or LOOPS.has(name)
|
||||
sounds[name] = Synth.to_stream(pcm[name], loop)
|
||||
_music.stream = sounds["music"]
|
||||
_music.volume_db = MUSIC_DB if music_on else -80.0
|
||||
_music.play()
|
||||
_rumble.stream = sounds["rumble"]
|
||||
_rumble.play()
|
||||
_squeal.stream = sounds["squeal"]
|
||||
_squeal.play()
|
||||
loaded = true
|
||||
print("[audio] %d Klänge in %d ms erzeugt" % [sounds.size(), Time.get_ticks_msec() - _t0])
|
||||
ready_for_play.emit()
|
||||
|
||||
func toggle_music() -> bool:
|
||||
music_on = not music_on
|
||||
_music.volume_db = MUSIC_DB if music_on else -80.0
|
||||
return music_on
|
||||
|
||||
# --- Abspielen ------------------------------------------------------------
|
||||
|
||||
func play(name: String, db := 0.0, pitch := 1.0) -> void:
|
||||
if not loaded or not sounds.has(name):
|
||||
return
|
||||
last_played = name
|
||||
var p := _pool[_next]
|
||||
_next = (_next + 1) % POOL
|
||||
p.stream = sounds[name]
|
||||
p.volume_db = db
|
||||
p.pitch_scale = pitch
|
||||
p.play()
|
||||
|
||||
func play_at(name: String, pos: Vector3, db := 0.0, pitch := 1.0) -> void:
|
||||
if not loaded or not sounds.has(name):
|
||||
return
|
||||
last_played = name
|
||||
var p := _pool3d[_next3d]
|
||||
_next3d = (_next3d + 1) % POOL_3D
|
||||
p.stream = sounds[name]
|
||||
p.global_position = pos
|
||||
p.volume_db = db
|
||||
p.pitch_scale = pitch
|
||||
p.play()
|
||||
|
||||
# --- Laufende Geräusche ---------------------------------------------------
|
||||
|
||||
## speed in m/s, top als Bezugsgröße für Lautstärke und Tonhöhe.
|
||||
func update_train(speed: float, top: float, braking: bool, delta: float) -> void:
|
||||
if not loaded:
|
||||
return
|
||||
var f: float = clampf(speed / maxf(top, 1.0), 0.0, 1.4)
|
||||
if speed < 0.15:
|
||||
_rumble.volume_db = -80.0
|
||||
else:
|
||||
_rumble.volume_db = linear_to_db(clampf(0.10 + f * 0.65, 0.0, 1.0)) - 6.0
|
||||
_rumble.pitch_scale = 0.75 + f * 0.55
|
||||
|
||||
var squealing: bool = braking and speed > 1.2
|
||||
var target: float = linear_to_db(clampf(0.15 + f * 0.5, 0.0, 1.0)) - 8.0 if squealing else -80.0
|
||||
_squeal.volume_db = move_toward(_squeal.volume_db, target, 260.0 * delta)
|
||||
if squealing:
|
||||
_squeal.pitch_scale = 0.8 + f * 0.5
|
||||
|
||||
if speed > 0.4:
|
||||
_clack_acc += speed * delta
|
||||
while _clack_acc >= CLACK_DIST:
|
||||
_clack_acc -= CLACK_DIST
|
||||
_clack_flip = not _clack_flip
|
||||
clacks += 1
|
||||
play("clack2" if _clack_flip else "clack",
|
||||
linear_to_db(clampf(0.25 + f * 0.75, 0.0, 1.0)) - 8.0,
|
||||
0.85 + f * 0.35)
|
||||
else:
|
||||
_clack_acc = 0.0
|
||||
|
||||
## Schritte, sobald sich der Spieler im Zug bewegt.
|
||||
func update_steps(moving_speed: float, delta: float) -> void:
|
||||
if not loaded or moving_speed < 0.4:
|
||||
_step_acc = STEP_DIST * 0.55
|
||||
return
|
||||
_step_acc += moving_speed * delta
|
||||
if _step_acc >= STEP_DIST:
|
||||
_step_acc = 0.0
|
||||
play("step", -19.0, randf_range(0.9, 1.12))
|
||||
1
scripts/audio.gd.uid
Normal file
1
scripts/audio.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://u8s7xv36vrlk
|
||||
76
scripts/avatar.gd
Normal file
76
scripts/avatar.gd
Normal file
@@ -0,0 +1,76 @@
|
||||
extends Node3D
|
||||
## Sichtbare Figur der Mitspieler.
|
||||
|
||||
const Mat = preload("res://scripts/mat.gd")
|
||||
const Deco = preload("res://scripts/deco.gd")
|
||||
|
||||
const COLORS := ["d8a33c", "3c9dd8", "6fbf5a", "cf5f8f", "9b6fd8", "d87a3c"]
|
||||
|
||||
var _label: Label3D
|
||||
var _tray: MeshInstance3D
|
||||
|
||||
func build(peer_id: int, label_text: String) -> void:
|
||||
var col := Color(COLORS[abs(peer_id) % COLORS.size()])
|
||||
var dark := Color("2b3138")
|
||||
|
||||
var legs := MeshInstance3D.new()
|
||||
legs.mesh = Deco.box(Vector3(0.42, 0.82, 0.3))
|
||||
legs.material_override = Mat.flat(dark)
|
||||
legs.position = Vector3(0, 0.41, 0)
|
||||
add_child(legs)
|
||||
|
||||
var torso := MeshInstance3D.new()
|
||||
torso.mesh = Deco.box(Vector3(0.52, 0.64, 0.34))
|
||||
torso.material_override = Mat.flat(col)
|
||||
torso.position = Vector3(0, 1.14, 0)
|
||||
add_child(torso)
|
||||
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
var arm := MeshInstance3D.new()
|
||||
arm.mesh = Deco.box(Vector3(0.13, 0.6, 0.19))
|
||||
arm.material_override = Mat.flat(col.darkened(0.12))
|
||||
arm.position = Vector3(sx * 0.33, 1.12, 0)
|
||||
add_child(arm)
|
||||
|
||||
var head := MeshInstance3D.new()
|
||||
head.mesh = Deco.blob(0.18)
|
||||
head.material_override = Mat.flat(Color("e8c39e"))
|
||||
head.position = Vector3(0, 1.6, 0)
|
||||
add_child(head)
|
||||
|
||||
var cap := MeshInstance3D.new()
|
||||
cap.mesh = Deco.box(Vector3(0.38, 0.14, 0.38))
|
||||
cap.material_override = Mat.flat(dark)
|
||||
cap.position = Vector3(0, 1.75, 0)
|
||||
add_child(cap)
|
||||
|
||||
var peak := MeshInstance3D.new()
|
||||
peak.mesh = Deco.box(Vector3(0.38, 0.05, 0.16))
|
||||
peak.material_override = Mat.flat(dark)
|
||||
peak.position = Vector3(0, 1.7, 0.24)
|
||||
add_child(peak)
|
||||
|
||||
_tray = MeshInstance3D.new()
|
||||
_tray.mesh = Deco.box(Vector3(0.26, 0.3, 0.26))
|
||||
_tray.material_override = Mat.flat(Color("8fd8ff"))
|
||||
_tray.position = Vector3(0.2, 1.25, 0.3)
|
||||
_tray.visible = false
|
||||
add_child(_tray)
|
||||
|
||||
_label = Label3D.new()
|
||||
_label.text = label_text
|
||||
_label.font_size = 44
|
||||
_label.pixel_size = 0.006
|
||||
_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
_label.no_depth_test = true
|
||||
_label.outline_size = 12
|
||||
_label.outline_modulate = Color(0, 0, 0, 0.7)
|
||||
_label.modulate = col
|
||||
_label.position = Vector3(0, 2.05, 0)
|
||||
add_child(_label)
|
||||
|
||||
func set_carrying(n: int) -> void:
|
||||
_tray.visible = n > 0
|
||||
|
||||
func set_label(text: String) -> void:
|
||||
_label.text = text
|
||||
1
scripts/avatar.gd.uid
Normal file
1
scripts/avatar.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://l8juemk6wh5s
|
||||
61
scripts/camera_rig.gd
Normal file
61
scripts/camera_rig.gd
Normal file
@@ -0,0 +1,61 @@
|
||||
extends Node3D
|
||||
## Aussenkamera: folgt dem Zug, drehbar und zoombar.
|
||||
|
||||
const MIN_DIST := 14.0
|
||||
const MAX_DIST := 140.0
|
||||
|
||||
var target: Node3D
|
||||
var yaw := 0.0
|
||||
var pitch := deg_to_rad(-34.0)
|
||||
var distance := 46.0
|
||||
|
||||
var _cam: Camera3D
|
||||
var _focus := Vector3.ZERO
|
||||
|
||||
func _ready() -> void:
|
||||
_cam = Camera3D.new()
|
||||
_cam.fov = 50.0
|
||||
_cam.far = 900.0
|
||||
add_child(_cam)
|
||||
|
||||
func activate() -> void:
|
||||
_cam.current = true
|
||||
|
||||
func is_active() -> bool:
|
||||
return _cam.current
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not is_active():
|
||||
return
|
||||
if event is InputEventMouseButton and event.pressed:
|
||||
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
distance = clampf(distance * 0.88, MIN_DIST, MAX_DIST)
|
||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
distance = clampf(distance * 1.14, MIN_DIST, MAX_DIST)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not is_active():
|
||||
if target:
|
||||
_focus = target.global_position
|
||||
return
|
||||
var turn := 0.0
|
||||
if Input.is_key_pressed(KEY_A) or Input.is_key_pressed(KEY_LEFT):
|
||||
turn += 1.0
|
||||
if Input.is_key_pressed(KEY_D) or Input.is_key_pressed(KEY_RIGHT):
|
||||
turn -= 1.0
|
||||
yaw += turn * 1.5 * delta
|
||||
if Input.is_key_pressed(KEY_R):
|
||||
pitch = clampf(pitch + 0.9 * delta, deg_to_rad(-82.0), deg_to_rad(-8.0))
|
||||
if Input.is_key_pressed(KEY_F):
|
||||
pitch = clampf(pitch - 0.9 * delta, deg_to_rad(-82.0), deg_to_rad(-8.0))
|
||||
|
||||
if target:
|
||||
_focus = _focus.lerp(target.global_position, clampf(delta * 5.0, 0.0, 1.0))
|
||||
position = _focus
|
||||
var dir := Vector3(0, 0, 1).rotated(Vector3.RIGHT, pitch).rotated(Vector3.UP, yaw)
|
||||
_cam.position = dir * distance
|
||||
_cam.look_at(global_position + Vector3(0, 2.5, 0), Vector3.UP)
|
||||
|
||||
func snap_to(p: Vector3) -> void:
|
||||
_focus = p
|
||||
position = p
|
||||
1
scripts/camera_rig.gd.uid
Normal file
1
scripts/camera_rig.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://uk8lvq4xilb8
|
||||
364
scripts/car.gd
Normal file
364
scripts/car.gd
Normal file
@@ -0,0 +1,364 @@
|
||||
extends Node3D
|
||||
## Ein Fahrzeug des Zugverbands: Aussenhuelle, begehbarer Innenraum,
|
||||
## Sitzplätze und Interaktionspunkte. Alles in lokalen Koordinaten,
|
||||
## +Z zeigt in Fahrtrichtung.
|
||||
|
||||
const Mat = preload("res://scripts/mat.gd")
|
||||
const Deco = preload("res://scripts/deco.gd")
|
||||
const Defs = preload("res://scripts/car_defs.gd")
|
||||
|
||||
const OUTER_HALF := 1.5
|
||||
const INNER_HALF := 1.36
|
||||
const FLOOR_Y := 1.10
|
||||
const CEIL_Y := 3.50
|
||||
const WALL_T := 0.12
|
||||
const GANGWAY_HALF := 0.48
|
||||
|
||||
const UNDER := Color("3a3a40")
|
||||
const FLOOR_COL := Color("8a6b4a")
|
||||
const CEIL_COL := Color("efe9dc")
|
||||
const SEAT_COL := Color("356b8f")
|
||||
const SEAT_COL2 := Color("8f3550")
|
||||
const METAL := Color("55575c")
|
||||
const CONSOLE := Color("2f3338")
|
||||
|
||||
var id := ""
|
||||
var kind := 0
|
||||
var length := 12.0
|
||||
var index := 0 ## Position im Verband, 0 = Lok
|
||||
var center_s := 0.0 ## Laengsposition der Waggonmitte im Zug
|
||||
|
||||
var inner_z0 := 0.0 ## begehbarer Bereich, lokal
|
||||
var inner_z1 := 0.0
|
||||
var blockers: Array[AABB] = [] ## Hindernisse im Innenraum
|
||||
var seats: Array = [] ## [{ "pos": Vector3, "yaw": float, "occupant": null }]
|
||||
var hotspots: Array = [] ## [{ "pos": Vector3, "kind": String, "seat": int }]
|
||||
var capacity := 0
|
||||
|
||||
var _cargo_stack: Array[MeshInstance3D] = []
|
||||
|
||||
func build(car_id: String) -> void:
|
||||
id = car_id
|
||||
kind = Defs.kind_of(car_id)
|
||||
length = Defs.car_length(car_id)
|
||||
var d: Dictionary = Defs.LOCOS[car_id] if Defs.LOCOS.has(car_id) else Defs.CARS[car_id]
|
||||
var body := Color(String(d["color"]))
|
||||
var trim := Color(String(d["trim"]))
|
||||
capacity = int(d.get("capacity", 0))
|
||||
|
||||
inner_z0 = -length * 0.5 + 0.16
|
||||
inner_z1 = length * 0.5 - 0.16
|
||||
if kind == Defs.Kind.LOCO:
|
||||
inner_z1 = length * 0.5 - 4.6 # vorne sitzt der Maschinenraum
|
||||
|
||||
_chassis(trim)
|
||||
_floor_and_ceiling()
|
||||
if kind == Defs.Kind.LOCO:
|
||||
_loco_shell(body, trim)
|
||||
elif kind == Defs.Kind.FREIGHT:
|
||||
_freight_shell(body, trim)
|
||||
else:
|
||||
_coach_shell(body, trim)
|
||||
|
||||
_lights()
|
||||
match kind:
|
||||
Defs.Kind.LOCO: _fit_loco()
|
||||
Defs.Kind.PASSENGER: _fit_passenger(int(d["seats"]))
|
||||
Defs.Kind.DINING: _fit_dining(int(d["seats"]))
|
||||
Defs.Kind.FREIGHT: _fit_freight()
|
||||
|
||||
## Deckenlampen - ohne sie waere vor allem der Güterwagen stockdunkel.
|
||||
func _lights() -> void:
|
||||
var n := maxi(1, int((inner_z1 - inner_z0) / 5.0))
|
||||
for i in n:
|
||||
var t := (i + 0.5) / float(n)
|
||||
var z := lerpf(inner_z0, inner_z1, t)
|
||||
var l := OmniLight3D.new()
|
||||
l.position = Vector3(0, CEIL_Y - 0.42, z)
|
||||
l.light_color = Color("ffe8c8")
|
||||
l.light_energy = 2.6
|
||||
l.omni_range = 8.5
|
||||
l.omni_attenuation = 1.3
|
||||
l.shadow_enabled = false
|
||||
add_child(l)
|
||||
_box(Vector3(0, CEIL_Y - 0.16, z), Vector3(0.55, 0.09, 0.55), Color("fff5e0"))
|
||||
|
||||
# --- Bausteine ------------------------------------------------------------
|
||||
|
||||
func _box(center: Vector3, size: Vector3, color: Color, block := false) -> MeshInstance3D:
|
||||
var m := MeshInstance3D.new()
|
||||
m.mesh = Deco.box(size)
|
||||
m.material_override = Mat.flat(color)
|
||||
m.position = center
|
||||
add_child(m)
|
||||
if block:
|
||||
blockers.append(AABB(center - size * 0.5, size))
|
||||
return m
|
||||
|
||||
func _chassis(trim: Color) -> void:
|
||||
_box(Vector3(0, 0.72, 0), Vector3(2.86, 0.42, length - 0.5), UNDER)
|
||||
for sz: float in [-1.0, 1.0]:
|
||||
_bogie(sz * length * 0.31)
|
||||
_box(Vector3(0, 0.72, sz * (length * 0.5 - 0.1)), Vector3(0.34, 0.26, 0.5), METAL)
|
||||
# Puffer
|
||||
for sz: float in [-1.0, 1.0]:
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
_box(Vector3(sx * 0.75, 0.8, sz * (length * 0.5 + 0.06)),
|
||||
Vector3(0.28, 0.28, 0.16), trim)
|
||||
|
||||
func _bogie(z: float) -> void:
|
||||
_box(Vector3(0, 0.55, z), Vector3(1.9, 0.34, 2.5), UNDER)
|
||||
for az: float in [-0.9, 0.9]:
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
var w := MeshInstance3D.new()
|
||||
w.mesh = Deco.cyl(0.42, 0.18, 10)
|
||||
w.material_override = Mat.flat(METAL)
|
||||
w.position = Vector3(sx * 0.78, 0.42, z + az)
|
||||
w.rotate_z(PI * 0.5)
|
||||
add_child(w)
|
||||
|
||||
func _floor_and_ceiling() -> void:
|
||||
var iz0 := -length * 0.5 + 0.06
|
||||
var iz1 := length * 0.5 - 0.06
|
||||
var l := iz1 - iz0
|
||||
_box(Vector3(0, FLOOR_Y - 0.06, (iz0 + iz1) * 0.5), Vector3(2.78, 0.12, l), FLOOR_COL)
|
||||
if kind != Defs.Kind.LOCO:
|
||||
_box(Vector3(0, CEIL_Y - 0.05, 0), Vector3(2.78, 0.1, l), CEIL_COL)
|
||||
else:
|
||||
_box(Vector3(0, CEIL_Y - 0.05, (inner_z0 + inner_z1) * 0.5),
|
||||
Vector3(2.78, 0.1, inner_z1 - inner_z0), CEIL_COL)
|
||||
|
||||
## Stirnwand mit Durchgang. open = Tuer vorhanden.
|
||||
func _end_wall(z: float, color: Color, open: bool) -> void:
|
||||
var t := 0.14
|
||||
if not open:
|
||||
_box(Vector3(0, (0.94 + CEIL_Y) * 0.5, z), Vector3(2.9, CEIL_Y - 0.94, t), color, true)
|
||||
return
|
||||
var door_h := FLOOR_Y + 2.05
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
var w := (1.45 - 0.5)
|
||||
_box(Vector3(sx * (0.5 + w * 0.5), (0.94 + CEIL_Y) * 0.5, z),
|
||||
Vector3(w, CEIL_Y - 0.94, t), color, true)
|
||||
_box(Vector3(0, (door_h + CEIL_Y) * 0.5, z), Vector3(1.0, CEIL_Y - door_h, t), color)
|
||||
# Faltenbalg nach hinten
|
||||
_box(Vector3(0, FLOOR_Y - 0.05, z + sign(z) * 0.2), Vector3(1.1, 0.1, 0.4), UNDER)
|
||||
|
||||
func _roof(color: Color) -> void:
|
||||
_box(Vector3(0, CEIL_Y + 0.12, 0), Vector3(3.02, 0.16, length), color)
|
||||
_box(Vector3(0, CEIL_Y + 0.24, 0), Vector3(2.3, 0.1, length - 0.6), color.darkened(0.12))
|
||||
|
||||
## Seitenwand mit Fensterband.
|
||||
func _windowed_side(color: Color, trim: Color, z0: float, z1: float) -> void:
|
||||
var win_b := FLOOR_Y + 0.78
|
||||
var win_t := FLOOR_Y + 1.86
|
||||
var seg := z1 - z0
|
||||
var mid := (z0 + z1) * 0.5
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
var x := sx * (OUTER_HALF - WALL_T * 0.5)
|
||||
_box(Vector3(x, (0.94 + win_b) * 0.5, mid), Vector3(WALL_T, win_b - 0.94, seg), color, true)
|
||||
_box(Vector3(x, (win_t + CEIL_Y) * 0.5, mid), Vector3(WALL_T, CEIL_Y - win_t, seg), color)
|
||||
_box(Vector3(x, win_b + 0.03, mid), Vector3(WALL_T + 0.08, 0.08, seg), trim)
|
||||
var n := maxi(2, int(seg / 2.3))
|
||||
var cell := seg / float(n)
|
||||
for i in n + 1:
|
||||
var pz := z0 + i * cell
|
||||
var w := 0.52
|
||||
if i == 0:
|
||||
pz += 0.3
|
||||
w = 0.6
|
||||
elif i == n:
|
||||
pz -= 0.3
|
||||
w = 0.6
|
||||
_box(Vector3(x, (win_b + win_t) * 0.5, pz), Vector3(WALL_T, win_t - win_b, w), color)
|
||||
# Unsichtbare Wand ueber die volle Hoehe haelt Spieler drinnen
|
||||
blockers.append(AABB(
|
||||
Vector3(x - 0.1 if sx > 0 else x - 0.02, FLOOR_Y, z0),
|
||||
Vector3(0.12, 2.2, seg)))
|
||||
|
||||
func _solid_side(color: Color, trim: Color, z0: float, z1: float) -> void:
|
||||
var seg := z1 - z0
|
||||
var mid := (z0 + z1) * 0.5
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
var x := sx * (OUTER_HALF - WALL_T * 0.5)
|
||||
_box(Vector3(x, (0.94 + CEIL_Y) * 0.5, mid), Vector3(WALL_T, CEIL_Y - 0.94, seg), color, true)
|
||||
# Schiebetuer als Farbakzent
|
||||
_box(Vector3(x + sx * 0.02, FLOOR_Y + 1.0, mid), Vector3(0.06, 1.9, 2.4), trim)
|
||||
|
||||
func _coach_shell(body: Color, trim: Color) -> void:
|
||||
_windowed_side(body, trim, -length * 0.5 + 0.06, length * 0.5 - 0.06)
|
||||
_end_wall(length * 0.5 - 0.07, body.darkened(0.1), true)
|
||||
_end_wall(-length * 0.5 + 0.07, body.darkened(0.1), true)
|
||||
_roof(trim)
|
||||
|
||||
func _freight_shell(body: Color, trim: Color) -> void:
|
||||
_solid_side(body, trim, -length * 0.5 + 0.06, length * 0.5 - 0.06)
|
||||
# Helle Innenverkleidung, damit sich die Ladung abhebt
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
_box(Vector3(sx * (OUTER_HALF - WALL_T - 0.03), (FLOOR_Y + CEIL_Y) * 0.5, 0),
|
||||
Vector3(0.05, CEIL_Y - FLOOR_Y, length - 0.4), Color("d9d2c4"))
|
||||
_end_wall(length * 0.5 - 0.07, body.darkened(0.1), true)
|
||||
_end_wall(-length * 0.5 + 0.07, body.darkened(0.1), true)
|
||||
_roof(trim)
|
||||
|
||||
func _loco_shell(body: Color, trim: Color) -> void:
|
||||
var cab0 := inner_z0
|
||||
var cab1 := inner_z1
|
||||
# Fuehrerhaus
|
||||
_windowed_side(body, trim, cab0 + 0.06, cab1)
|
||||
_end_wall(cab0 + 0.07, body.darkened(0.1), true)
|
||||
# Frontscheibe: Wand mit grossem Ausschnitt
|
||||
_box(Vector3(0, CEIL_Y - 0.25, cab1), Vector3(2.9, 0.5, 0.14), body, true)
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
_box(Vector3(sx * 1.2, FLOOR_Y + 1.35, cab1), Vector3(0.5, 2.2, 0.14), body, true)
|
||||
_box(Vector3(0, FLOOR_Y + 0.42, cab1), Vector3(2.9, 0.85, 0.14), body, true)
|
||||
_box(Vector3(0, CEIL_Y + 0.12, (cab0 + cab1) * 0.5),
|
||||
Vector3(3.02, 0.16, cab1 - cab0 + 0.2), trim)
|
||||
# Maschinenraum vorne
|
||||
var nz0 := cab1
|
||||
var nz1 := length * 0.5
|
||||
var nl := nz1 - nz0
|
||||
_box(Vector3(0, FLOOR_Y + 0.42, nz1 + 0.08), Vector3(2.8, 0.5, 0.3), Color("d8b24a"))
|
||||
_box(Vector3(0, FLOOR_Y - 0.05, (nz0 + nz1) * 0.5), Vector3(2.5, 0.3, nl), UNDER)
|
||||
if id == "steam":
|
||||
# Runder Kessel statt Kasten
|
||||
var boiler := MeshInstance3D.new()
|
||||
boiler.mesh = Deco.cyl(0.92, nl - 0.2, 12)
|
||||
boiler.material_override = Mat.flat(body)
|
||||
boiler.position = Vector3(0, FLOOR_Y + 0.55, (nz0 + nz1) * 0.5)
|
||||
boiler.rotate_x(PI * 0.5)
|
||||
add_child(boiler)
|
||||
var front := MeshInstance3D.new()
|
||||
front.mesh = Deco.cyl(0.99, 0.3, 12)
|
||||
front.material_override = Mat.flat(trim)
|
||||
front.position = Vector3(0, FLOOR_Y + 0.55, nz1 - 0.15)
|
||||
front.rotate_x(PI * 0.5)
|
||||
add_child(front)
|
||||
var stack := MeshInstance3D.new()
|
||||
stack.mesh = Deco.cyl(0.27, 1.0, 8)
|
||||
stack.material_override = Mat.flat(UNDER)
|
||||
stack.position = Vector3(0, FLOOR_Y + 1.95, nz1 - 0.9)
|
||||
add_child(stack)
|
||||
_box(Vector3(0, FLOOR_Y + 1.62, nz1 - 2.6), Vector3(0.66, 0.6, 0.66), trim)
|
||||
else:
|
||||
_box(Vector3(0, FLOOR_Y + 0.55, (nz0 + nz1) * 0.5), Vector3(2.6, 1.3, nl), body)
|
||||
_box(Vector3(0, FLOOR_Y + 1.25, (nz0 + nz1) * 0.5), Vector3(2.2, 0.18, nl), trim)
|
||||
_box(Vector3(0, FLOOR_Y + 1.5, (nz0 + nz1) * 0.5), Vector3(1.4, 0.3, nl - 1.2), UNDER)
|
||||
|
||||
# --- Innenausbau ----------------------------------------------------------
|
||||
|
||||
func _seat(pos: Vector3, yaw: float, col: Color) -> void:
|
||||
var s := Node3D.new()
|
||||
s.position = pos
|
||||
s.rotation.y = yaw
|
||||
add_child(s)
|
||||
var base := MeshInstance3D.new()
|
||||
base.mesh = Deco.box(Vector3(0.92, 0.4, 0.56))
|
||||
base.material_override = Mat.flat(col)
|
||||
base.position = Vector3(0, 0.2, 0)
|
||||
s.add_child(base)
|
||||
var back := MeshInstance3D.new()
|
||||
back.mesh = Deco.box(Vector3(0.92, 0.62, 0.14))
|
||||
back.material_override = Mat.flat(col.darkened(0.12))
|
||||
back.position = Vector3(0, 0.71, -0.21)
|
||||
s.add_child(back)
|
||||
blockers.append(AABB(pos + Vector3(-0.46, 0, -0.32), Vector3(0.92, 0.72, 0.64)))
|
||||
|
||||
func _fit_passenger(seat_count: int) -> void:
|
||||
var rows := int(ceil(seat_count / 2.0))
|
||||
var span := inner_z1 - inner_z0 - 1.6
|
||||
var gap := span / float(maxi(1, rows - 1)) if rows > 1 else 0.0
|
||||
var z0 := inner_z0 + 0.8
|
||||
var placed := 0
|
||||
for r in rows:
|
||||
var z := z0 + gap * r
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
if placed >= seat_count:
|
||||
break
|
||||
var pos := Vector3(sx * 0.92, FLOOR_Y, z)
|
||||
_seat(pos, 0.0, SEAT_COL if sx < 0 else SEAT_COL2)
|
||||
seats.append({"pos": pos, "yaw": 0.0, "occupant": null})
|
||||
hotspots.append({"pos": pos + Vector3(0, 0.9, 0), "kind": "seat", "seat": seats.size() - 1})
|
||||
placed += 1
|
||||
# Tisch am Fenster
|
||||
if r % 2 == 1:
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
_box(Vector3(sx * 1.0, FLOOR_Y + 0.62, z + 0.55), Vector3(0.7, 0.06, 0.5), Color("caa87a"), true)
|
||||
|
||||
func _fit_dining(seat_count: int) -> void:
|
||||
var z0 := inner_z0 + 0.6
|
||||
var z1 := inner_z1 - 0.6
|
||||
# Theke links
|
||||
_box(Vector3(-0.85, FLOOR_Y + 0.52, (z0 + z1) * 0.5 - 1.0),
|
||||
Vector3(0.9, 1.04, (z1 - z0) * 0.55), Color("7a4f2c"), true)
|
||||
_box(Vector3(-0.85, FLOOR_Y + 1.06, (z0 + z1) * 0.5 - 1.0),
|
||||
Vector3(1.0, 0.06, (z1 - z0) * 0.55), Color("caa87a"))
|
||||
# Zapfstelle
|
||||
var tap_pos := Vector3(-0.45, FLOOR_Y + 1.1, (z0 + z1) * 0.5 - 1.0)
|
||||
_box(tap_pos, Vector3(0.3, 0.5, 0.3), Color("c8b56a"))
|
||||
hotspots.append({"pos": tap_pos + Vector3(0.35, 0.1, 0), "kind": "tap", "seat": -1})
|
||||
# Regal
|
||||
_box(Vector3(-1.15, FLOOR_Y + 1.7, (z0 + z1) * 0.5 - 1.0),
|
||||
Vector3(0.35, 0.7, (z1 - z0) * 0.4), Color("8f6a45"))
|
||||
# Sitzecke rechts
|
||||
var placed := 0
|
||||
for i in int(ceil(seat_count / 2.0)):
|
||||
var z := z1 - 1.2 - i * 2.2
|
||||
for sz: float in [-1.0, 1.0]:
|
||||
if placed >= seat_count:
|
||||
break
|
||||
var pos := Vector3(0.95, FLOOR_Y, z + sz * 0.65)
|
||||
_seat(pos, 0.0 if sz < 0 else PI, Color("6b8f55"))
|
||||
seats.append({"pos": pos, "yaw": 0.0 if sz < 0 else PI, "occupant": null})
|
||||
hotspots.append({"pos": pos + Vector3(0, 0.9, 0), "kind": "seat", "seat": seats.size() - 1})
|
||||
placed += 1
|
||||
_box(Vector3(0.95, FLOOR_Y + 0.62, z), Vector3(0.8, 0.06, 0.7), Color("caa87a"), true)
|
||||
|
||||
func _fit_freight() -> void:
|
||||
var console_pos := Vector3(1.05, FLOOR_Y + 1.0, inner_z1 - 1.0)
|
||||
_box(console_pos, Vector3(0.4, 0.8, 0.7), CONSOLE, true)
|
||||
_box(console_pos + Vector3(-0.22, 0.25, 0), Vector3(0.06, 0.3, 0.5), Color("6ad0a0"))
|
||||
hotspots.append({"pos": console_pos + Vector3(-0.4, 0.1, 0), "kind": "load", "seat": -1})
|
||||
# Ladeplaetze: Stapel werden je nach Füllstand sichtbar
|
||||
var slots := capacity
|
||||
var per_row := 2
|
||||
var rows := int(ceil(float(slots) / per_row))
|
||||
var z0 := inner_z0 + 1.0
|
||||
var span := (inner_z1 - 2.4) - z0
|
||||
for i in slots:
|
||||
var r := i / per_row
|
||||
var c := i % per_row
|
||||
var z: float = z0 + (span * r / float(maxi(1, rows - 1)) if rows > 1 else 0.0)
|
||||
var m := _box(Vector3((-0.6 if c == 0 else 0.6), FLOOR_Y + 0.35, z),
|
||||
Vector3(0.85, 0.7, 0.85), Color.WHITE)
|
||||
m.visible = false
|
||||
_cargo_stack.append(m)
|
||||
|
||||
func _fit_loco() -> void:
|
||||
var z := inner_z1 - 0.75
|
||||
_box(Vector3(0, FLOOR_Y + 0.5, z), Vector3(1.9, 1.0, 0.5), CONSOLE, true)
|
||||
_box(Vector3(0, FLOOR_Y + 1.02, z), Vector3(2.0, 0.08, 0.7), Color("4a4f55"))
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
_box(Vector3(sx * 0.55, FLOOR_Y + 1.14, z), Vector3(0.16, 0.3, 0.16), Color("d8b24a"))
|
||||
hotspots.append({"pos": Vector3(0, FLOOR_Y + 1.2, z - 0.55), "kind": "drive", "seat": -1})
|
||||
# Hocker
|
||||
_box(Vector3(-0.9, FLOOR_Y + 0.25, z - 1.1), Vector3(0.5, 0.5, 0.5), Color("6b4a33"), true)
|
||||
# Depot-Terminal für den Laden
|
||||
var term := Vector3(1.15, FLOOR_Y + 1.1, inner_z0 + 1.2)
|
||||
_box(term, Vector3(0.3, 0.9, 0.8), CONSOLE, true)
|
||||
_box(term + Vector3(-0.17, 0.2, 0), Vector3(0.06, 0.45, 0.6), Color("7ab7e0"))
|
||||
hotspots.append({"pos": term + Vector3(-0.4, 0.0, 0), "kind": "shop", "seat": -1})
|
||||
|
||||
# --- Laufzeit -------------------------------------------------------------
|
||||
|
||||
func show_cargo(count: int, color: Color) -> void:
|
||||
for i in _cargo_stack.size():
|
||||
var m := _cargo_stack[i]
|
||||
m.visible = i < count
|
||||
if m.visible:
|
||||
m.material_override = Mat.flat(color)
|
||||
|
||||
func free_seat() -> int:
|
||||
for i in seats.size():
|
||||
if seats[i]["occupant"] == null:
|
||||
return i
|
||||
return -1
|
||||
1
scripts/car.gd.uid
Normal file
1
scripts/car.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bsy0ylapauwtw
|
||||
102
scripts/car_defs.gd
Normal file
102
scripts/car_defs.gd
Normal file
@@ -0,0 +1,102 @@
|
||||
extends RefCounted
|
||||
## Stammdaten für Loks, Waggons und Ausbauten.
|
||||
|
||||
enum Kind { LOCO, PASSENGER, FREIGHT, DINING }
|
||||
|
||||
## Loks. "rep" = benoetigtes Ansehen zum Freischalten.
|
||||
const LOCOS := {
|
||||
"steam": {
|
||||
"name": "Dampf-Veteran", "price": 0, "sell": 0, "rep": 0,
|
||||
"length": 11.0, "max_speed": 15.0, "accel": 3.0, "brake": 8.0,
|
||||
"color": "a83b30", "trim": "7d2a22",
|
||||
"desc": "Gemütlich, laut, zuverlässig.",
|
||||
},
|
||||
"diesel": {
|
||||
"name": "Diesel-Kurier", "price": 3200, "sell": 1900, "rep": 12,
|
||||
"length": 12.0, "max_speed": 24.0, "accel": 4.6, "brake": 10.0,
|
||||
"color": "2f6f4e", "trim": "24523b",
|
||||
"desc": "Schneller anfahren, höhere Endgeschwindigkeit.",
|
||||
},
|
||||
"express": {
|
||||
"name": "Elektro-Express", "price": 11000, "sell": 6600, "rep": 40,
|
||||
"length": 13.0, "max_speed": 34.0, "accel": 6.8, "brake": 13.5,
|
||||
"color": "2c4f8a", "trim": "1e3a68",
|
||||
"desc": "Der Fernverkehr lässt grüßen.",
|
||||
},
|
||||
}
|
||||
|
||||
## Waggons.
|
||||
const CARS := {
|
||||
"coach": {
|
||||
"name": "Personenwagen", "kind": Kind.PASSENGER, "price": 900, "sell": 540, "rep": 0,
|
||||
"length": 12.0, "seats": 8, "capacity": 0,
|
||||
"color": "c8ccd2", "trim": "8f959c",
|
||||
"desc": "Acht Sitzplätze, schlichte Ausstattung.",
|
||||
},
|
||||
"saloon": {
|
||||
"name": "Salonwagen", "kind": Kind.PASSENGER, "price": 2600, "sell": 1560, "rep": 18,
|
||||
"length": 13.0, "seats": 10, "capacity": 0,
|
||||
"color": "e8dcc0", "trim": "b09a6d",
|
||||
"desc": "Zehn Plätze. Gäste zahlen 50 % mehr.",
|
||||
"fare_bonus": 0.5,
|
||||
},
|
||||
"freight": {
|
||||
"name": "Güterwagen", "kind": Kind.FREIGHT, "price": 700, "sell": 420, "rep": 0,
|
||||
"length": 11.0, "seats": 0, "capacity": 10,
|
||||
"color": "8a5c3c", "trim": "6b4630",
|
||||
"desc": "Zehn Einheiten Ladung.",
|
||||
},
|
||||
"hopper": {
|
||||
"name": "Großgüterwagen", "kind": Kind.FREIGHT, "price": 2100, "sell": 1260, "rep": 14,
|
||||
"length": 13.0, "seats": 0, "capacity": 22,
|
||||
"color": "6b7a52", "trim": "4f5c3c",
|
||||
"desc": "22 Einheiten Ladung.",
|
||||
},
|
||||
"dining": {
|
||||
"name": "Speisewagen", "kind": Kind.DINING, "price": 1900, "sell": 1140, "rep": 8,
|
||||
"length": 12.0, "seats": 4, "capacity": 0,
|
||||
"color": "b8874a", "trim": "8d6435",
|
||||
"desc": "Hier gibt es Getränke für die Gäste.",
|
||||
},
|
||||
}
|
||||
|
||||
## Dauerhafte Ausbauten, einmal kaufbar.
|
||||
const UPGRADES := {
|
||||
"loader": {
|
||||
"name": "Schnelllader", "price": 1400, "rep": 6,
|
||||
"desc": "Be- und Entladen läuft doppelt so schnell.",
|
||||
},
|
||||
"scanner": {
|
||||
"name": "Ticket-Scanner", "price": 1100, "rep": 10,
|
||||
"desc": "Ticketkontrolle in einem Rutsch statt Gast für Gast.",
|
||||
},
|
||||
"trolley": {
|
||||
"name": "Servierwagen", "price": 1800, "rep": 20,
|
||||
"desc": "Trägt drei Getränke statt einem.",
|
||||
},
|
||||
"schedule": {
|
||||
"name": "Fahrplan-Tafel", "price": 900, "rep": 4,
|
||||
"desc": "Zeigt Ziel und Wunsch jedes Gastes im HUD.",
|
||||
},
|
||||
}
|
||||
|
||||
static func loco(id: String) -> Dictionary:
|
||||
return LOCOS[id]
|
||||
|
||||
static func car(id: String) -> Dictionary:
|
||||
return CARS[id]
|
||||
|
||||
static func car_length(id: String) -> float:
|
||||
if LOCOS.has(id):
|
||||
return float(LOCOS[id]["length"])
|
||||
return float(CARS[id]["length"])
|
||||
|
||||
static func kind_of(id: String) -> int:
|
||||
if LOCOS.has(id):
|
||||
return Kind.LOCO
|
||||
return int(CARS[id]["kind"])
|
||||
|
||||
static func display_name(id: String) -> String:
|
||||
if LOCOS.has(id):
|
||||
return String(LOCOS[id]["name"])
|
||||
return String(CARS[id]["name"])
|
||||
1
scripts/car_defs.gd.uid
Normal file
1
scripts/car_defs.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://6cosvd7thiko
|
||||
12
scripts/cargo.gd
Normal file
12
scripts/cargo.gd
Normal file
@@ -0,0 +1,12 @@
|
||||
extends RefCounted
|
||||
## Warentypen.
|
||||
|
||||
enum Type { WOOD, COAL, GRAIN }
|
||||
|
||||
const NAMES := ["Holz", "Kohle", "Getreide"]
|
||||
const PRICES := [12, 20, 9]
|
||||
const COLORS := [
|
||||
Color("9c6b3f"), # Holz
|
||||
Color("2e2e33"), # Kohle
|
||||
Color("e0b73f"), # Getreide
|
||||
]
|
||||
1
scripts/cargo.gd.uid
Normal file
1
scripts/cargo.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dx8x0i2u3x75m
|
||||
95
scripts/deco.gd
Normal file
95
scripts/deco.gd
Normal file
@@ -0,0 +1,95 @@
|
||||
extends RefCounted
|
||||
## Low-Poly Bausteine aus Godot-Primitiven.
|
||||
|
||||
const Mat = preload("res://scripts/mat.gd")
|
||||
|
||||
const TRUNK := Color("6b4a2f")
|
||||
const PINE := [Color("2f7a45"), Color("35894e"), Color("276b3c")]
|
||||
const LEAF := [Color("58a844"), Color("e0932f"), Color("d8c33c"), Color("7cb85a")]
|
||||
const ROCK := Color("8c8b86")
|
||||
|
||||
static func _mi(mesh: Mesh, color: Color, pos: Vector3) -> MeshInstance3D:
|
||||
var m := MeshInstance3D.new()
|
||||
m.mesh = mesh
|
||||
m.material_override = Mat.flat(color)
|
||||
m.position = pos
|
||||
return m
|
||||
|
||||
static func cone(bottom_r: float, height: float, sides: int = 7) -> CylinderMesh:
|
||||
var c := CylinderMesh.new()
|
||||
c.top_radius = 0.0
|
||||
c.bottom_radius = bottom_r
|
||||
c.height = height
|
||||
c.radial_segments = sides
|
||||
c.rings = 1
|
||||
return c
|
||||
|
||||
static func cyl(r: float, height: float, sides: int = 7) -> CylinderMesh:
|
||||
var c := CylinderMesh.new()
|
||||
c.top_radius = r
|
||||
c.bottom_radius = r
|
||||
c.height = height
|
||||
c.radial_segments = sides
|
||||
c.rings = 1
|
||||
return c
|
||||
|
||||
static func box(size: Vector3) -> BoxMesh:
|
||||
var b := BoxMesh.new()
|
||||
b.size = size
|
||||
return b
|
||||
|
||||
static func blob(r: float) -> SphereMesh:
|
||||
var s := SphereMesh.new()
|
||||
s.radius = r
|
||||
s.height = r * 1.9
|
||||
s.radial_segments = 7
|
||||
s.rings = 4
|
||||
return s
|
||||
|
||||
## Nadelbaum: Stamm + zwei bis drei Kegel-Etagen.
|
||||
static func pine_tree(rng: RandomNumberGenerator) -> Node3D:
|
||||
var n := Node3D.new()
|
||||
var h := rng.randf_range(3.4, 6.2)
|
||||
var col: Color = PINE[rng.randi() % PINE.size()]
|
||||
n.add_child(_mi(cyl(0.18, h * 0.3, 6), TRUNK, Vector3(0, h * 0.15, 0)))
|
||||
var tiers := 3 if h > 4.6 else 2
|
||||
for i in tiers:
|
||||
var f := float(i) / float(tiers)
|
||||
var r := lerpf(h * 0.28, h * 0.13, f)
|
||||
var th := h * 0.42
|
||||
var y := h * 0.26 + f * h * 0.32 + th * 0.5
|
||||
n.add_child(_mi(cone(r, th, 7), col, Vector3(0, y, 0)))
|
||||
n.rotate_y(rng.randf() * TAU)
|
||||
return n
|
||||
|
||||
## Laubbaum: Stamm + facettierte Krone.
|
||||
static func round_tree(rng: RandomNumberGenerator) -> Node3D:
|
||||
var n := Node3D.new()
|
||||
var h := rng.randf_range(2.6, 4.4)
|
||||
var col: Color = LEAF[rng.randi() % LEAF.size()]
|
||||
n.add_child(_mi(cyl(0.16, h * 0.55, 6), TRUNK, Vector3(0, h * 0.275, 0)))
|
||||
var r := h * 0.42
|
||||
n.add_child(_mi(blob(r), col, Vector3(0, h * 0.55 + r * 0.55, 0)))
|
||||
n.rotate_y(rng.randf() * TAU)
|
||||
return n
|
||||
|
||||
static func rock(rng: RandomNumberGenerator) -> Node3D:
|
||||
var n := Node3D.new()
|
||||
var r := rng.randf_range(0.4, 1.1)
|
||||
var m := _mi(blob(r), ROCK, Vector3(0, r * 0.35, 0))
|
||||
m.scale = Vector3(1.0, rng.randf_range(0.4, 0.7), rng.randf_range(0.8, 1.2))
|
||||
n.add_child(m)
|
||||
n.rotate_y(rng.randf() * TAU)
|
||||
return n
|
||||
|
||||
## Haus: Quader + Giebeldach (+ optionaler Schornstein).
|
||||
static func house(wall: Color, roof: Color, w: float, d: float, h: float, chimney := false) -> Node3D:
|
||||
var n := Node3D.new()
|
||||
n.add_child(_mi(box(Vector3(w, h, d)), wall, Vector3(0, h * 0.5, 0)))
|
||||
var p := PrismMesh.new()
|
||||
p.size = Vector3(w * 1.14, h * 0.6, d * 1.06)
|
||||
n.add_child(_mi(p, roof, Vector3(0, h + h * 0.3, 0)))
|
||||
if chimney:
|
||||
n.add_child(_mi(box(Vector3(w * 0.16, h * 0.7, w * 0.16)), roof.darkened(0.3),
|
||||
Vector3(w * 0.25, h + h * 0.45, 0)))
|
||||
return n
|
||||
1
scripts/deco.gd.uid
Normal file
1
scripts/deco.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bqncfqu6smtcu
|
||||
1011
scripts/game.gd
Normal file
1011
scripts/game.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
scripts/game.gd.uid
Normal file
1
scripts/game.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bh5vhe5gn4rd6
|
||||
177
scripts/hud.gd
Normal file
177
scripts/hud.gd
Normal file
@@ -0,0 +1,177 @@
|
||||
extends CanvasLayer
|
||||
## Fadenkreuz, Statusleiste, Interaktionshinweis und Meldungen.
|
||||
|
||||
const Cargo = preload("res://scripts/cargo.gd")
|
||||
const Defs = preload("res://scripts/car_defs.gd")
|
||||
const PassengerScript = preload("res://scripts/passenger.gd")
|
||||
const PlayerScript = preload("res://scripts/player.gd")
|
||||
|
||||
var _stats := Label.new()
|
||||
var _prompt := Label.new()
|
||||
var _drive := Label.new()
|
||||
var _cross := Panel.new()
|
||||
var _toasts: Array = []
|
||||
var _toast_box := VBoxContainer.new()
|
||||
|
||||
const PROMPTS := {
|
||||
"drive": "E Steuerung übernehmen",
|
||||
"shop": "E Depot-Terminal öffnen",
|
||||
"tap": "E Getränke aufnehmen",
|
||||
"load": "E halten Be- und entladen",
|
||||
"seat": "E Fahrgast",
|
||||
}
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 1
|
||||
var panel := PanelContainer.new()
|
||||
panel.position = Vector2(18, 16)
|
||||
panel.add_theme_stylebox_override("panel", _bg(Color(0.08, 0.11, 0.09, 0.55)))
|
||||
add_child(panel)
|
||||
_stats.add_theme_font_size_override("font_size", 17)
|
||||
panel.add_child(_stats)
|
||||
|
||||
_toast_box.add_theme_constant_override("separation", 4)
|
||||
_toast_box.position = Vector2(18, 258)
|
||||
add_child(_toast_box)
|
||||
|
||||
_prompt.add_theme_font_size_override("font_size", 20)
|
||||
_prompt.add_theme_color_override("font_color", Color("ffe9a8"))
|
||||
_prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
add_child(_prompt)
|
||||
|
||||
_drive.add_theme_font_size_override("font_size", 22)
|
||||
_drive.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
add_child(_drive)
|
||||
|
||||
var cs := StyleBoxFlat.new()
|
||||
cs.bg_color = Color(1, 1, 1, 0.75)
|
||||
cs.set_corner_radius_all(3)
|
||||
_cross.add_theme_stylebox_override("panel", cs)
|
||||
_cross.size = Vector2(5, 5)
|
||||
add_child(_cross)
|
||||
|
||||
# Kein HUD-Element darf Eingaben schlucken - sonst stirbt der Mausblick.
|
||||
_ignore_mouse(self)
|
||||
get_viewport().size_changed.connect(_layout)
|
||||
_layout()
|
||||
|
||||
func _ignore_mouse(n: Node) -> void:
|
||||
if n is Control:
|
||||
(n as Control).mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
for c in n.get_children():
|
||||
_ignore_mouse(c)
|
||||
|
||||
func _bg(c: Color) -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = c
|
||||
s.set_corner_radius_all(10)
|
||||
s.set_content_margin_all(14)
|
||||
return s
|
||||
|
||||
func _layout() -> void:
|
||||
var v := get_viewport().get_visible_rect().size
|
||||
_cross.position = v * 0.5 - Vector2(2.5, 2.5)
|
||||
_prompt.size = Vector2(v.x, 30)
|
||||
_prompt.position = Vector2(0, v.y * 0.58)
|
||||
_drive.size = Vector2(v.x, 34)
|
||||
_drive.position = Vector2(0, v.y - 96)
|
||||
|
||||
func refresh(game) -> void:
|
||||
var t = game.train
|
||||
var lines: Array[String] = []
|
||||
lines.append("%d € Ansehen %d" % [game.money, game.reputation])
|
||||
lines.append("")
|
||||
lines.append("Zug %s + %d Wagen" % [Defs.display_name(t.loco_id), t.consist.size()])
|
||||
lines.append("Tempo %3.0f km/h" % t.speed_kmh())
|
||||
|
||||
var seated := 0
|
||||
var thirsty := 0
|
||||
var unchecked := 0
|
||||
for p in game.passengers:
|
||||
if p.state == PassengerScript.State.SEATED:
|
||||
seated += 1
|
||||
if p.wants_drink and not p.served:
|
||||
thirsty += 1
|
||||
if not p.ticket_checked:
|
||||
unchecked += 1
|
||||
lines.append("Gäste %d / %d ohne Ticket %d Durst %d"
|
||||
% [seated, t.seat_count(), unchecked, thirsty])
|
||||
|
||||
var cargo_txt := "leer"
|
||||
if t.total_cargo() > 0:
|
||||
var parts: Array[String] = []
|
||||
for k in t.cargo.keys():
|
||||
parts.append("%s %d" % [Cargo.NAMES[int(k)], int(t.cargo[k])])
|
||||
cargo_txt = ", ".join(parts)
|
||||
lines.append("Ladung %d / %d (%s)" % [t.total_cargo(), t.cargo_capacity(), cargo_txt])
|
||||
if game.player.carrying > 0:
|
||||
lines.append("Getränke dabei: %d" % game.player.carrying)
|
||||
|
||||
var st = t.docked_at
|
||||
if st != null and t.speed < 0.1:
|
||||
var extra := ""
|
||||
if st.produces >= 0:
|
||||
extra = " Lager: %s %d" % [Cargo.NAMES[st.produces], int(st.stock)]
|
||||
lines.append("Halt: %s%s" % [st.station_name, extra])
|
||||
else:
|
||||
var nxt: Array = t.next_station()
|
||||
lines.append("Nächster Halt: %s (%d m)" % [nxt[0].station_name, int(nxt[1])])
|
||||
_stats.text = "\n".join(lines)
|
||||
|
||||
var h: Dictionary = game.player.hotspot
|
||||
if game.player.mode == PlayerScript.Mode.DRIVE:
|
||||
_prompt.text = "E Steuerung abgeben"
|
||||
elif h.is_empty():
|
||||
_prompt.text = ""
|
||||
elif String(h["kind"]) == "seat":
|
||||
_prompt.text = _seat_prompt(game, h)
|
||||
else:
|
||||
_prompt.text = String(PROMPTS.get(String(h["kind"]), ""))
|
||||
|
||||
if game.player.mode == PlayerScript.Mode.DRIVE:
|
||||
_drive.text = "W Gas S Bremse %3.0f km/h" % t.speed_kmh()
|
||||
_drive.add_theme_color_override("font_color",
|
||||
Color("aef2a0") if t.speed > 0.2 else Color("ffd978"))
|
||||
else:
|
||||
_drive.text = ""
|
||||
_cross.visible = not game.player.input_locked
|
||||
_tick_toasts()
|
||||
|
||||
func _seat_prompt(game, h: Dictionary) -> String:
|
||||
var car = game.train.cars[int(h["car"])]
|
||||
var occ = car.seats[int(h["seat"])]["occupant"]
|
||||
if occ == null:
|
||||
return "Freier Platz"
|
||||
if not occ.ticket_checked:
|
||||
return "E Ticket kontrollieren (%d €)" % occ.fare
|
||||
if occ.wants_drink and not occ.served:
|
||||
return "E Getränk servieren" if game.player.carrying > 0 else "Braucht ein Getränk"
|
||||
if game.upgrades.has("schedule"):
|
||||
return "Ziel: %s - %s" % [game.stations[occ.dest_station].station_name, occ.mood_word()]
|
||||
return "E Nachfragen"
|
||||
|
||||
# --- Meldungen ------------------------------------------------------------
|
||||
|
||||
func toast(text: String, color: Color = Color("aef2a0")) -> void:
|
||||
var l := Label.new()
|
||||
l.text = text
|
||||
l.add_theme_font_size_override("font_size", 19)
|
||||
l.add_theme_color_override("font_color", color)
|
||||
l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
|
||||
l.add_theme_constant_override("outline_size", 5)
|
||||
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_toast_box.add_child(l)
|
||||
_toasts.append({"node": l, "time": 3.4})
|
||||
if _toasts.size() > 5:
|
||||
var old: Dictionary = _toasts.pop_front()
|
||||
old["node"].queue_free()
|
||||
|
||||
func _tick_toasts() -> void:
|
||||
var dt := get_process_delta_time()
|
||||
for e in _toasts.duplicate():
|
||||
e["time"] -= dt
|
||||
if e["time"] <= 0.0:
|
||||
e["node"].queue_free()
|
||||
_toasts.erase(e)
|
||||
else:
|
||||
e["node"].modulate.a = clampf(float(e["time"]) / 0.9, 0.0, 1.0)
|
||||
1
scripts/hud.gd.uid
Normal file
1
scripts/hud.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://7ckhmyrrsbx8
|
||||
16
scripts/mat.gd
Normal file
16
scripts/mat.gd
Normal file
@@ -0,0 +1,16 @@
|
||||
extends RefCounted
|
||||
## Material-Cache: eine ShaderMaterial pro Farbe, flat shaded.
|
||||
|
||||
const FLAT_SHADER := preload("res://shaders/flat.gdshader")
|
||||
|
||||
static var _cache: Dictionary = {}
|
||||
|
||||
static func flat(color: Color) -> ShaderMaterial:
|
||||
var key := color.to_rgba32()
|
||||
if _cache.has(key):
|
||||
return _cache[key]
|
||||
var m := ShaderMaterial.new()
|
||||
m.shader = FLAT_SHADER
|
||||
m.set_shader_parameter("albedo", color)
|
||||
_cache[key] = m
|
||||
return m
|
||||
1
scripts/mat.gd.uid
Normal file
1
scripts/mat.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d0twbpthct6rv
|
||||
101
scripts/menu.gd
Normal file
101
scripts/menu.gd
Normal file
@@ -0,0 +1,101 @@
|
||||
extends CanvasLayer
|
||||
## Startmenue: Einzelspieler, Hosten oder einer Sitzung beitreten.
|
||||
|
||||
signal solo_chosen()
|
||||
signal host_chosen(port: int)
|
||||
signal join_chosen(address: String, port: int)
|
||||
|
||||
const DEFAULT_PORT := 7654
|
||||
|
||||
var _root: Control
|
||||
var _status := Label.new()
|
||||
var _addr := LineEdit.new()
|
||||
var _port := LineEdit.new()
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 3
|
||||
_root = Control.new()
|
||||
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(_root)
|
||||
|
||||
var bg := ColorRect.new()
|
||||
bg.color = Color("14201c")
|
||||
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.add_child(bg)
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.add_child(center)
|
||||
|
||||
var vb := VBoxContainer.new()
|
||||
vb.add_theme_constant_override("separation", 14)
|
||||
vb.custom_minimum_size = Vector2(420, 0)
|
||||
center.add_child(vb)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Cozy Train"
|
||||
title.add_theme_font_size_override("font_size", 52)
|
||||
title.add_theme_color_override("font_color", Color("ffd978"))
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vb.add_child(title)
|
||||
|
||||
var sub := Label.new()
|
||||
sub.text = "Eine Crew, ein Zug, viele Fahrgäste."
|
||||
sub.add_theme_font_size_override("font_size", 17)
|
||||
sub.add_theme_color_override("font_color", Color(1, 1, 1, 0.7))
|
||||
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vb.add_child(sub)
|
||||
|
||||
vb.add_child(_spacer(18))
|
||||
vb.add_child(_button("Einzelspieler", func(): solo_chosen.emit()))
|
||||
vb.add_child(_button("Sitzung hosten", func(): host_chosen.emit(_port_value())))
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
_addr.text = "127.0.0.1"
|
||||
_addr.placeholder_text = "Adresse"
|
||||
_addr.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(_addr)
|
||||
_port.text = str(DEFAULT_PORT)
|
||||
_port.custom_minimum_size = Vector2(90, 0)
|
||||
row.add_child(_port)
|
||||
vb.add_child(row)
|
||||
vb.add_child(_button("Beitreten", func(): join_chosen.emit(_addr.text, _port_value())))
|
||||
|
||||
_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_status.add_theme_font_size_override("font_size", 16)
|
||||
_status.add_theme_color_override("font_color", Color("ffb4a0"))
|
||||
vb.add_child(_status)
|
||||
|
||||
var help := Label.new()
|
||||
help.text = "Maus: umsehen WASD: laufen E: benutzen V: Außenkamera Esc: Maus frei"
|
||||
help.add_theme_font_size_override("font_size", 14)
|
||||
help.add_theme_color_override("font_color", Color(1, 1, 1, 0.45))
|
||||
help.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vb.add_child(help)
|
||||
|
||||
func _spacer(h: int) -> Control:
|
||||
var c := Control.new()
|
||||
c.custom_minimum_size = Vector2(0, h)
|
||||
return c
|
||||
|
||||
func _button(text: String, cb: Callable) -> Button:
|
||||
var b := Button.new()
|
||||
b.text = text
|
||||
b.custom_minimum_size = Vector2(0, 46)
|
||||
b.add_theme_font_size_override("font_size", 20)
|
||||
b.pressed.connect(cb)
|
||||
return b
|
||||
|
||||
func _port_value() -> int:
|
||||
var v := int(_port.text)
|
||||
return v if v > 0 else DEFAULT_PORT
|
||||
|
||||
func set_status(text: String) -> void:
|
||||
_status.text = text
|
||||
|
||||
func hide_menu() -> void:
|
||||
_root.visible = false
|
||||
|
||||
func show_menu() -> void:
|
||||
_root.visible = true
|
||||
1
scripts/menu.gd.uid
Normal file
1
scripts/menu.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ybrxme2724st
|
||||
130
scripts/passenger.gd
Normal file
130
scripts/passenger.gd
Normal file
@@ -0,0 +1,130 @@
|
||||
extends Node3D
|
||||
## Ein Fahrgast. Wartet am Bahnsteig, faehrt mit, will kontrolliert
|
||||
## und bedient werden und steigt am Ziel wieder aus.
|
||||
|
||||
const Mat = preload("res://scripts/mat.gd")
|
||||
const Deco = preload("res://scripts/deco.gd")
|
||||
const Car = preload("res://scripts/car.gd")
|
||||
|
||||
enum State { WAITING, BOARDING, SEATED, ALIGHTING, DONE }
|
||||
|
||||
const SHIRTS := ["c65b4a", "4a7cc6", "5fa86b", "c6a44a", "9a5fc6", "c66b9a", "4ab0a8"]
|
||||
const SKIN := ["e8c39e", "c99a6e", "8d5f3f", "f0d5b8"]
|
||||
const THIRST_AFTER := 26.0
|
||||
const MOOD_DECAY := 0.055
|
||||
|
||||
var pid := 0
|
||||
var state := State.WAITING
|
||||
var origin_station := 0
|
||||
var dest_station := 0
|
||||
var fare := 0
|
||||
var ticket_checked := false
|
||||
var wants_drink := false
|
||||
var served := false
|
||||
var mood := 1.0
|
||||
var seat_car := -1
|
||||
var seat_index := -1
|
||||
|
||||
var _legs: Node3D
|
||||
var _label: Label3D
|
||||
var _seated_time := 0.0
|
||||
var _lerp_from := Transform3D()
|
||||
var _lerp_to := Transform3D()
|
||||
var _lerp_t := 0.0
|
||||
var _lerp_dur := 1.0
|
||||
|
||||
func build(rng: RandomNumberGenerator) -> void:
|
||||
var shirt := Color(SHIRTS[rng.randi() % SHIRTS.size()])
|
||||
var skin := Color(SKIN[rng.randi() % SKIN.size()])
|
||||
var trousers := Color("3d4450").lightened(rng.randf() * 0.25)
|
||||
|
||||
_legs = Node3D.new()
|
||||
add_child(_legs)
|
||||
var lm := MeshInstance3D.new()
|
||||
lm.mesh = Deco.box(Vector3(0.42, 0.8, 0.3))
|
||||
lm.material_override = Mat.flat(trousers)
|
||||
lm.position = Vector3(0, -0.4, 0)
|
||||
_legs.add_child(lm)
|
||||
_legs.position = Vector3(0, 0.8, 0)
|
||||
|
||||
var torso := MeshInstance3D.new()
|
||||
torso.mesh = Deco.box(Vector3(0.5, 0.62, 0.32))
|
||||
torso.material_override = Mat.flat(shirt)
|
||||
torso.position = Vector3(0, 1.12, 0)
|
||||
add_child(torso)
|
||||
|
||||
for sx: float in [-1.0, 1.0]:
|
||||
var arm := MeshInstance3D.new()
|
||||
arm.mesh = Deco.box(Vector3(0.13, 0.58, 0.18))
|
||||
arm.material_override = Mat.flat(shirt.darkened(0.1))
|
||||
arm.position = Vector3(sx * 0.32, 1.1, 0)
|
||||
add_child(arm)
|
||||
|
||||
var head := MeshInstance3D.new()
|
||||
head.mesh = Deco.blob(0.17)
|
||||
head.material_override = Mat.flat(skin)
|
||||
head.position = Vector3(0, 1.58, 0)
|
||||
add_child(head)
|
||||
|
||||
var hair := MeshInstance3D.new()
|
||||
hair.mesh = Deco.box(Vector3(0.34, 0.12, 0.34))
|
||||
hair.material_override = Mat.flat(Color("3a2c22").lightened(rng.randf() * 0.4))
|
||||
hair.position = Vector3(0, 1.72, 0)
|
||||
add_child(hair)
|
||||
|
||||
_label = Label3D.new()
|
||||
_label.font_size = 36
|
||||
_label.pixel_size = 0.0042
|
||||
_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
_label.outline_size = 12
|
||||
_label.outline_modulate = Color(0, 0, 0, 0.7)
|
||||
_label.position = Vector3(0, 2.05, 0)
|
||||
_label.visible = false
|
||||
add_child(_label)
|
||||
|
||||
func set_seated(on: bool) -> void:
|
||||
_legs.rotation.x = deg_to_rad(-82.0) if on else 0.0
|
||||
|
||||
func start_lerp(from: Transform3D, to: Transform3D, dur: float) -> void:
|
||||
_lerp_from = from
|
||||
_lerp_to = to
|
||||
_lerp_t = 0.0
|
||||
_lerp_dur = dur
|
||||
|
||||
## true, sobald die Bewegung fertig ist.
|
||||
func advance_lerp(delta: float) -> bool:
|
||||
_lerp_t = minf(_lerp_t + delta / _lerp_dur, 1.0)
|
||||
var t := _lerp_t * _lerp_t * (3.0 - 2.0 * _lerp_t)
|
||||
global_transform = _lerp_from.interpolate_with(_lerp_to, t)
|
||||
return _lerp_t >= 1.0
|
||||
|
||||
func tick(delta: float, drinks_available: bool) -> void:
|
||||
if state != State.SEATED:
|
||||
return
|
||||
_seated_time += delta
|
||||
if drinks_available and not wants_drink and not served and _seated_time > THIRST_AFTER:
|
||||
wants_drink = true
|
||||
if wants_drink and not served:
|
||||
mood = maxf(mood - MOOD_DECAY * delta, 0.15)
|
||||
_refresh_label()
|
||||
|
||||
func _refresh_label() -> void:
|
||||
if not ticket_checked:
|
||||
_label.text = "Ticket"
|
||||
_label.modulate = Color("ffd978")
|
||||
_label.visible = true
|
||||
elif wants_drink and not served:
|
||||
_label.text = "Durst"
|
||||
_label.modulate = Color("8fd8ff")
|
||||
_label.visible = true
|
||||
else:
|
||||
_label.visible = false
|
||||
|
||||
func mood_word() -> String:
|
||||
if mood > 0.85:
|
||||
return "bestens"
|
||||
if mood > 0.55:
|
||||
return "zufrieden"
|
||||
if mood > 0.3:
|
||||
return "genervt"
|
||||
return "sauer"
|
||||
1
scripts/passenger.gd.uid
Normal file
1
scripts/passenger.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dg24o5s0meuye
|
||||
175
scripts/player.gd
Normal file
175
scripts/player.gd
Normal file
@@ -0,0 +1,175 @@
|
||||
extends Node3D
|
||||
## First-Person-Spieler. Bewegt sich in Zugkoordinaten (s laengs, x quer),
|
||||
## damit der Innenraum auch bei voller Fahrt stabil begehbar bleibt.
|
||||
|
||||
const Mat = preload("res://scripts/mat.gd")
|
||||
const Deco = preload("res://scripts/deco.gd")
|
||||
const Car = preload("res://scripts/car.gd")
|
||||
|
||||
signal interact_pressed(hotspot: Dictionary)
|
||||
|
||||
enum Mode { WALK, DRIVE }
|
||||
|
||||
const RADIUS := 0.30
|
||||
const HEIGHT := 1.75
|
||||
const EYE := 1.62
|
||||
const WALK_SPEED := 2.6
|
||||
const RUN_SPEED := 4.6
|
||||
const ACCEL := 14.0
|
||||
const REACH := 2.0
|
||||
const MOUSE_SENS := 0.0022
|
||||
|
||||
var s := 0.0
|
||||
var x := 0.0
|
||||
var yaw := 0.0
|
||||
var pitch := 0.0
|
||||
var mode := Mode.WALK
|
||||
var carrying := 0 ## getragene Getränke
|
||||
var hotspot: Dictionary = {} ## aktuell anvisierter Interaktionspunkt
|
||||
var input_locked := false ## wenn ein Menue offen ist
|
||||
|
||||
var camera: Camera3D
|
||||
var train
|
||||
|
||||
var _vel := Vector2.ZERO ## (quer, laengs)
|
||||
|
||||
func setup(train_node) -> void:
|
||||
train = train_node
|
||||
camera = Camera3D.new()
|
||||
camera.fov = 75.0
|
||||
camera.near = 0.05
|
||||
camera.far = 900.0
|
||||
add_child(camera)
|
||||
snap_to_cab()
|
||||
|
||||
func snap_to_cab() -> void:
|
||||
if train.cars.is_empty():
|
||||
return
|
||||
var loco = train.cars[0]
|
||||
s = loco.center_s + loco.inner_z1 - 2.4
|
||||
x = 0.0
|
||||
yaw = 0.0
|
||||
pitch = 0.0
|
||||
# Falls dort etwas steht, nach hinten ausweichen.
|
||||
var span: Vector2 = train.walk_range()
|
||||
var tries := 0
|
||||
while _blocked(x, s) and tries < 40:
|
||||
s = maxf(s - 0.25, span.x)
|
||||
tries += 1
|
||||
|
||||
## Aktuelles Lauftempo, für die Schrittgeräusche.
|
||||
func move_speed() -> float:
|
||||
return _vel.length()
|
||||
|
||||
func capture_mouse(on: bool) -> void:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if on else Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
yaw -= event.relative.x * MOUSE_SENS
|
||||
pitch = clampf(pitch - event.relative.y * MOUSE_SENS, -1.35, 1.35)
|
||||
elif event is InputEventKey and event.pressed and not event.echo:
|
||||
if event.keycode == KEY_E and not input_locked:
|
||||
if not hotspot.is_empty():
|
||||
interact_pressed.emit(hotspot)
|
||||
|
||||
# --- Bewegung -------------------------------------------------------------
|
||||
|
||||
func forward_dir() -> Vector2:
|
||||
## (quer, laengs) im Zugsystem
|
||||
return Vector2(sin(yaw), cos(yaw))
|
||||
|
||||
## Bildschirm-rechts im Zugsystem. Godots Kamerabasis hat +X nach links,
|
||||
## deshalb das umgekehrte Vorzeichen zu forward_dir().
|
||||
func right_dir() -> Vector2:
|
||||
return Vector2(-cos(yaw), sin(yaw))
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if train == null or train.cars.is_empty():
|
||||
return
|
||||
if mode == Mode.WALK and not input_locked:
|
||||
_walk(delta)
|
||||
else:
|
||||
_vel = Vector2.ZERO
|
||||
_update_transform()
|
||||
_pick_hotspot()
|
||||
|
||||
func _walk(delta: float) -> void:
|
||||
var move := Vector2.ZERO
|
||||
if Input.is_key_pressed(KEY_W):
|
||||
move += forward_dir()
|
||||
if Input.is_key_pressed(KEY_S):
|
||||
move -= forward_dir()
|
||||
if Input.is_key_pressed(KEY_D):
|
||||
move += right_dir()
|
||||
if Input.is_key_pressed(KEY_A):
|
||||
move -= right_dir()
|
||||
if move.length() > 0.001:
|
||||
move = move.normalized()
|
||||
var target: float = RUN_SPEED if Input.is_key_pressed(KEY_SHIFT) else WALK_SPEED
|
||||
_vel = _vel.move_toward(move * target, ACCEL * delta)
|
||||
|
||||
var step := _vel * delta
|
||||
# Achsenweise schieben, damit man an Hindernissen entlanggleitet.
|
||||
if not _blocked(x + step.x, s):
|
||||
x += step.x
|
||||
if not _blocked(x, s + step.y):
|
||||
s += step.y
|
||||
|
||||
var span: Vector2 = train.walk_range()
|
||||
s = clampf(s, span.x, span.y)
|
||||
|
||||
func _blocked(nx: float, ns: float) -> bool:
|
||||
var loc: Dictionary = train.locate(ns)
|
||||
var car = train.cars[int(loc["index"])]
|
||||
var lz: float = float(loc["local_z"])
|
||||
var half: float = Car.GANGWAY_HALF if bool(loc["gangway"]) else Car.INNER_HALF
|
||||
if absf(nx) > half - RADIUS:
|
||||
return true
|
||||
var y0: float = Car.FLOOR_Y + 0.05
|
||||
var y1: float = Car.FLOOR_Y + HEIGHT
|
||||
for b in car.blockers:
|
||||
var box: AABB = b
|
||||
if box.position.y + box.size.y <= y0 or box.position.y >= y1:
|
||||
continue
|
||||
if nx + RADIUS <= box.position.x or nx - RADIUS >= box.position.x + box.size.x:
|
||||
continue
|
||||
if lz + RADIUS <= box.position.z or lz - RADIUS >= box.position.z + box.size.z:
|
||||
continue
|
||||
return true
|
||||
return false
|
||||
|
||||
func _update_transform() -> void:
|
||||
var loc: Dictionary = train.locate(s)
|
||||
var xf: Transform3D = train.car_transform(int(loc["index"]))
|
||||
var lz: float = float(loc["local_z"])
|
||||
global_position = xf * Vector3(x, Car.FLOOR_Y, lz)
|
||||
var look := Vector3(sin(yaw) * cos(pitch), sin(pitch), cos(yaw) * cos(pitch))
|
||||
global_basis = xf.basis * Basis.looking_at(look, Vector3.UP)
|
||||
camera.position = Vector3(0, EYE, 0)
|
||||
|
||||
# --- Interaktion ----------------------------------------------------------
|
||||
|
||||
func _pick_hotspot() -> void:
|
||||
hotspot = {}
|
||||
if input_locked:
|
||||
return
|
||||
var best_score := 0.35
|
||||
var fwd := forward_dir()
|
||||
for ci in train.cars.size():
|
||||
var car = train.cars[ci]
|
||||
for h in car.hotspots:
|
||||
var p: Vector3 = h["pos"]
|
||||
var hs: float = car.center_s + p.z
|
||||
var d := Vector2(p.x - x, hs - s)
|
||||
var dist := d.length()
|
||||
if dist > REACH:
|
||||
continue
|
||||
if absf(p.y - (Car.FLOOR_Y + EYE)) > 1.7:
|
||||
continue
|
||||
var dir := d.normalized() if dist > 0.01 else fwd
|
||||
var score := fwd.dot(dir) * (1.0 - dist / (REACH * 2.0))
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
hotspot = {"car": ci, "kind": String(h["kind"]), "seat": int(h["seat"]),
|
||||
"pos": p, "dist": dist}
|
||||
1
scripts/player.gd.uid
Normal file
1
scripts/player.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cetffj1gko2vf
|
||||
213
scripts/sfx.gd
Normal file
213
scripts/sfx.gd
Normal file
@@ -0,0 +1,213 @@
|
||||
extends RefCounted
|
||||
## Alle Klänge des Spiels, einmal beim Start berechnet.
|
||||
|
||||
const Synth = preload("res://scripts/synth.gd")
|
||||
|
||||
const BPM := 72.0
|
||||
const BEAT := 60.0 / BPM
|
||||
const BAR := BEAT * 4.0
|
||||
const BARS := 8
|
||||
|
||||
## Eine Stufe der Harmoniefolge. Alle Akkorde liegen in derselben Lage
|
||||
## (F3..G4), damit kein Takt aus dem Klangbild springt. "melody" enthält
|
||||
## nur Töne, die zum Akkord passen. Geprüft wird auf kleine Sekunde und
|
||||
## kleine None gegen jede Stimme - genau die reiben hörbar. Über G6 fehlt
|
||||
## deshalb das C, über Cmaj7 ebenfalls.
|
||||
const PROGRESSION := [
|
||||
{"name": "Am7", "pad": [220.00, 261.63, 329.63, 392.00], "bass": 110.00,
|
||||
"melody": [440.00, 493.88, 523.25, 587.33, 659.26, 783.99]},
|
||||
{"name": "Fmaj7", "pad": [174.61, 220.00, 261.63, 329.63], "bass": 87.31,
|
||||
"melody": [440.00, 523.25, 587.33, 659.26, 783.99, 880.00]},
|
||||
{"name": "Cmaj7", "pad": [196.00, 246.94, 329.63, 392.00], "bass": 130.81,
|
||||
"melody": [493.88, 587.33, 659.26, 783.99, 880.00]},
|
||||
{"name": "G6", "pad": [196.00, 246.94, 293.66, 329.63], "bass": 98.00,
|
||||
"melody": [440.00, 493.88, 587.33, 659.26, 783.99]},
|
||||
{"name": "Am7", "pad": [220.00, 261.63, 329.63, 392.00], "bass": 110.00,
|
||||
"melody": [440.00, 493.88, 523.25, 587.33, 659.26, 783.99]},
|
||||
{"name": "Fmaj7", "pad": [174.61, 220.00, 261.63, 329.63], "bass": 87.31,
|
||||
"melody": [440.00, 523.25, 587.33, 659.26, 783.99, 880.00]},
|
||||
{"name": "Dm7", "pad": [220.00, 261.63, 293.66, 349.23], "bass": 146.83,
|
||||
"melody": [440.00, 523.25, 587.33, 659.26, 698.46, 783.99]},
|
||||
{"name": "G6", "pad": [196.00, 246.94, 293.66, 329.63], "bass": 98.00,
|
||||
"melody": [440.00, 493.88, 587.33, 659.26, 783.99]},
|
||||
]
|
||||
|
||||
static func build_all() -> Dictionary:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 20260828
|
||||
return {
|
||||
"clack": _clack(rng),
|
||||
"clack2": _clack2(rng),
|
||||
"rumble": _rumble(rng),
|
||||
"squeal": _squeal(rng),
|
||||
"whistle": _whistle(rng),
|
||||
"hiss": _hiss(rng),
|
||||
"bell": _bell(),
|
||||
"coin": _coin(),
|
||||
"pour": _pour(rng),
|
||||
"clink": _clink(),
|
||||
"thud": _thud(rng),
|
||||
"step": _step(rng),
|
||||
"click": _click(),
|
||||
"deny": _deny(),
|
||||
}
|
||||
|
||||
static func build_music() -> PackedByteArray:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 7411
|
||||
var loop_len := BAR * BARS
|
||||
var buf := Synth.buffer(loop_len + 3.0)
|
||||
|
||||
for b in BARS:
|
||||
var t0 := b * BAR
|
||||
var stage: Dictionary = PROGRESSION[b]
|
||||
var pad: Array = stage["pad"]
|
||||
var bass: float = float(stage["bass"])
|
||||
var melody: Array = stage["melody"]
|
||||
|
||||
# Flächen, leicht überlappend für weiche Übergänge
|
||||
for i in pad.size():
|
||||
var amp: float = 0.19 / (1.0 + i * 0.4)
|
||||
Synth.add_pad(buf, t0 - 0.45, float(pad[i]), BAR + 1.0, amp, 1.2)
|
||||
# Bass: Grundton auf 1, Quinte auf 3
|
||||
Synth.add_tone(buf, t0, bass, 2.3, 0.36, 1.4, [1.0, 0.38, 0.12], 0.01)
|
||||
Synth.add_tone(buf, t0 + BEAT * 2.0, bass * 1.5, 1.7, 0.20, 1.8, [1.0, 0.28], 0.01)
|
||||
# Melodie: sparsame Glockentöne, immer aus dem Akkord
|
||||
var beats := [0.0, 1.5, 2.5] if b % 2 == 0 else [0.5, 2.0, 3.0]
|
||||
for k in beats.size():
|
||||
if rng.randf() < 0.3:
|
||||
continue
|
||||
var f: float = float(melody[rng.randi() % melody.size()])
|
||||
Synth.add_tone(buf, t0 + float(beats[k]) * BEAT, f, 2.4,
|
||||
0.11, 2.2, [1.0, 0.45, 0.18, 0.07], 0.006)
|
||||
# Sehr leiser Shaker auf jedem Schlag
|
||||
for beat in 4:
|
||||
Synth.add_noise(buf, t0 + beat * BEAT, 0.12,
|
||||
0.032 if beat % 2 == 1 else 0.020, 34.0, 0.62, rng)
|
||||
|
||||
return Synth.to_pcm(Synth.seamless(buf, loop_len))
|
||||
|
||||
# --- Einzelklänge ---------------------------------------------------------
|
||||
|
||||
## Ein Radstoß: tiefer Schlag, mittleres Klopfen, kurzer Metallanteil.
|
||||
static func _impact(b: PackedFloat32Array, t: float, low: float, mid: float,
|
||||
amp: float, rng: RandomNumberGenerator) -> void:
|
||||
Synth.add_tone(b, t, low, 0.30, 0.85 * amp, 13.0, [1.0, 0.30, 0.10], 0.002)
|
||||
Synth.add_tone(b, t, mid, 0.22, 0.42 * amp, 26.0, [1.0, 0.35], 0.002)
|
||||
Synth.add_noise(b, t, 0.16, 0.30 * amp, 30.0, 0.14, rng, 0.001)
|
||||
Synth.add_noise(b, t, 0.05, 0.20 * amp, 90.0, 0.55, rng, 0.001)
|
||||
Synth.add_resonant_noise(b, t, 0.14, 1450.0, 0.985, 0.16 * amp, rng, 26.0)
|
||||
|
||||
## Doppelschlag der beiden Achsen eines Drehgestells.
|
||||
static func _clack(rng: RandomNumberGenerator) -> PackedByteArray:
|
||||
var b := Synth.buffer(0.62)
|
||||
_impact(b, 0.0, 61.0, 196.0, 1.0, rng)
|
||||
_impact(b, 0.155, 58.0, 183.0, 0.72, rng)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _clack2(rng: RandomNumberGenerator) -> PackedByteArray:
|
||||
var b := Synth.buffer(0.62)
|
||||
_impact(b, 0.0, 68.0, 213.0, 0.94, rng)
|
||||
_impact(b, 0.168, 64.0, 172.0, 0.66, rng)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
## Grundrauschen der Fahrt, als Schleife.
|
||||
static func _rumble(rng: RandomNumberGenerator) -> PackedByteArray:
|
||||
var loop := 1.5
|
||||
var b := Synth.buffer(loop + 0.4)
|
||||
var y := 0.0
|
||||
var y2 := 0.0
|
||||
for i in b.size():
|
||||
y += (rng.randf_range(-1.0, 1.0) - y) * 0.06
|
||||
y2 += (y - y2) * 0.09
|
||||
b[i] = y2 * 3.4
|
||||
for k in 3:
|
||||
Synth.add_tone(b, 0.0, 46.0 + k * 23.0, loop + 0.4, 0.1 / (k + 1), 0.0, [1.0], 0.05)
|
||||
return Synth.to_pcm(Synth.seamless(b, loop))
|
||||
|
||||
## Bremsenquietschen. Der tonale Teil ist exakt periodisch zur Schleifen-
|
||||
## länge, der Rauschanteil wird übergeblendet - so klickt die Naht nicht.
|
||||
static func _squeal(rng: RandomNumberGenerator) -> PackedByteArray:
|
||||
var loop := 1.2
|
||||
var unit := 1.0 / loop ## alles Vielfache davon
|
||||
var noise := Synth.buffer(loop + 0.35)
|
||||
Synth.add_resonant_noise(noise, 0.0, loop + 0.35, 1740.0, 0.9955, 0.55, rng)
|
||||
Synth.add_resonant_noise(noise, 0.0, loop + 0.35, 2610.0, 0.9945, 0.28, rng)
|
||||
Synth.add_noise(noise, 0.0, loop + 0.35, 0.05, 0.0, 0.5, rng, 0.02)
|
||||
var b := Synth.seamless(noise, loop)
|
||||
Synth.add_wobble(b, 0.0, unit * 1740.0, loop, 0.26, 0.010, unit * 4.0, 0.12)
|
||||
Synth.add_wobble(b, 0.0, unit * 2610.0, loop, 0.13, 0.008, unit * 7.0)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _whistle(rng: RandomNumberGenerator) -> PackedByteArray:
|
||||
var b := Synth.buffer(1.6)
|
||||
for f in [523.0, 659.0, 784.0]:
|
||||
Synth.add_tone(b, 0.0, float(f), 1.45, 0.30, 1.1,
|
||||
[1.0, 0.45, 0.2, 0.08], 0.09, 0.012, 5.5)
|
||||
Synth.add_tone(b, 0.0, float(f) * 1.004, 1.45, 0.22, 1.1, [1.0, 0.3], 0.11)
|
||||
Synth.add_noise(b, 0.0, 1.4, 0.12, 1.4, 0.55, rng, 0.1)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _hiss(rng: RandomNumberGenerator) -> PackedByteArray:
|
||||
var b := Synth.buffer(1.3)
|
||||
Synth.add_noise(b, 0.0, 1.2, 0.7, 3.2, 0.75, rng, 0.03)
|
||||
Synth.add_noise(b, 0.05, 1.0, 0.3, 2.4, 0.35, rng, 0.05)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _bell() -> PackedByteArray:
|
||||
var b := Synth.buffer(1.9)
|
||||
for k in 2:
|
||||
var t := k * 0.34
|
||||
Synth.add_tone(b, t, 987.77, 1.5, 0.34, 2.6, [1.0, 0.42, 0.18, 0.09], 0.004)
|
||||
Synth.add_tone(b, t, 1318.51, 1.4, 0.20, 3.0, [1.0, 0.3], 0.004)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _coin() -> PackedByteArray:
|
||||
var b := Synth.buffer(0.5)
|
||||
Synth.add_tone(b, 0.0, 1174.66, 0.3, 0.4, 11.0, [1.0, 0.4, 0.15], 0.003)
|
||||
Synth.add_tone(b, 0.07, 1567.98, 0.42, 0.4, 8.0, [1.0, 0.35, 0.12], 0.003)
|
||||
Synth.add_tone(b, 0.07, 2349.32, 0.35, 0.15, 10.0, [1.0], 0.003)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _pour(rng: RandomNumberGenerator) -> PackedByteArray:
|
||||
var b := Synth.buffer(0.95)
|
||||
Synth.add_noise(b, 0.0, 0.9, 0.4, 1.1, 0.42, rng, 0.08)
|
||||
for i in 9:
|
||||
var t := 0.1 + i * 0.085
|
||||
Synth.add_tone(b, t, rng.randf_range(700.0, 1500.0), 0.09, 0.1, 30.0, [1.0], 0.004)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _clink() -> PackedByteArray:
|
||||
var b := Synth.buffer(0.6)
|
||||
Synth.add_tone(b, 0.0, 2637.02, 0.5, 0.3, 7.0, [1.0, 0.3], 0.002)
|
||||
Synth.add_tone(b, 0.0, 3520.0, 0.4, 0.16, 9.0, [1.0], 0.002)
|
||||
Synth.add_tone(b, 0.11, 2793.83, 0.4, 0.2, 8.0, [1.0], 0.002)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _thud(rng: RandomNumberGenerator) -> PackedByteArray:
|
||||
var b := Synth.buffer(0.4)
|
||||
Synth.add_tone(b, 0.0, 68.0, 0.3, 0.65, 13.0, [1.0, 0.3], 0.003)
|
||||
Synth.add_noise(b, 0.0, 0.14, 0.3, 34.0, 0.28, rng)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
## Schritt: Ferse zuerst, dann das Abrollen. Ohne den tiefen Anteil
|
||||
## bleibt nur ein heller Tick übrig.
|
||||
static func _step(rng: RandomNumberGenerator) -> PackedByteArray:
|
||||
var b := Synth.buffer(0.34)
|
||||
Synth.add_tone(b, 0.0, 84.0, 0.26, 0.55, 15.0, [1.0, 0.28, 0.08], 0.003)
|
||||
Synth.add_tone(b, 0.0, 137.0, 0.16, 0.22, 26.0, [1.0], 0.003)
|
||||
Synth.add_noise(b, 0.0, 0.20, 0.26, 21.0, 0.12, rng, 0.003)
|
||||
Synth.add_noise(b, 0.035, 0.12, 0.10, 34.0, 0.42, rng, 0.012)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _click() -> PackedByteArray:
|
||||
var b := Synth.buffer(0.12)
|
||||
Synth.add_tone(b, 0.0, 1720.0, 0.06, 0.28, 60.0, [1.0, 0.4], 0.001)
|
||||
Synth.add_tone(b, 0.02, 1180.0, 0.07, 0.2, 45.0, [1.0], 0.001)
|
||||
return Synth.to_pcm(b)
|
||||
|
||||
static func _deny() -> PackedByteArray:
|
||||
var b := Synth.buffer(0.35)
|
||||
Synth.add_tone(b, 0.0, 320.0, 0.14, 0.3, 16.0, [1.0, 0.4], 0.004)
|
||||
Synth.add_tone(b, 0.12, 240.0, 0.2, 0.3, 13.0, [1.0, 0.4], 0.004)
|
||||
return Synth.to_pcm(b)
|
||||
1
scripts/sfx.gd.uid
Normal file
1
scripts/sfx.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bmk8l5ddth83t
|
||||
211
scripts/shop.gd
Normal file
211
scripts/shop.gd
Normal file
@@ -0,0 +1,211 @@
|
||||
extends CanvasLayer
|
||||
## Depot-Terminal: Loks, Waggons und Ausbauten kaufen oder verkaufen.
|
||||
|
||||
const Defs = preload("res://scripts/car_defs.gd")
|
||||
|
||||
signal closed()
|
||||
|
||||
var is_open := false
|
||||
|
||||
var _game
|
||||
var _root: Control
|
||||
var _tabs: TabContainer
|
||||
var _money := Label.new()
|
||||
|
||||
func setup(game) -> void:
|
||||
_game = game
|
||||
layer = 2
|
||||
_root = Control.new()
|
||||
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.visible = false
|
||||
add_child(_root)
|
||||
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0, 0, 0, 0.55)
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.add_child(dim)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
panel.custom_minimum_size = Vector2(760, 520)
|
||||
panel.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||
panel.grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = Color("1b2420")
|
||||
sb.set_corner_radius_all(14)
|
||||
sb.set_content_margin_all(18)
|
||||
sb.border_color = Color("3d5a4c")
|
||||
sb.set_border_width_all(2)
|
||||
panel.add_theme_stylebox_override("panel", sb)
|
||||
_root.add_child(panel)
|
||||
|
||||
var vb := VBoxContainer.new()
|
||||
vb.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(vb)
|
||||
|
||||
var head := HBoxContainer.new()
|
||||
var title := Label.new()
|
||||
title.text = "Depot-Terminal"
|
||||
title.add_theme_font_size_override("font_size", 26)
|
||||
head.add_child(title)
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
head.add_child(spacer)
|
||||
_money.add_theme_font_size_override("font_size", 22)
|
||||
_money.add_theme_color_override("font_color", Color("ffd978"))
|
||||
head.add_child(_money)
|
||||
vb.add_child(head)
|
||||
|
||||
var hint := Label.new()
|
||||
hint.text = "Esc schließt das Terminal."
|
||||
hint.add_theme_font_size_override("font_size", 14)
|
||||
hint.add_theme_color_override("font_color", Color(1, 1, 1, 0.6))
|
||||
vb.add_child(hint)
|
||||
|
||||
_tabs = TabContainer.new()
|
||||
_tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
vb.add_child(_tabs)
|
||||
|
||||
func open() -> void:
|
||||
is_open = true
|
||||
_root.visible = true
|
||||
_rebuild()
|
||||
|
||||
func close() -> void:
|
||||
is_open = false
|
||||
_root.visible = false
|
||||
closed.emit()
|
||||
|
||||
func refresh() -> void:
|
||||
_rebuild()
|
||||
|
||||
func _rebuild() -> void:
|
||||
_money.text = "%d € Ansehen %d" % [_game.money, _game.reputation]
|
||||
var tab := _tabs.current_tab
|
||||
for c in _tabs.get_children():
|
||||
c.queue_free()
|
||||
_tabs.add_child(_page("Loks", _loco_rows()))
|
||||
_tabs.add_child(_page("Waggons", _car_rows()))
|
||||
_tabs.add_child(_page("Mein Zug", _consist_rows()))
|
||||
_tabs.add_child(_page("Ausbauten", _upgrade_rows()))
|
||||
if tab >= 0 and tab < 4:
|
||||
_tabs.current_tab = tab
|
||||
|
||||
func _page(title: String, rows: Array) -> Control:
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.name = title
|
||||
var vb := VBoxContainer.new()
|
||||
vb.add_theme_constant_override("separation", 8)
|
||||
vb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.add_child(vb)
|
||||
for r in rows:
|
||||
vb.add_child(r)
|
||||
return scroll
|
||||
|
||||
func _row(title: String, desc: String, right_text: String, button_text: String,
|
||||
enabled: bool, on_press: Callable) -> Control:
|
||||
var pc := PanelContainer.new()
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = Color(1, 1, 1, 0.05)
|
||||
sb.set_corner_radius_all(8)
|
||||
sb.set_content_margin_all(10)
|
||||
pc.add_theme_stylebox_override("panel", sb)
|
||||
|
||||
var hb := HBoxContainer.new()
|
||||
hb.add_theme_constant_override("separation", 12)
|
||||
pc.add_child(hb)
|
||||
|
||||
var vb := VBoxContainer.new()
|
||||
vb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var t := Label.new()
|
||||
t.text = title
|
||||
t.add_theme_font_size_override("font_size", 19)
|
||||
vb.add_child(t)
|
||||
var d := Label.new()
|
||||
d.text = desc
|
||||
d.add_theme_font_size_override("font_size", 14)
|
||||
d.add_theme_color_override("font_color", Color(1, 1, 1, 0.65))
|
||||
vb.add_child(d)
|
||||
hb.add_child(vb)
|
||||
|
||||
var r := Label.new()
|
||||
r.text = right_text
|
||||
r.add_theme_font_size_override("font_size", 17)
|
||||
r.custom_minimum_size = Vector2(190, 0)
|
||||
r.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
hb.add_child(r)
|
||||
|
||||
var b := Button.new()
|
||||
b.text = button_text
|
||||
b.disabled = not enabled
|
||||
b.custom_minimum_size = Vector2(120, 40)
|
||||
b.pressed.connect(func():
|
||||
if _game.audio != null:
|
||||
_game.audio.play("click", -14.0)
|
||||
on_press.call())
|
||||
hb.add_child(b)
|
||||
return pc
|
||||
|
||||
func _lock_note(rep_needed: int) -> String:
|
||||
if _game.reputation >= rep_needed:
|
||||
return ""
|
||||
return " (ab Ansehen %d)" % rep_needed
|
||||
|
||||
func _loco_rows() -> Array:
|
||||
var rows: Array = []
|
||||
for id in Defs.LOCOS.keys():
|
||||
var d: Dictionary = Defs.LOCOS[id]
|
||||
var owned: bool = _game.train.loco_id == id
|
||||
var rep: int = int(d["rep"])
|
||||
var price: int = int(d["price"])
|
||||
var can: bool = not owned and _game.can_buy(price, rep)
|
||||
var right := "%d km/h %d €" % [int(float(d["max_speed"]) * 3.6), price]
|
||||
var name: String = String(d["name"]) + (_lock_note(rep) if not owned else " [im Einsatz]")
|
||||
rows.append(_row(name, String(d["desc"]), right,
|
||||
"Im Einsatz" if owned else "Kaufen", can,
|
||||
func(): _game.request("buy_loco", id); _rebuild()))
|
||||
return rows
|
||||
|
||||
func _car_rows() -> Array:
|
||||
var rows: Array = []
|
||||
for id in Defs.CARS.keys():
|
||||
var d: Dictionary = Defs.CARS[id]
|
||||
var rep: int = int(d["rep"])
|
||||
var price: int = int(d["price"])
|
||||
var can: bool = _game.can_buy(price, rep) and _game.train.consist.size() < 6
|
||||
rows.append(_row(String(d["name"]) + _lock_note(rep), String(d["desc"]),
|
||||
"%d €" % price, "Kaufen", can,
|
||||
func(): _game.request("buy_car", id); _rebuild()))
|
||||
return rows
|
||||
|
||||
func _consist_rows() -> Array:
|
||||
var rows: Array = []
|
||||
var d: Dictionary = Defs.LOCOS[_game.train.loco_id]
|
||||
rows.append(_row(String(d["name"]), "Lok - lässt sich nur ersetzen, nicht verkaufen",
|
||||
"", "", false, func(): pass))
|
||||
var list: Array = _game.train.consist
|
||||
for i in list.size():
|
||||
var cid: String = list[i]
|
||||
var cd: Dictionary = Defs.CARS[cid]
|
||||
var idx := i
|
||||
rows.append(_row("%d. %s" % [i + 1, String(cd["name"])], String(cd["desc"]),
|
||||
"Rücknahme %d €" % int(cd["sell"]), "Verkaufen", true,
|
||||
func(): _game.request("sell_car", "", idx); _rebuild()))
|
||||
if list.is_empty():
|
||||
rows.append(_row("Keine Waggons", "Kauf dir mindestens einen Wagen.", "", "", false,
|
||||
func(): pass))
|
||||
return rows
|
||||
|
||||
func _upgrade_rows() -> Array:
|
||||
var rows: Array = []
|
||||
for id in Defs.UPGRADES.keys():
|
||||
var d: Dictionary = Defs.UPGRADES[id]
|
||||
var owned: bool = _game.upgrades.has(id)
|
||||
var rep: int = int(d["rep"])
|
||||
var price: int = int(d["price"])
|
||||
var can: bool = not owned and _game.can_buy(price, rep)
|
||||
rows.append(_row(String(d["name"]) + (_lock_note(rep) if not owned else ""),
|
||||
String(d["desc"]), "" if owned else "%d €" % price,
|
||||
"Gekauft" if owned else "Kaufen", can,
|
||||
func(): _game.request("buy_upgrade", id); _rebuild()))
|
||||
return rows
|
||||
1
scripts/shop.gd.uid
Normal file
1
scripts/shop.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://sg33v3inblwx
|
||||
88
scripts/station.gd
Normal file
88
scripts/station.gd
Normal file
@@ -0,0 +1,88 @@
|
||||
extends Node3D
|
||||
## Bahnhof: Bahnsteig, Gebaeude, Warteplaetze, Warenlager.
|
||||
|
||||
const Mat = preload("res://scripts/mat.gd")
|
||||
const Deco = preload("res://scripts/deco.gd")
|
||||
const Cargo = preload("res://scripts/cargo.gd")
|
||||
|
||||
const PLATFORM_LEN := 44.0
|
||||
const PLATFORM_H := 0.95
|
||||
|
||||
var station_name := ""
|
||||
var progress := 0.0
|
||||
var side := 1.0
|
||||
var produces := -1
|
||||
var accepts: Array[int] = []
|
||||
var stock := 0.0
|
||||
var max_stock := 30.0
|
||||
var rate := 0.55
|
||||
var waiting_spots: Array[Vector3] = [] ## Weltpositionen auf dem Bahnsteig
|
||||
var waiting: Array = [] ## wartende Passagiere
|
||||
|
||||
func setup(nm: String, prog: float, prod: int, acc: Array[int]) -> void:
|
||||
station_name = nm
|
||||
progress = prog
|
||||
produces = prod
|
||||
accepts = acc
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
if produces >= 0:
|
||||
stock = minf(stock + rate * delta, max_stock)
|
||||
|
||||
func _box(parent: Node3D, center: Vector3, size: Vector3, color: Color) -> MeshInstance3D:
|
||||
var m := MeshInstance3D.new()
|
||||
m.mesh = Deco.box(size)
|
||||
m.material_override = Mat.flat(color)
|
||||
m.position = center
|
||||
parent.add_child(m)
|
||||
return m
|
||||
|
||||
func build_visuals(track_xf: Transform3D, s: float, wall: Color, roof: Color, chimney: bool) -> void:
|
||||
transform = track_xf
|
||||
side = s
|
||||
var edge := s * 2.4
|
||||
|
||||
# Bahnsteig
|
||||
_box(self, Vector3(edge + s * 1.6, PLATFORM_H * 0.5, 0),
|
||||
Vector3(3.2, PLATFORM_H, PLATFORM_LEN), Color("cfc3a8"))
|
||||
_box(self, Vector3(edge + s * 0.06, PLATFORM_H - 0.04, 0),
|
||||
Vector3(0.35, 0.1, PLATFORM_LEN), Color("e8e0cc"))
|
||||
|
||||
# Vordach
|
||||
for i in 3:
|
||||
var z := -PLATFORM_LEN * 0.22 + i * PLATFORM_LEN * 0.22
|
||||
_box(self, Vector3(edge + s * 4.1, PLATFORM_H + 1.75, z),
|
||||
Vector3(0.24, 3.5, 0.24), Color("6b5a45"))
|
||||
_box(self, Vector3(edge + s * 3.0, PLATFORM_H + 3.6, 0),
|
||||
Vector3(3.4, 0.22, PLATFORM_LEN * 0.5), roof)
|
||||
|
||||
# Empfangsgebaeude
|
||||
var h := Deco.house(wall, roof, 11.0, 8.0, 4.6, chimney)
|
||||
h.position = Vector3(edge + s * 11.5, 0.0, -PLATFORM_LEN * 0.18)
|
||||
h.rotate_y(PI * 0.5 * -s)
|
||||
add_child(h)
|
||||
|
||||
# Baenke
|
||||
for i in 3:
|
||||
var z := -PLATFORM_LEN * 0.25 + i * PLATFORM_LEN * 0.25
|
||||
_box(self, Vector3(edge + s * 2.5, PLATFORM_H + 0.28, z),
|
||||
Vector3(0.55, 0.12, 1.6), Color("8a6b4a"))
|
||||
_box(self, Vector3(edge + s * 2.75, PLATFORM_H + 0.6, z),
|
||||
Vector3(0.1, 0.55, 1.6), Color("8a6b4a"))
|
||||
|
||||
# Namensschild
|
||||
var label := Label3D.new()
|
||||
label.text = station_name
|
||||
label.font_size = 64
|
||||
label.pixel_size = 0.014
|
||||
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
label.outline_size = 18
|
||||
label.outline_modulate = Color(0, 0, 0, 0.7)
|
||||
label.position = Vector3(edge + s * 3.0, PLATFORM_H + 4.9, 0)
|
||||
add_child(label)
|
||||
|
||||
# Warteplaetze auf dem Bahnsteig
|
||||
for i in 10:
|
||||
var lz := -PLATFORM_LEN * 0.34 + i * PLATFORM_LEN * 0.075
|
||||
var lx := edge + s * (1.1 + (0.9 if i % 2 == 0 else 0.0))
|
||||
waiting_spots.append(to_global(Vector3(lx, PLATFORM_H, lz)))
|
||||
1
scripts/station.gd.uid
Normal file
1
scripts/station.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dcpyfw5eg0apq
|
||||
163
scripts/synth.gd
Normal file
163
scripts/synth.gd
Normal file
@@ -0,0 +1,163 @@
|
||||
extends RefCounted
|
||||
## Kleiner Synthesizer. Erzeugt PCM-Puffer und daraus AudioStreamWAV.
|
||||
## Alles wird beim Start berechnet, zur Laufzeit kostet es nichts mehr.
|
||||
|
||||
const RATE := 22050
|
||||
const TABLE_BITS := 11
|
||||
const TABLE_SIZE := 1 << TABLE_BITS
|
||||
const TABLE_MASK := TABLE_SIZE - 1
|
||||
|
||||
static var _sine: PackedFloat32Array
|
||||
|
||||
static func _table() -> PackedFloat32Array:
|
||||
if _sine.is_empty():
|
||||
_sine.resize(TABLE_SIZE)
|
||||
for i in TABLE_SIZE:
|
||||
_sine[i] = sin(TAU * float(i) / float(TABLE_SIZE))
|
||||
return _sine
|
||||
|
||||
## Sinus über eine Phase in Umdrehungen (0..1). Schneller als sin().
|
||||
static func osc(phase: float) -> float:
|
||||
var t := _table()
|
||||
return t[int(phase * TABLE_SIZE) & TABLE_MASK]
|
||||
|
||||
static func buffer(seconds: float) -> PackedFloat32Array:
|
||||
var b := PackedFloat32Array()
|
||||
b.resize(int(seconds * RATE))
|
||||
return b
|
||||
|
||||
## Ton mit Obertönen und exponentiellem Abfall.
|
||||
static func add_tone(buf: PackedFloat32Array, start: float, freq: float, dur: float,
|
||||
amp: float, decay: float, harmonics: Array = [1.0], attack := 0.005,
|
||||
vibrato := 0.0, vib_hz := 5.0) -> void:
|
||||
var i0 := int(start * RATE)
|
||||
var n := int(dur * RATE)
|
||||
var step := freq / float(RATE)
|
||||
for i in n:
|
||||
var idx := i0 + i
|
||||
if idx < 0 or idx >= buf.size():
|
||||
continue
|
||||
var t := float(i) / float(RATE)
|
||||
var env := exp(-t * decay)
|
||||
if t < attack:
|
||||
env *= t / attack
|
||||
var ph := float(i) * step
|
||||
if vibrato > 0.0:
|
||||
# Integrierte Phasenauslenkung. Die Phase selbst zu skalieren
|
||||
# würde die Tonhöhe über die Zeit davonlaufen lassen.
|
||||
ph += vibrato * freq / (TAU * vib_hz) * (1.0 + osc(t * vib_hz - 0.25))
|
||||
var v := 0.0
|
||||
var h := 1.0
|
||||
for a in harmonics:
|
||||
v += float(a) * osc(ph * h)
|
||||
h += 1.0
|
||||
buf[idx] += v * env * amp
|
||||
|
||||
## Weiches Pad: langsam an, langsam aus.
|
||||
static func add_pad(buf: PackedFloat32Array, start: float, freq: float, dur: float,
|
||||
amp: float, fade: float) -> void:
|
||||
var i0 := int(start * RATE)
|
||||
var n := int(dur * RATE)
|
||||
var step := freq / float(RATE)
|
||||
for i in n:
|
||||
var idx := i0 + i
|
||||
if idx < 0 or idx >= buf.size():
|
||||
continue
|
||||
var t := float(i) / float(RATE)
|
||||
var env: float = clampf(t / fade, 0.0, 1.0) * clampf((dur - t) / fade, 0.0, 1.0)
|
||||
env = env * env * (3.0 - 2.0 * env)
|
||||
var p := float(i) * step
|
||||
var v := osc(p) + 0.30 * osc(p * 2.0) + 0.09 * osc(p * 3.0)
|
||||
# Leichtes Schweben durch minimale Verstimmung
|
||||
v += 0.26 * osc(p * 1.003)
|
||||
buf[idx] += v * env * amp * 0.5
|
||||
|
||||
## Gefiltertes Rauschen mit Hüllkurve.
|
||||
static func add_noise(buf: PackedFloat32Array, start: float, dur: float, amp: float,
|
||||
decay: float, lp: float, rng: RandomNumberGenerator, attack := 0.002) -> void:
|
||||
var i0 := int(start * RATE)
|
||||
var n := int(dur * RATE)
|
||||
var y := 0.0
|
||||
for i in n:
|
||||
var idx := i0 + i
|
||||
if idx < 0 or idx >= buf.size():
|
||||
continue
|
||||
var t := float(i) / float(RATE)
|
||||
var env := exp(-t * decay)
|
||||
if t < attack:
|
||||
env *= t / attack
|
||||
y += (rng.randf_range(-1.0, 1.0) - y) * lp
|
||||
buf[idx] += y * env * amp
|
||||
|
||||
## Ton mit echtem Frequenzwobbeln. Die Phase wird aufsummiert, nicht
|
||||
## skaliert - nur so bleibt die Tonhöhe da, wo sie hingehört.
|
||||
static func add_wobble(buf: PackedFloat32Array, start: float, freq: float, dur: float,
|
||||
amp: float, depth: float, rate: float, harm2 := 0.0) -> void:
|
||||
var i0 := int(start * RATE)
|
||||
var n := int(dur * RATE)
|
||||
var ph := 0.0
|
||||
for i in n:
|
||||
var idx := i0 + i
|
||||
if idx < 0 or idx >= buf.size():
|
||||
continue
|
||||
var t := float(i) / float(RATE)
|
||||
ph += freq * (1.0 + depth * osc(t * rate)) / float(RATE)
|
||||
buf[idx] += (osc(ph) + harm2 * osc(ph * 2.0)) * amp
|
||||
|
||||
## Rauschen durch einen schmalen Resonanzfilter - klingt metallisch,
|
||||
## ohne die Härte eines reinen Sinus.
|
||||
static func add_resonant_noise(buf: PackedFloat32Array, start: float, dur: float,
|
||||
freq: float, r: float, amp: float, rng: RandomNumberGenerator,
|
||||
decay := 0.0) -> void:
|
||||
var i0 := int(start * RATE)
|
||||
var n := int(dur * RATE)
|
||||
var b1 := 2.0 * r * cos(TAU * freq / float(RATE))
|
||||
var b2 := -r * r
|
||||
var y1 := 0.0
|
||||
var y2 := 0.0
|
||||
var gain := (1.0 - r) * 1.6
|
||||
for i in n:
|
||||
var idx := i0 + i
|
||||
if idx < 0 or idx >= buf.size():
|
||||
continue
|
||||
var y := gain * rng.randf_range(-1.0, 1.0) + b1 * y1 + b2 * y2
|
||||
y2 = y1
|
||||
y1 = y
|
||||
var env := 1.0 if decay <= 0.0 else exp(-float(i) / float(RATE) * decay)
|
||||
buf[idx] += y * amp * env
|
||||
|
||||
## Legt den Ausklang auf den Anfang, damit die Schleife nahtlos wird.
|
||||
static func seamless(buf: PackedFloat32Array, loop_len: float) -> PackedFloat32Array:
|
||||
var n := int(loop_len * RATE)
|
||||
var tail: int = mini(buf.size() - n, n)
|
||||
var out := buf.slice(0, n)
|
||||
for i in tail:
|
||||
var w := 1.0 - float(i) / float(tail)
|
||||
out[i] += buf[n + i] * w
|
||||
return out
|
||||
|
||||
## Begrenzt sanft und wandelt in rohes PCM. Läuft auch in einem Thread.
|
||||
static func to_pcm(buf: PackedFloat32Array) -> PackedByteArray:
|
||||
var peak := 0.0
|
||||
for v in buf:
|
||||
peak = maxf(peak, absf(v))
|
||||
var gain: float = 0.92 / peak if peak > 0.001 else 1.0
|
||||
var data := PackedByteArray()
|
||||
data.resize(buf.size() * 2)
|
||||
for i in buf.size():
|
||||
var v: float = clampf(buf[i] * gain, -1.0, 1.0)
|
||||
data.encode_s16(i * 2, int(v * 32767.0))
|
||||
return data
|
||||
|
||||
## Baut den abspielbaren Stream. Nur im Hauptthread aufrufen.
|
||||
static func to_stream(pcm: PackedByteArray, loop := false) -> AudioStreamWAV:
|
||||
var w := AudioStreamWAV.new()
|
||||
w.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
w.mix_rate = RATE
|
||||
w.stereo = false
|
||||
w.data = pcm
|
||||
if loop:
|
||||
w.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
w.loop_begin = 0
|
||||
w.loop_end = pcm.size() / 2
|
||||
return w
|
||||
1
scripts/synth.gd.uid
Normal file
1
scripts/synth.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dkcl8lgogwxb2
|
||||
61
scripts/track.gd
Normal file
61
scripts/track.gd
Normal file
@@ -0,0 +1,61 @@
|
||||
extends Node3D
|
||||
## Baut aus 2D-Stuetzpunkten eine geschlossene Gleisschleife inkl. Optik.
|
||||
|
||||
const Mat = preload("res://scripts/mat.gd")
|
||||
const Deco = preload("res://scripts/deco.gd")
|
||||
|
||||
const BALLAST := Color("d98d4a")
|
||||
const SLEEPER := Color("5e4130")
|
||||
const RAIL := Color("9a9da2")
|
||||
const STEP := 1.2
|
||||
|
||||
var path: Path3D
|
||||
|
||||
func build(points: PackedVector2Array) -> void:
|
||||
path = Path3D.new()
|
||||
path.name = "TrackPath"
|
||||
var c := Curve3D.new()
|
||||
var n := points.size()
|
||||
# Catmull-Rom Tangenten -> weiche, geschlossene Schleife.
|
||||
for i in n + 1:
|
||||
var idx := i % n
|
||||
var prev := points[(idx - 1 + n) % n]
|
||||
var nxt := points[(idx + 1) % n]
|
||||
var t := (nxt - prev) / 6.0
|
||||
var p := points[idx]
|
||||
c.add_point(Vector3(p.x, 0, p.y), Vector3(-t.x, 0, -t.y), Vector3(t.x, 0, t.y))
|
||||
path.curve = c
|
||||
add_child(path)
|
||||
_build_visuals()
|
||||
|
||||
func length() -> float:
|
||||
return path.curve.get_baked_length()
|
||||
|
||||
func sample(offset: float) -> Transform3D:
|
||||
return path.curve.sample_baked_with_rotation(fposmod(offset, length()), true, true)
|
||||
|
||||
func _build_visuals() -> void:
|
||||
var count := int(length() / STEP)
|
||||
var xf: Array[Transform3D] = []
|
||||
for i in count:
|
||||
xf.append(sample(i * STEP))
|
||||
|
||||
_multi(Deco.box(Vector3(2.7, 0.16, STEP * 1.12)), BALLAST, xf, [Vector3(0, 0.08, 0)])
|
||||
_multi(Deco.box(Vector3(2.1, 0.09, 0.34)), SLEEPER, xf, [Vector3(0, 0.20, 0)])
|
||||
_multi(Deco.box(Vector3(0.09, 0.11, STEP * 1.08)), RAIL, xf,
|
||||
[Vector3(-0.62, 0.29, 0), Vector3(0.62, 0.29, 0)])
|
||||
|
||||
func _multi(mesh: Mesh, color: Color, xf: Array[Transform3D], offsets: Array) -> void:
|
||||
var mm := MultiMesh.new()
|
||||
mm.transform_format = MultiMesh.TRANSFORM_3D
|
||||
mm.mesh = mesh
|
||||
mm.instance_count = xf.size() * offsets.size()
|
||||
var i := 0
|
||||
for t in xf:
|
||||
for o in offsets:
|
||||
mm.set_instance_transform(i, t.translated_local(o))
|
||||
i += 1
|
||||
var mmi := MultiMeshInstance3D.new()
|
||||
mmi.multimesh = mm
|
||||
mmi.material_override = Mat.flat(color)
|
||||
add_child(mmi)
|
||||
1
scripts/track.gd.uid
Normal file
1
scripts/track.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c4v1pl34pv0wk
|
||||
244
scripts/train.gd
Normal file
244
scripts/train.gd
Normal file
@@ -0,0 +1,244 @@
|
||||
extends Node3D
|
||||
## Der Zugverband: Lok + Waggons auf der Strecke, Fahrphysik,
|
||||
## und die Abbildung "Laengsposition im Zug" -> Weltkoordinate.
|
||||
|
||||
const CarScene = preload("res://scripts/car.gd")
|
||||
const Defs = preload("res://scripts/car_defs.gd")
|
||||
const Cargo = preload("res://scripts/cargo.gd")
|
||||
|
||||
signal consist_changed()
|
||||
|
||||
const GAP := 0.75 ## Abstand zwischen zwei Waggons
|
||||
const DRAG := 0.6
|
||||
const DOCK_RANGE := 3.0
|
||||
|
||||
var loco_id := "steam"
|
||||
var consist: Array[String] = [] ## nur die Waggons, ohne Lok
|
||||
var cars: Array = [] ## Car-Knoten, Index 0 = Lok
|
||||
var followers: Array[PathFollow3D] = []
|
||||
|
||||
var progress := 0.0 ## Position der Zugspitze auf der Kurve
|
||||
var speed := 0.0
|
||||
var throttle := 0.0
|
||||
var braking := false
|
||||
var cargo: Dictionary = {} ## Cargo.Type -> int
|
||||
var stations: Array = []
|
||||
var docked_at: Node = null
|
||||
|
||||
var _track
|
||||
var _stand_time := 0.0
|
||||
|
||||
func setup(track, station_list: Array) -> void:
|
||||
_track = track
|
||||
stations = station_list
|
||||
|
||||
func max_speed() -> float:
|
||||
return float(Defs.LOCOS[loco_id]["max_speed"])
|
||||
|
||||
func accel() -> float:
|
||||
return float(Defs.LOCOS[loco_id]["accel"])
|
||||
|
||||
func brake_power() -> float:
|
||||
return float(Defs.LOCOS[loco_id]["brake"])
|
||||
|
||||
func speed_kmh() -> float:
|
||||
return speed * 3.6
|
||||
|
||||
func cargo_capacity() -> int:
|
||||
var n := 0
|
||||
for c in cars:
|
||||
n += c.capacity
|
||||
return n
|
||||
|
||||
func total_cargo() -> int:
|
||||
var n := 0
|
||||
for v in cargo.values():
|
||||
n += int(v)
|
||||
return n
|
||||
|
||||
func has_dining() -> bool:
|
||||
for c in cars:
|
||||
if c.kind == Defs.Kind.DINING:
|
||||
return true
|
||||
return false
|
||||
|
||||
func seat_count() -> int:
|
||||
var n := 0
|
||||
for c in cars:
|
||||
n += c.seats.size()
|
||||
return n
|
||||
|
||||
# --- Verband aufbauen -----------------------------------------------------
|
||||
|
||||
func rebuild() -> void:
|
||||
for f in followers:
|
||||
f.queue_free()
|
||||
followers.clear()
|
||||
cars.clear()
|
||||
|
||||
var ids: Array[String] = [loco_id]
|
||||
ids.append_array(consist)
|
||||
var s := 0.0
|
||||
for i in ids.size():
|
||||
var car = CarScene.new()
|
||||
car.build(ids[i])
|
||||
car.index = i
|
||||
car.center_s = s - car.length * 0.5
|
||||
s = car.center_s - car.length * 0.5 - GAP
|
||||
var f := PathFollow3D.new()
|
||||
f.loop = true
|
||||
f.rotation_mode = PathFollow3D.ROTATION_Y
|
||||
f.use_model_front = true
|
||||
f.cubic_interp = true
|
||||
f.add_child(car)
|
||||
_track.path.add_child(f)
|
||||
followers.append(f)
|
||||
cars.append(car)
|
||||
_sync_followers()
|
||||
refresh_cargo_visuals()
|
||||
consist_changed.emit()
|
||||
|
||||
func total_length() -> float:
|
||||
if cars.is_empty():
|
||||
return 0.0
|
||||
var last = cars[cars.size() - 1]
|
||||
return abs(last.center_s - last.length * 0.5)
|
||||
|
||||
## Begehbarer Bereich als [hinten, vorne] in Zug-Laengskoordinaten.
|
||||
func walk_range() -> Vector2:
|
||||
if cars.is_empty():
|
||||
return Vector2.ZERO
|
||||
var front = cars[0]
|
||||
var back = cars[cars.size() - 1]
|
||||
return Vector2(back.center_s + back.inner_z0 + 0.35, front.center_s + front.inner_z1 - 0.35)
|
||||
|
||||
## Findet den Waggon zu einer Laengsposition.
|
||||
func locate(s: float) -> Dictionary:
|
||||
var best := 0
|
||||
var best_d := INF
|
||||
for i in cars.size():
|
||||
var c = cars[i]
|
||||
var lo: float = c.center_s + c.inner_z0
|
||||
var hi: float = c.center_s + c.inner_z1
|
||||
if s >= lo and s <= hi:
|
||||
return {"index": i, "local_z": s - c.center_s, "gangway": false}
|
||||
var d: float = min(absf(s - lo), absf(s - hi))
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = i
|
||||
var c2 = cars[best]
|
||||
return {"index": best, "local_z": s - c2.center_s, "gangway": true}
|
||||
|
||||
func car_transform(i: int) -> Transform3D:
|
||||
return followers[i].global_transform
|
||||
|
||||
## Weltposition eines Punktes im Zug (x quer, y hoch, s laengs).
|
||||
func world_point(s: float, x: float, y: float) -> Transform3D:
|
||||
var loc := locate(s)
|
||||
var xf: Transform3D = car_transform(int(loc["index"]))
|
||||
return xf.translated_local(Vector3(x, y, float(loc["local_z"])))
|
||||
|
||||
func _sync_followers() -> void:
|
||||
var l: float = _track.length()
|
||||
for i in cars.size():
|
||||
followers[i].progress = fposmod(progress + cars[i].center_s, l)
|
||||
|
||||
# --- Ladung ---------------------------------------------------------------
|
||||
|
||||
func refresh_cargo_visuals() -> void:
|
||||
var units: Array[int] = []
|
||||
for t in cargo.keys():
|
||||
for i in int(cargo[t]):
|
||||
units.append(int(t))
|
||||
var idx := 0
|
||||
for c in cars:
|
||||
if c.capacity <= 0:
|
||||
continue
|
||||
var take: int = mini(c.capacity, units.size() - idx)
|
||||
if take <= 0:
|
||||
c.show_cargo(0, Color.WHITE)
|
||||
continue
|
||||
c.show_cargo(take, Cargo.COLORS[units[idx]])
|
||||
idx += take
|
||||
|
||||
func add_cargo(t: int, amount: int) -> int:
|
||||
var free: int = cargo_capacity() - total_cargo()
|
||||
var take: int = mini(free, amount)
|
||||
if take > 0:
|
||||
cargo[t] = int(cargo.get(t, 0)) + take
|
||||
refresh_cargo_visuals()
|
||||
return take
|
||||
|
||||
func remove_cargo(t: int, amount: int) -> int:
|
||||
var have: int = int(cargo.get(t, 0))
|
||||
var take: int = mini(have, amount)
|
||||
if take > 0:
|
||||
cargo[t] = have - take
|
||||
if cargo[t] <= 0:
|
||||
cargo.erase(t)
|
||||
refresh_cargo_visuals()
|
||||
return take
|
||||
|
||||
# --- Fahrt ----------------------------------------------------------------
|
||||
|
||||
func drive(delta: float) -> void:
|
||||
if braking:
|
||||
speed = maxf(speed - brake_power() * delta, 0.0)
|
||||
elif throttle > 0.0:
|
||||
speed = minf(speed + accel() * throttle * delta, max_speed())
|
||||
else:
|
||||
speed = maxf(speed - DRAG * delta, 0.0)
|
||||
progress = fposmod(progress + speed * delta, _track.length())
|
||||
_sync_followers()
|
||||
_update_dock(delta)
|
||||
|
||||
## Nur Anzeige aktualisieren (Clients im Netzspiel).
|
||||
func apply_snapshot(p: float, v: float) -> void:
|
||||
progress = p
|
||||
speed = v
|
||||
_sync_followers()
|
||||
_update_dock(0.0)
|
||||
|
||||
func _update_dock(delta: float) -> void:
|
||||
var near := nearest_station()
|
||||
var st: Node = near[0]
|
||||
var dist: float = near[1]
|
||||
if st == null:
|
||||
return
|
||||
if speed > 0.15 or dist > DOCK_RANGE:
|
||||
_stand_time = 0.0
|
||||
docked_at = null
|
||||
return
|
||||
_stand_time += delta
|
||||
docked_at = st
|
||||
|
||||
## Abstand eines Bahnhofs zum Zug. 0, solange der Bahnsteig neben dem
|
||||
## Zug liegt - sonst waere ein langer Zug nie richtig am Bahnsteig.
|
||||
func station_gap(st) -> float:
|
||||
var l: float = _track.length()
|
||||
var behind: float = fposmod(progress - st.progress, l)
|
||||
var len_train: float = total_length()
|
||||
if behind <= len_train:
|
||||
return 0.0
|
||||
return minf(behind - len_train, l - behind)
|
||||
|
||||
func nearest_station() -> Array:
|
||||
var best: Node = null
|
||||
var best_d := INF
|
||||
for s in stations:
|
||||
var d: float = station_gap(s)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = s
|
||||
return [best, best_d]
|
||||
|
||||
func next_station() -> Array:
|
||||
var best: Node = null
|
||||
var best_d := INF
|
||||
var l: float = _track.length()
|
||||
for s in stations:
|
||||
var d: float = fposmod(s.progress - progress, l)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = s
|
||||
return [best, best_d]
|
||||
1
scripts/train.gd.uid
Normal file
1
scripts/train.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bs0xp741xc3pg
|
||||
113
scripts/world.gd
Normal file
113
scripts/world.gd
Normal file
@@ -0,0 +1,113 @@
|
||||
extends Node3D
|
||||
## Gelaende, Vegetation und Umgebung. Baeume laufen ueber MultiMesh,
|
||||
## damit auch mehrere hundert Stueck guenstig bleiben.
|
||||
|
||||
const Mat = preload("res://scripts/mat.gd")
|
||||
const Deco = preload("res://scripts/deco.gd")
|
||||
|
||||
const GRASS := Color("7cb85a")
|
||||
const GRASS_DARK := Color("74b054")
|
||||
const WATER := Color("4f9fd6")
|
||||
const SAND := Color("e0c98a")
|
||||
const EXTENT := 360.0
|
||||
|
||||
var _rng: RandomNumberGenerator
|
||||
|
||||
func build(rng: RandomNumberGenerator, track, stations: Array) -> void:
|
||||
_rng = rng
|
||||
_ground()
|
||||
_scatter(track, stations)
|
||||
|
||||
func _flat_disc(pos: Vector3, radius: float, color: Color, height := 0.06) -> void:
|
||||
var m := MeshInstance3D.new()
|
||||
m.mesh = Deco.cyl(radius, height, 24)
|
||||
m.material_override = Mat.flat(color)
|
||||
m.position = pos
|
||||
add_child(m)
|
||||
|
||||
func _ground() -> void:
|
||||
var plane := PlaneMesh.new()
|
||||
plane.size = Vector2(1000, 1000)
|
||||
var g := MeshInstance3D.new()
|
||||
g.mesh = plane
|
||||
g.material_override = Mat.flat(GRASS)
|
||||
add_child(g)
|
||||
|
||||
_flat_disc(Vector3(-230, 0.02, 150), 72.0, SAND, 0.05)
|
||||
_flat_disc(Vector3(-230, 0.04, 150), 60.0, WATER, 0.05)
|
||||
for i in 18:
|
||||
var p := Vector2(_rng.randf_range(-EXTENT, EXTENT), _rng.randf_range(-EXTENT, EXTENT))
|
||||
_flat_disc(Vector3(p.x, 0.01, p.y), _rng.randf_range(18.0, 44.0), GRASS_DARK, 0.03)
|
||||
|
||||
func _multi(mesh: Mesh, items: Array) -> void:
|
||||
if items.is_empty():
|
||||
return
|
||||
var mm := MultiMesh.new()
|
||||
mm.transform_format = MultiMesh.TRANSFORM_3D
|
||||
mm.use_colors = true
|
||||
mm.mesh = mesh
|
||||
mm.instance_count = items.size()
|
||||
for i in items.size():
|
||||
var it: Dictionary = items[i]
|
||||
mm.set_instance_transform(i, it["xf"])
|
||||
# Instanzfarben gehen roh in den Shader, deshalb hier nach Linear wandeln.
|
||||
mm.set_instance_color(i, (it["color"] as Color).srgb_to_linear())
|
||||
var mmi := MultiMeshInstance3D.new()
|
||||
mmi.multimesh = mm
|
||||
mmi.material_override = Mat.flat(Color.WHITE)
|
||||
add_child(mmi)
|
||||
|
||||
func _scatter(track, stations: Array) -> void:
|
||||
var trunks: Array = []
|
||||
var cones: Array = []
|
||||
var blobs: Array = []
|
||||
var rocks: Array = []
|
||||
|
||||
var placed := 0
|
||||
var tries := 0
|
||||
while placed < 620 and tries < 12000:
|
||||
tries += 1
|
||||
var p := Vector2(_rng.randf_range(-EXTENT, EXTENT), _rng.randf_range(-EXTENT, EXTENT))
|
||||
var closest: Vector3 = track.path.curve.get_closest_point(Vector3(p.x, 0, p.y))
|
||||
if Vector2(closest.x, closest.z).distance_to(p) < 11.0:
|
||||
continue
|
||||
if p.distance_to(Vector2(-230, 150)) < 78.0:
|
||||
continue
|
||||
var blocked := false
|
||||
for st in stations:
|
||||
if p.distance_to(Vector2(st.global_position.x, st.global_position.z)) < 30.0:
|
||||
blocked = true
|
||||
break
|
||||
if blocked:
|
||||
continue
|
||||
placed += 1
|
||||
var yaw := _rng.randf() * TAU
|
||||
if _rng.randf() < 0.6:
|
||||
var h := _rng.randf_range(4.0, 8.5)
|
||||
var col: Color = Deco.PINE[_rng.randi() % Deco.PINE.size()]
|
||||
trunks.append(_item(p, h * 0.16, Vector3(1, h * 0.3, 1), yaw, Deco.TRUNK))
|
||||
var tiers := 3 if h > 6.0 else 2
|
||||
for i in tiers:
|
||||
var f := float(i) / float(tiers)
|
||||
var r := lerpf(h * 0.3, h * 0.14, f)
|
||||
var th := h * 0.44
|
||||
var y := h * 0.26 + f * h * 0.32 + th * 0.5
|
||||
cones.append(_item(p, y, Vector3(r, th, r), yaw, col))
|
||||
elif _rng.randf() < 0.85:
|
||||
var h := _rng.randf_range(3.2, 6.0)
|
||||
var col: Color = Deco.LEAF[_rng.randi() % Deco.LEAF.size()]
|
||||
trunks.append(_item(p, h * 0.28, Vector3(0.9, h * 0.55, 0.9), yaw, Deco.TRUNK))
|
||||
var r := h * 0.44
|
||||
blobs.append(_item(p, h * 0.55 + r * 0.5, Vector3(r, r, r), yaw, col))
|
||||
else:
|
||||
var r := _rng.randf_range(0.7, 2.0)
|
||||
rocks.append(_item(p, r * 0.35, Vector3(r, r * 0.55, r * 1.1), yaw, Deco.ROCK))
|
||||
|
||||
_multi(Deco.cyl(0.18, 1.0, 6), trunks)
|
||||
_multi(Deco.cone(1.0, 1.0, 7), cones)
|
||||
_multi(Deco.blob(1.0), blobs)
|
||||
_multi(Deco.blob(1.0), rocks)
|
||||
|
||||
func _item(p: Vector2, y: float, scale: Vector3, yaw: float, color: Color) -> Dictionary:
|
||||
var b := Basis(Vector3.UP, yaw).scaled(scale)
|
||||
return {"xf": Transform3D(b, Vector3(p.x, y, p.y)), "color": color}
|
||||
1
scripts/world.gd.uid
Normal file
1
scripts/world.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://br0aw8liqwhyj
|
||||
Reference in New Issue
Block a user