extends Node3D class_name GameTable ## Baut den 3D-Tisch mit Würfelzonen, Bechern, Licht und Kamera. const TABLE_H := 0.95 const ZONE_RAD := 0.68 ## Radius der runden Würfelzone const WALL_SEGS := 20 ## Bandensegmente - Godot kennt keinen hohlen Zylinder const WALL_H := 0.55 ## unsichtbare Begrenzung, damit nichts herausspringt const SEAT_ARC := ZONE_RAD * 2.0 + 0.85 ## Bogenlänge, die ein Platz am Tischrand braucht const MIN_ZONE_R := 1.15 const Stoffe = preload("res://scripts/stoffe.gd") ## Materialien aus Regeln, ohne Dateien const Formen = preload("res://scripts/formen.gd") ## Becher, Fass, Laterne aus Primitiven const RIM_RUHE := Color(0.22, 0.15, 0.09) ## Zonenrand, wenn der Platz wartet const RIM_ZUG := Color(0.85, 0.66, 0.22) ## Zonenrand des Platzes, der dran ist const TILT_DEG := 45.0 ## Neigung der Kamera gegen die Senkrechte const HUD_TOP := 0.13 ## Bildanteil oben für die Gebotsanzeige const HUD_BOTTOM := 0.17 ## Bildanteil unten für die Ansage-Leiste ## Tisch und Zonenradius wachsen mit der Spielerzahl. var table_r := 2.0 var zone_r := 1.2 var seat_count := 0 var local_seat := 0 var seat_dir: Array[Vector3] = [] var zones: Array[Node3D] = [] var cups: Array[Node3D] = [] var labels: Array[Label3D] = [] var cup_tweens: Array = [] var bid_counts: Array[Label3D] = [] var bid_icons: Array[Sprite3D] = [] var _bid_anchor: Array[Vector3] = [] var plates: Array[Sprite3D] = [] var rims: Array[MeshInstance3D] = [] ## Rand der Zone, leuchtet beim Zug var topf_muenzen: Dictionary = {} ## Wurfnummer -> Münze in der Tischmitte static var _muenz_mesh: ArrayMesh static var _muenz_mat: StandardMaterial3D var dice: Array = [] ## seat -> Array[Die] var camera: Camera3D func build(count: int, local_index: int) -> void: seat_count = count local_seat = local_index zone_r = maxf(MIN_ZONE_R, count * SEAT_ARC / TAU) table_r = zone_r + ZONE_RAD + 0.45 _build_room() seat_dir.clear() for i in count: var ang := -PI * 0.5 + float(i - local_index) * TAU / float(count) seat_dir.append(Vector3(cos(ang), 0.0, sin(ang))) for i in count: _build_seat(i) dice.append([]) _deko() _build_camera() ## Eine Laterne am Tischrand - weit genug draussen, dass sie keine Zone ## verdeckt, nah genug, dass ihr Schein den Filz erreicht. Das Fass aus den ## Formregeln bleibt weg: vom Boden aus ist es aus Kamerahöhe nur ein Fleck. func _deko() -> void: var laterne := Formen.laterne() laterne.position = Vector3(-(table_r + 0.45), 0.0, table_r * 0.55) # steht auf der Platte add_child(laterne) func _build_room() -> void: # Low-Poly-Look: heller Himmel, eine Sonne mit weichen Schatten, viel # Umgebungslicht. Kein Nebel, kein Glow - die Formen sollen klar stehen. var env := WorldEnvironment.new() var e := Environment.new() e.background_mode = Environment.BG_COLOR e.background_color = Color(0.56, 0.74, 0.88) e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR e.ambient_light_color = Color(0.70, 0.78, 0.92) e.ambient_light_energy = 0.75 e.tonemap_mode = Environment.TONE_MAPPER_FILMIC e.tonemap_exposure = 0.9 env.environment = e add_child(env) var sonne := DirectionalLight3D.new() sonne.light_color = Color(1.0, 0.96, 0.90) sonne.light_energy = 1.1 sonne.shadow_enabled = true sonne.light_angular_distance = 3.0 # weiche Schattenkanten sonne.directional_shadow_mode = DirectionalLight3D.SHADOW_ORTHOGONAL sonne.directional_shadow_max_distance = 30.0 sonne.rotation_degrees = Vector3(-52.0, 35.0, 0.0) add_child(sonne) # Sockelplatte wie ein Diorama: eine runde Scheibe mit weicher Kante, # darunter nur Himmel. var platte := MeshInstance3D.new() # Klein genug, dass an den Rändern Himmel bleibt - sonst ist alles Platte. platte.mesh = Formen.scheibe(table_r * 1.55, 0.18, 0.08) platte.position = Vector3(0, -0.18, 0) platte.material_override = Stoffe.boden() add_child(platte) var boden := StaticBody3D.new() var bc := CollisionShape3D.new() var bs := BoxShape3D.new() bs.size = Vector3(40, 0.2, 40) bc.shape = bs bc.position = Vector3(0, -0.1, 0) boden.add_child(bc) add_child(boden) # Tischplatte: Holz mit runder Kante, darauf die Filzeinlage. var tisch := MeshInstance3D.new() tisch.mesh = Formen.scheibe(table_r, 0.10, 0.045) tisch.position = Vector3(0, TABLE_H - 0.10, 0) tisch.material_override = Stoffe.holz() add_child(tisch) var einlage := MeshInstance3D.new() einlage.mesh = Formen.scheibe(table_r - 0.16, 0.025, 0.012) einlage.position = Vector3(0, TABLE_H - 0.012, 0) einlage.material_override = Stoffe.filz() add_child(einlage) var tcol := StaticBody3D.new() var tc := CollisionShape3D.new() var tcs := CylinderShape3D.new() tcs.radius = table_r tcs.height = 0.12 tc.shape = tcs # Oberkante auf Höhe des Zonenfilzes, damit Würfel darauf liegen, nicht darin. tc.position = Vector3(0, TABLE_H - 0.048, 0) tcol.add_child(tc) add_child(tcol) # Ein dicker Mittelfuss statt vier Beinen - aus Kamerahöhe sieht man ohnehin # nur den Ansatz, und rund passt zur Knete. var fuss := MeshInstance3D.new() fuss.mesh = Formen.rund_zylinder(0.55, 0.35, TABLE_H - 0.10, 0.06) fuss.material_override = Stoffe.holz() add_child(fuss) func _build_seat(i: int) -> void: var dir: Vector3 = seat_dir[i] var zone := Node3D.new() zone.position = dir * zone_r + Vector3(0, TABLE_H, 0) zone.rotation.y = -atan2(dir.z, dir.x) add_child(zone) zones.append(zone) # Runde Bande aus Segmenten - ein hohler Zylinder ist als Kollisionsform # nicht vorgesehen. Nur Kollision, kein Mesh: der sichtbare Rand ist ein Torus. var walls := StaticBody3D.new() zone.add_child(walls) var seg_w := TAU * ZONE_RAD / WALL_SEGS * 1.25 # Überlappung, damit keine Lücke bleibt for k in WALL_SEGS: var ang := TAU * k / WALL_SEGS var c := CollisionShape3D.new() var b := BoxShape3D.new() b.size = Vector3(0.12, WALL_H, seg_w) c.shape = b c.position = Vector3(cos(ang), 0.0, sin(ang)) * (ZONE_RAD + 0.06) \ + Vector3(0, WALL_H * 0.5, 0) c.rotation.y = -ang walls.add_child(c) # Filzfläche var felt := MeshInstance3D.new() felt.mesh = Formen.scheibe(ZONE_RAD + 0.02, 0.012, 0.006) felt.position = Vector3(0, 0.0, 0) felt.material_override = Stoffe.filz_dunkel() zone.add_child(felt) # Sichtbarer Rand var rim := MeshInstance3D.new() rim.mesh = Formen.ring(ZONE_RAD + 0.045, 0.035) rim.position = Vector3(0, 0.02, 0) rim.material_override = Stoffe.rand() zone.add_child(rim) rims.append(rim) # Becher var cup := Node3D.new() cup.position = Vector3(0, 0.02, 0) zone.add_child(cup) cup.add_child(Formen.becher()) cups.append(cup) cup_tweens.append(null) # Namensschild var lbl := Label3D.new() lbl.position = dir * zone_r + Vector3(0, TABLE_H + 0.52, 0) # oben auf dem Becher lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED # Keine feste Bildschirmgrösse: so schrumpft der Name mit dem Becher, wenn # der Tisch bei mehr Spielern wächst und die Kamera zurückweicht. lbl.font_size = 64 lbl.pixel_size = 0.0023 lbl.outline_size = 12 lbl.no_depth_test = true lbl.text = "" lbl.visible = i != local_seat # den eigenen Namen kennt man add_child(lbl) labels.append(lbl) # Unterlage hinter Name und Ansage _build_plate_texture() var pl := Sprite3D.new() pl.texture = _plate_tex pl.billboard = BaseMaterial3D.BILLBOARD_ENABLED pl.no_depth_test = true pl.pixel_size = 1.0 / 128.0 # Grundgrösse 1 x 1 Welteinheit pl.render_priority = -1 # immer hinter Schrift und Würfelbild pl.visible = i != local_seat add_child(pl) plates.append(pl) # Ansagezeile darunter: Anzahl als Text, Augenzahl als Würfelbild _build_face_textures() var unten := lbl.position + Vector3(0, -0.001, 0) var cnt := Label3D.new() cnt.billboard = BaseMaterial3D.BILLBOARD_ENABLED cnt.font_size = 64 cnt.pixel_size = 0.0019 cnt.outline_size = 12 cnt.no_depth_test = true cnt.modulate = Color(1.0, 0.88, 0.52) cnt.visible = false add_child(cnt) bid_counts.append(cnt) var ic := Sprite3D.new() ic.billboard = BaseMaterial3D.BILLBOARD_ENABLED ic.no_depth_test = true ic.pixel_size = 0.0016 ic.texture = _face_tex[0] ic.visible = false add_child(ic) bid_icons.append(ic) _bid_anchor.append(unten) ## Fast senkrechte Aufsicht mit leichter Neigung, damit die Würfel noch ## plastisch wirken. Der eigene Platz liegt unten im Bild. func _build_camera() -> void: camera = Camera3D.new() camera.fov = 58.0 camera.current = true add_child(camera) _frame_camera() get_viewport().size_changed.connect(_frame_camera) func _frame_camera() -> void: var dir: Vector3 = seat_dir[local_seat] var fov_v := deg_to_rad(camera.fov) var tilt := deg_to_rad(TILT_DEG) var vp := get_viewport().get_visible_rect().size var aspect := 16.0 / 9.0 if vp.y <= 0.0 else vp.x / vp.y # Der Tisch füllt das freie Band zwischen den HUD-Feldern aus. Geneigt # betrachtet wird er zur Ellipse: senkrecht kürzer, waagerecht unverändert. var band := 1.0 - HUD_TOP - HUD_BOTTOM var half_v := tan(fov_v * 0.5) var dist := (table_r * cos(tilt)) / (band * half_v) # Geneigt wächst die nahe Tischhälfte durch die Perspektive; je schräger, # desto mehr Abstand braucht es, damit die eigene Zone nicht unter der # Ansageleiste verschwindet. dist *= 1.0 + 0.38 * sin(tilt) # Waagerecht ist normalerweise reichlich Platz, in schmalen Fenstern nicht. dist = maxf(dist, table_r / (0.98 * tan(fov_v * 0.5) * aspect)) # Das freie Band liegt nicht mittig, der Tisch rückt entsprechend nach oben. var shift := (HUD_BOTTOM - HUD_TOP) * half_v * dist var target := dir * shift + Vector3(0, TABLE_H, 0) var pos := target + (Vector3.UP * cos(tilt) + dir * sin(tilt)) * dist camera.look_at_from_position(pos, target, Vector3.UP) _place_bid_lines() ## Setzt Name, Ansage und Unterlage eines Platzes. Alles wird entlang der ## Bildachsen ausgerichtet, sonst stünde bei der Aufsicht eines hinter dem ## anderen statt darunter. func _layout_seat(i: int) -> void: if camera == null or i >= plates.size(): return var rechts: Vector3 = camera.global_transform.basis.x var hoch: Vector3 = camera.global_transform.basis.y var p: Vector3 = _bid_anchor[i] var zeigt_ansage: bool = bid_icons[i].visible var name_w := _label_width(labels[i]) labels[i].position = p + hoch * (0.10 if zeigt_ansage else 0.0) var icon_w: float = 96.0 * bid_icons[i].pixel_size var cnt_w := _label_width(bid_counts[i]) var zeile_w := cnt_w + 0.03 + icon_w var mitte: Vector3 = p - hoch * 0.10 bid_counts[i].position = mitte - rechts * (zeile_w - cnt_w) * 0.5 bid_icons[i].position = mitte + rechts * (zeile_w - icon_w) * 0.5 var breite := maxf(name_w, zeile_w if zeigt_ansage else 0.0) + 0.14 var hoehe := 0.40 if zeigt_ansage else 0.22 plates[i].scale = Vector3(breite, hoehe, 1.0) plates[i].position = p plates[i].visible = labels[i].visible or zeigt_ansage func _label_width(l: Label3D) -> float: if l.text == "": return 0.0 var f := ThemeDB.fallback_font return f.get_string_size(l.text, HORIZONTAL_ALIGNMENT_LEFT, -1, l.font_size).x * l.pixel_size func _place_bid_lines() -> void: for i in plates.size(): _layout_seat(i) ## Würfelbilder für die Ansagen am Tisch. Einmal gezeichnet, von allen genutzt. static var _face_tex: Array[ImageTexture] = [] static var _plate_tex: ImageTexture ## Fast deckende, abgerundete Unterlage. Halbtransparent ginge sie auf dem ## dunkelroten Becher unter. static func _build_plate_texture() -> void: if _plate_tex != null: return const S := 128 var img := Image.create(S, S, false, Image.FORMAT_RGBA8) img.fill(Color(0, 0, 0, 0)) var r := S * 0.22 for y in S: for x in S: var dx := maxf(maxf(r - x, x - (S - r)), 0.0) var dy := maxf(maxf(r - y, y - (S - r)), 0.0) if Vector2(dx, dy).length() <= r: img.set_pixel(x, y, Color(0.04, 0.05, 0.07, 0.90)) _plate_tex = ImageTexture.create_from_image(img) static func _build_face_textures() -> void: if not _face_tex.is_empty(): return const S := 192 # doppelt gezeichnet und verkleinert = weiche Kanten const OUT := 96 for v in range(1, 7): var img := Image.create(S, S, false, Image.FORMAT_RGBA8) img.fill(Color(0, 0, 0, 0)) var r := S * 0.18 for y in S: for x in S: var dx := maxf(maxf(r - x, x - (S - r)), 0.0) var dy := maxf(maxf(r - y, y - (S - r)), 0.0) if Vector2(dx, dy).length() <= r: img.set_pixel(x, y, Color(0.95, 0.94, 0.90)) var mid := S * 0.5 var spread := S * 0.24 var pr := S * 0.105 for p: Vector2 in Die.PIPS[v]: var cx := mid + p.x * spread var cy := mid + p.y * spread for y in S: for x in S: if Vector2(x + 0.5 - cx, y + 0.5 - cy).length() <= pr: img.set_pixel(x, y, Color(0.09, 0.08, 0.1)) img.resize(OUT, OUT, Image.INTERPOLATE_LANCZOS) _face_tex.append(ImageTexture.create_from_image(img)) ## Zeigt unter dem Namen, was der Platz zuletzt angesagt hat (0 = nichts). func set_bid(seat: int, count: int, face: int) -> void: if seat >= bid_counts.size(): return # Am eigenen Platz steht kein Name, dort läge die Zeile in den Würfeln. var zeige := seat != local_seat and count > 0 and face >= 1 and face <= 6 bid_counts[seat].visible = zeige bid_icons[seat].visible = zeige if zeige: bid_counts[seat].text = "%d ×" % count bid_icons[seat].texture = _face_tex[face - 1] _layout_seat(seat) func _mat(col: Color, rough: float) -> StandardMaterial3D: var m := StandardMaterial3D.new() m.albedo_color = col m.roughness = rough return m ## --- Würfel --- ## Nur die neu dazugekommenen Würfel werden hingelegt. Die schon liegenden ## bleiben, wie sie sind - ein Nachschub mitten in der Runde würde ihnen sonst ## die Drehung nehmen und damit auf dem Tisch die Augenzahl. func set_dice_count(seat: int, count: int) -> void: var arr: Array = dice[seat] while arr.size() > count: var d = arr.pop_back() d.queue_free() # Ein neuer Würfel darf nicht sichtbar sein, wenn die anderen unterm # Becher liegen - sonst verrät er die Hand des Gegners. var sichtbar: bool = arr.is_empty() or arr[0].visible var ab := arr.size() while arr.size() < count: var d := Die.new() add_child(d) d.global_position = zones[seat].global_position + Vector3(0, 0.4, 0) d.visible = sichtbar arr.append(d) if arr.size() > ab: _layout_idle(seat, ab) func dice_of(seat: int) -> Array: return dice[seat] func throw_dice(seat: int, rng: RandomNumberGenerator) -> void: var zone: Node3D = zones[seat] var arr: Array = dice[seat] var n := arr.size() for i in n: # gleichmässig auf einem Kreis verteilt, damit nichts übereinander fällt var ang := TAU * i / maxi(n, 1) + rng.randf_range(-0.15, 0.15) var rad := ZONE_RAD * rng.randf_range(0.30, 0.46) var off := Vector3( cos(ang) * rad, 0.48 + rng.randf_range(0.0, 0.10), sin(ang) * rad ) arr[i].throw_from(zone.global_transform * off, rng) ## Fangnetz: ein Würfel, der über die Bande oder durch den Filz gefallen ist, ## fiele sonst ewig - und das Würfeln käme nie zur Ruhe. Er kommt zurück in ## seine Zone und fällt dort noch einmal. Gibt zurück, ob einer gefangen wurde. func fange_verirrte(seat: int) -> bool: var zone: Node3D = zones[seat] var gefangen := false for d in dice[seat]: if d.global_position.y > TABLE_H - 0.3 and d.global_position.y < TABLE_H + 3.0: continue var ang := randf() * TAU var off := Vector3(cos(ang), 0.0, sin(ang)) * ZONE_RAD * 0.3 + Vector3(0, 0.35, 0) d.freeze = false d.sleeping = false d.linear_velocity = Vector3.ZERO d.angular_velocity = Vector3.ZERO var t := Transform3D(Basis(), zone.global_transform * off) d.place(t) gefangen = true return gefangen ## Legt die Würfel ordentlich nebeneinander (für Clients/Reveal). Ab `von` ## bleiben die davorliegenden unangetastet. func _layout_idle(seat: int, von := 0) -> void: var zone: Node3D = zones[seat] var arr: Array = dice[seat] # Bogen auf einem Kreis um die Zonenmitte - passt zur runden Bande. # Der Schrittwinkel folgt aus dem gewünschten Abstand: Diagonale eines # Würfels plus Luft, sonst überlappen die Bilder beim Aufdecken. const BOGEN_R := 0.48 # etwas weiter aussen, damit das Namensschild frei bleibt var schritt: float = (Die.SIZE * 1.7) / BOGEN_R for i in range(von, arr.size()): var ang := PI + (float(i) - (arr.size() - 1) * 0.5) * schritt var t := zone.global_transform t.origin = zone.global_transform * Vector3( cos(ang) * BOGEN_R, Die.HALF + 0.01, sin(ang) * BOGEN_R) arr[i].set_kinematic(true) arr[i].place(t) func arrange_and_show(seat: int, values: Array, rng: RandomNumberGenerator) -> void: _layout_idle(seat) var arr: Array = dice[seat] for i in mini(arr.size(), values.size()): arr[i].snap_to_value(values[i], rng) set_dice_visible(seat, true) ## Friert alle Würfel ein, damit gesetzte Werte liegen bleiben. func freeze_dice(seat: int) -> void: for d in dice[seat]: d.set_kinematic(true) ## Der Topf liegt als Häufchen Messing in der Tischmitte. Jede Münze trägt ## ihre Wurfnummer; was nicht mehr in der Liste steht, ist beim Gewinner - ## dorthin fliegt sie dann auch (`ziel`), sonst verschwindet sie einfach. ## Lage und Drehung kommen aus der Nummer, damit Host und Clients dasselbe ## Häufchen sehen. Wappen-Münzen tragen einen geprägten Ring und leuchten ## stärker: so sieht man den Wurfausgang auf dem Tisch, nicht nur im Text. func set_topf(liste: Array, ziel := -1) -> void: var bleibt := {} for m in liste: var nr := int(m.get("nr", 0)) bleibt[nr] = true if topf_muenzen.has(nr): continue var r := RandomNumberGenerator.new() r.seed = nr * 7919 var ang := r.randf() * TAU var rad := r.randf_range(0.30, 0.55) var wappen: bool = m.get("stark", false) var lage := Vector3(cos(ang) * rad, TABLE_H + 0.013, sin(ang) * rad) # auf der Einlage var mi := MeshInstance3D.new() mi.mesh = _muenz_form() mi.material_override = _muenz_stoff(wappen) mi.position = lage + Vector3(0, 0.45, 0) mi.rotation.y = r.randf() * TAU add_child(mi) if wappen: var ring := MeshInstance3D.new() ring.mesh = _praegung() ring.material_override = _praegung_stoff() ring.position = Vector3(0, 0.008, 0) mi.add_child(ring) topf_muenzen[nr] = mi # Fällt drehend aus der Hand und bleibt flach liegen. var tw := create_tween().set_parallel(true) tw.tween_property(mi, "position", lage, 0.55).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) tw.tween_property(mi, "rotation:x", TAU * 3.0, 0.55).from(0.0) for nr in topf_muenzen.keys(): if bleibt.has(nr): continue var mi: MeshInstance3D = topf_muenzen[nr] topf_muenzen.erase(nr) if ziel < 0 or ziel >= zones.size(): mi.queue_free() continue # Zum Gewinner fliegen, dann weg. var hin: Vector3 = zones[ziel].global_position + Vector3(0, 0.25, 0) var tw := create_tween() tw.tween_property(mi, "global_position", hin, 0.6).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT) tw.tween_callback(mi.queue_free) static var _muenz_mat_wappen: StandardMaterial3D static var _praeg_mesh: TorusMesh static var _praeg_mat: StandardMaterial3D static func _muenz_form() -> ArrayMesh: if _muenz_mesh == null: # Aus Kamerahöhe muss eine Münze fast so gross sein wie ein Würfel, # sonst ist sie ein Punkt. _muenz_mesh = Formen.scheibe(0.11, 0.016, 0.006) return _muenz_mesh static func _muenz_stoff(wappen: bool) -> StandardMaterial3D: if _muenz_mat == null: _muenz_mat = Stoffe.messing(0.25) _muenz_mat_wappen = Stoffe.messing(0.9) return _muenz_mat_wappen if wappen else _muenz_mat static func _praegung() -> TorusMesh: if _praeg_mesh == null: _praeg_mesh = TorusMesh.new() _praeg_mesh.inner_radius = 0.035 _praeg_mesh.outer_radius = 0.065 _praeg_mesh.rings = 20 _praeg_mesh.ring_segments = 6 return _praeg_mesh static func _praegung_stoff() -> StandardMaterial3D: if _praeg_mat == null: _praeg_mat = StandardMaterial3D.new() _praeg_mat.albedo_color = Color(0.20, 0.14, 0.05) _praeg_mat.roughness = 0.8 return _praeg_mat ## Beim Zählen leuchten die passenden Würfel nacheinander auf, mit einem ## Tick je Würfel. So sieht man, woher die Zahl im Banner kommt. func zaehlen_zeigen(face: int, werte: Array) -> void: var k := 0 for seat in mini(werte.size(), dice.size()): var dl: Array = dice[seat] for i in mini(dl.size(), werte[seat].size()): if int(werte[seat][i]) != face: continue var d = dl[i] get_tree().create_timer(0.12 * k).timeout.connect(func() -> void: if is_instance_valid(d): d.hervorheben(true) Klang.tick()) k += 1 func hervorhebung_weg() -> void: for dl in dice: for d in dl: d.hervorheben(false) ## Der Platz, der dran ist, bekommt einen leuchtenden Rand. In der dunklen ## Szene ist das von jeder Ecke aus zu sehen - ein farbiger Name allein nicht. func set_active_seat(seat: int) -> void: for i in rims.size(): var m: StandardMaterial3D = rims[i].material_override m.emission_enabled = i == seat m.emission_energy_multiplier = 1.2 func set_dice_visible(seat: int, on: bool) -> void: for d in dice[seat]: d.visible = on ## Becher senken (Würfel verdeckt) oder anheben und ausblenden (Würfel frei). func set_cup_down(seat: int, down: bool) -> void: var cup: Node3D = cups[seat] if cup_tweens[seat] != null and cup_tweens[seat].is_valid(): cup_tweens[seat].kill() var tw := create_tween() cup_tweens[seat] = tw if down: cup.visible = true tw.tween_property(cup, "position:y", 0.02, 0.35).set_trans(Tween.TRANS_CUBIC) else: tw.tween_property(cup, "position:y", 1.1, 0.35).set_trans(Tween.TRANS_CUBIC) tw.tween_callback(func() -> void: cup.visible = false) func set_label(seat: int, text: String, col: Color) -> void: labels[seat].text = text labels[seat].modulate = col _layout_seat(seat)