485 lines
15 KiB
GDScript
485 lines
15 KiB
GDScript
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)
|