Files
pirate-dice/scripts/table.gd
2026-09-07 04:29:23 +02:00

485 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 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(Color(0.22, 0.15, 0.09), 0.6)
zone.add_child(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 ---
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()
while arr.size() < count:
var d := Die.new()
add_child(d)
d.global_position = zones[seat].global_position + Vector3(0, 0.4, 0)
arr.append(d)
_layout_idle(seat)
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).
func _layout_idle(seat: int) -> 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 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)
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)