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 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 := 15.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 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([]) _build_camera() func _build_room() -> void: var env := WorldEnvironment.new() var e := Environment.new() e.background_mode = Environment.BG_COLOR e.background_color = Color(0.04, 0.05, 0.07) e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR e.ambient_light_color = Color(0.35, 0.38, 0.45) e.ambient_light_energy = 1.1 e.tonemap_mode = Environment.TONE_MAPPER_FILMIC e.fog_enabled = true e.fog_light_color = Color(0.05, 0.06, 0.09) e.fog_density = 0.02 env.environment = e add_child(env) var lamp := SpotLight3D.new() lamp.position = Vector3(0, TABLE_H + table_r * 1.45, 0) lamp.rotation_degrees = Vector3(-90, 0, 0) lamp.light_energy = 2.5 lamp.light_color = Color(1.0, 0.94, 0.82) lamp.spot_range = table_r * 4.2 lamp.spot_angle = 60.0 lamp.spot_attenuation = 0.6 lamp.shadow_enabled = true add_child(lamp) var fill := DirectionalLight3D.new() fill.rotation_degrees = Vector3(-55, 35, 0) fill.light_energy = 0.25 add_child(fill) # Boden var floor_body := StaticBody3D.new() var floor_col := CollisionShape3D.new() var fs := BoxShape3D.new() fs.size = Vector3(24, 0.4, 24) floor_col.shape = fs floor_col.position = Vector3(0, -0.2, 0) floor_body.add_child(floor_col) var floor_mi := MeshInstance3D.new() var fpm := PlaneMesh.new() fpm.size = Vector2(24, 24) floor_mi.mesh = fpm floor_mi.material_override = _mat(Color(0.08, 0.07, 0.09), 0.9) floor_body.add_child(floor_mi) add_child(floor_body) # Tischplatte var table := StaticBody3D.new() var tcol := CollisionShape3D.new() var tcs := CylinderShape3D.new() tcs.radius = table_r tcs.height = 0.12 tcol.shape = tcs tcol.position = Vector3(0, TABLE_H - 0.06, 0) table.add_child(tcol) var tmi := MeshInstance3D.new() var tcm := CylinderMesh.new() tcm.top_radius = table_r tcm.bottom_radius = table_r tcm.height = 0.12 tmi.mesh = tcm tmi.position = Vector3(0, TABLE_H - 0.06, 0) tmi.material_override = _mat(Color(0.10, 0.28, 0.17), 0.85) table.add_child(tmi) add_child(table) # Tischbein var leg := MeshInstance3D.new() var lcm := CylinderMesh.new() lcm.top_radius = table_r * 0.09 lcm.bottom_radius = table_r * 0.22 lcm.height = TABLE_H - 0.12 leg.mesh = lcm leg.position = Vector3(0, (TABLE_H - 0.12) * 0.5, 0) leg.material_override = _mat(Color(0.16, 0.11, 0.07), 0.7) add_child(leg) 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() var fm := CylinderMesh.new() fm.top_radius = ZONE_RAD fm.bottom_radius = ZONE_RAD fm.height = 0.012 fm.radial_segments = 32 felt.mesh = fm felt.position = Vector3(0, 0.006, 0) felt.material_override = _mat(Color(0.07, 0.20, 0.13), 0.95) zone.add_child(felt) # Sichtbarer Rand var rim := MeshInstance3D.new() var tm := TorusMesh.new() tm.inner_radius = ZONE_RAD tm.outer_radius = ZONE_RAD + 0.07 tm.rings = 32 tm.ring_segments = 8 rim.mesh = tm rim.position = Vector3(0, 0.02, 0) rim.material_override = _mat(RIM_RUHE, 0.6) zone.add_child(rim) rims.append(rim) # Becher var cup := Node3D.new() cup.position = Vector3(0, 0.02, 0) zone.add_child(cup) var cup_mi := MeshInstance3D.new() var cm := CylinderMesh.new() cm.top_radius = 0.33 cm.bottom_radius = 0.42 cm.height = 0.44 cup_mi.mesh = cm cup_mi.position = Vector3(0, 0.22, 0) cup_mi.material_override = _mat(Color(0.35, 0.12, 0.10), 0.5) cup.add_child(cup_mi) 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) # 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) ## 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 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 var dran: bool = i == seat m.albedo_color = RIM_ZUG if dran else RIM_RUHE m.emission_enabled = dran if dran: m.emission = Color(1.0, 0.78, 0.30) m.emission_energy_multiplier = 1.1 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)