This commit is contained in:
team3
2026-09-16 17:55:28 +02:00
parent 9800c614bb
commit a0597110bd
17 changed files with 652 additions and 278 deletions

View File

@@ -172,6 +172,22 @@ Ruhe.
Wer keine Würfel mehr hat, scheidet aus und schaut zu. Der Letzte gewinnt. Wer keine Würfel mehr hat, scheidet aus und schaut zu. Der Letzte gewinnt.
## Kampagne
Im Menü unter **Kampagne** liegt eine Seekarte mit sechs Kapitänen, von
*Roter Jack* (leicht, 900) bis *Admiral Krähe* (schwer, 1360). Ein Klick auf
einen Hafen startet die Partie: der Kapitän spielt auf seiner Stufe, seine
Mannschaft eine Stufe darunter.
* Du hast eine **Wertung** (Elo, Start 1000) und daraus einen **Rang**:
Schiffsjunge, Matrose, Maat, Steuermann, Kapitän, Admiral.
* **Geschlagen** ist ein Kapitän, wenn er vor dir ausscheidet - oder du
gewinnst. Er verschwindet dann für zwei Partien auf See und **kommt
stärker zurück**: mehr Wertung, eine Stufe höher, ab „schwer" mehr
Mannschaft.
* Verlierst du, bleibt er, und deine Wertung sinkt.
* Der Stand liegt in `user://kampagne.json`. „Neu anfangen" löscht ihn.
## Münzen ## Münzen
Münzen ersetzen die frühere Booster-Phase. Die kostete jede Runde eine eigene Münzen ersetzen die frühere Booster-Phase. Die kostete jede Runde eine eigene
@@ -248,12 +264,13 @@ Runden.
die Münzen zu seiner Zone. die Münzen zu seiner Zone.
* Klänge (Würfel, Münze, Becher, Gewinn) werden beim Start berechnet. Es gibt * Klänge (Würfel, Münze, Becher, Gewinn) werden beim Start berechnet. Es gibt
keine Tondateien, so wie es auch keine Bilddateien gibt. keine Tondateien, so wie es auch keine Bilddateien gibt.
* Kein Bild, kein Mesh aus einer Datei. **Stoffe** sind Regeln (`stoffe.gd`): * Kein Bild, kein Mesh aus einer Datei. Der Look ist **Low-Poly wie Knete**:
Farbverlauf, Rauschart, Reliefstärke, Rauheit. Daraus entstehen zur flache, pastellige Farben, runde Kanten, weiche Schatten, heller Himmel.
Laufzeit drei Texturen je Stoff - Farbe, Relief, Rauheit. Das Relief macht **Stoffe** (`stoffe.gd`) sind nur Farbe und Rauheit - was Holz von Filz
aus gemustertem Plastik Filz, Holz und Messing. **Formen** sind Bauregeln trennt, ist die Form. **Formen** (`formen.gd`) entstehen aus Profilen, die
(`formen.gd`): Becher = Kegelstumpf + Rand + zwei Bänder, Laterne = Sockel + um die Achse gedreht werden; Fasen sind Viertelkreise im Profil. Becher =
Glas + Kappe. Kanten sind schmale Ringe. Der Plan steht in `assets.md`. Kegelstumpf mit runden Kanten + Messingrand + Band. Der Plan steht in
`assets.md`.
* Auf dem Handy nur Querformat - ein Tisch von oben braucht Breite. * Auf dem Handy nur Querformat - ein Tisch von oben braucht Breite.
* Die Regeln stehen im Menü unter **Regeln**, inklusive aller sechs Münzen. * Die Regeln stehen im Menü unter **Regeln**, inklusive aller sechs Münzen.
@@ -286,8 +303,9 @@ vermutlich haarscharf, rufen sie „Genau".
| `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) | | `scripts/ai.gd` | Bot-Entscheidungen (Binomialabschätzung für Gebot/Anfechtung) |
| `scripts/hud.gd` | Spiel-UI | | `scripts/hud.gd` | Spiel-UI |
| `scripts/klang.gd` | Autoload: berechnete Klänge, keine Dateien | | `scripts/klang.gd` | Autoload: berechnete Klänge, keine Dateien |
| `scripts/stoffe.gd` | Materialien aus Regeln, Texturen zur Laufzeit - keine Dateien | | `scripts/kampagne.gd` | Autoload: Seekarte, Kapitäne, Wertung, Speicherstand |
| `scripts/formen.gd` | Becher, Fass, Laterne aus Primitiven nach Bauregeln | | `scripts/stoffe.gd` | Flache Farben der Knete-Palette, ein Material je Stoff |
| `scripts/formen.gd` | Gedrehte Profile mit Fasen: Scheiben, Becher, Laterne |
| `scripts/icons.gd` | Gezeichnete Münz- und Würfelbilder fürs HUD | | `scripts/icons.gd` | Gezeichnete Münz- und Würfelbilder fürs HUD |
| `scripts/hud_panels.gd` | Vorrat, Topf, Erklärung, Verlauf, Protokoll | | `scripts/hud_panels.gd` | Vorrat, Topf, Erklärung, Verlauf, Protokoll |
| `tests/` | Headless-Tests, `make test` (~30 s); `make sim` misst die Bots - siehe `tests/README.md` | | `tests/` | Headless-Tests, `make test` (~30 s); `make sim` misst die Bots - siehe `tests/README.md` |

View File

@@ -6,15 +6,21 @@ gebaut ist.
## Stand ## Stand
- Stoffe: Filz, Holz, dunkles Holz, Messing, Knochen. Je Regel drei Der Look ist **Low-Poly wie Knete** (Vorbild: pastelliges Haus-Diorama):
Laufzeit-Texturen aus `NoiseTexture2D`: Farbe (Rauschen durch flache Farben, runde Kanten, weiche Schatten, heller Himmel. Der frühere
Farbverlauf), Relief (`as_normal_map`), Rauheit. Kein Shadercode. Plan einer dunklen Taverne mit Maserung und Relief ist verworfen - er wirkte
- Formen: Becher, Fass, Laterne aus Primitiven. Kanten als Torus-Ringe. aus Kamerahöhe schäbig.
- Gelernt: Rauschen nur als Farbe bleibt flach - das Relief macht den
Unterschied. Grobes, glänzendes Relief auf Filz sieht nach nassem Moos aus: - `stoffe.gd`: eine Farbe je Stoff, matt, kaum Glanz. Keine Texturen.
fein, matt, kaum Höhe. Maserung entsteht durch gestreckte UV (1:9). - `formen.gd`: Profile (Radius, Höhe) um die Achse gedreht, Fasen als
- Verworfen: Dielen auf dem Boden (aus Kamerahöhe nur Rauschen), Lampe über Viertelkreise. Scheibe, runder Zylinder, Ring, Becher, Laterne.
der Mitte (hängt der Kamera vor der Linse), Fass am Boden (ein Fleck). - Tisch: Holzscheibe mit Filzeinlage auf einem Mittelfuss, auf einer runden
Sockelplatte wie ein Diorama. Zonen: Filzscheiben mit Holzring.
- Licht: eine Sonne mit weichen Schatten, viel Umgebungslicht, kein Nebel,
kein Glow. Belichtung 0,9, sonst brennt die Platte aus.
- Gelernt: Der Look lebt von den Fasen und dem Licht, nicht vom Material.
Ohne Fasen sieht flache Farbe nach Prototyp aus. Und aus Kamerahöhe muss
am Rand Himmel bleiben, sonst ist alles Platte.
## Grundidee ## Grundidee

View File

@@ -12,6 +12,7 @@ config/features=PackedStringArray("4.7", "Mobile")
BoosterDB="*res://scripts/boosters.gd" BoosterDB="*res://scripts/boosters.gd"
Net="*res://scripts/net.gd" Net="*res://scripts/net.gd"
Klang="*res://scripts/klang.gd" Klang="*res://scripts/klang.gd"
Kampagne="*res://scripts/kampagne.gd"
[display] [display]

View File

@@ -84,6 +84,7 @@ func _phase_reveal(result: Dictionary) -> void:
g.seats[l].count = 0 g.seats[l].count = 0
g.host_values[l] = [] g.host_values[l] = []
g.table.set_dice_count(l, 0) g.table.set_dice_count(l, 0)
g.ausgeschieden.append(l)
g._log("%s scheidet aus." % g.seats[l].name) g._log("%s scheidet aus." % g.seats[l].name)
else: else:
g._zieh_muenze(l) g._zieh_muenze(l)

View File

@@ -1,10 +1,76 @@
extends RefCounted extends RefCounted
## Formen aus Regeln: jedes Ding hat eine Grundform aus Godot-Primitiven, und ## Formen aus Regeln. Der Kern des Low-Poly-Looks sind runde Kanten: jede
## Regeln setzen Teile darauf - Rand, Bänder, Reifen, Kappe. Kanten sind ## Form wird als Profil (Radius, Höhe) beschrieben und um die Achse gedreht.
## schmale Ringe statt Fasen. Kein Mesh kommt aus einer Datei. ## Fasen sind Viertelkreise im Profil. Kein Mesh kommt aus einer Datei.
## Kein class_name: den Klassencache baut nur der Editor, headless fehlt er. ## Kein class_name: den Klassencache baut nur der Editor, headless fehlt er.
const Stoffe = preload("res://scripts/stoffe.gd") const Stoffe = preload("res://scripts/stoffe.gd")
const FASE_SEG := 4 ## Punkte je Viertelkreis - vier reichen bei glatten Normalen
## Ein Profil um die Y-Achse drehen. `profil` läuft von unten (Achse) nach
## oben (Achse), Punkte sind (radius, höhe). Glatte Normalen, damit die
## Fasen weich wirken und nicht wie Absätze.
static func drehen(profil: PackedVector2Array, segmente := 40) -> ArrayMesh:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
st.set_smooth_group(0)
for i in range(profil.size() - 1):
var a := profil[i]
var b := profil[i + 1]
for k in segmente:
var t0 := TAU * float(k) / segmente
var t1 := TAU * float(k + 1) / segmente
var p00 := Vector3(cos(t0) * a.x, a.y, sin(t0) * a.x)
var p10 := Vector3(cos(t1) * a.x, a.y, sin(t1) * a.x)
var p01 := Vector3(cos(t0) * b.x, b.y, sin(t0) * b.x)
var p11 := Vector3(cos(t1) * b.x, b.y, sin(t1) * b.x)
if a.x > 0.0001:
st.add_vertex(p00)
st.add_vertex(p10)
st.add_vertex(p01)
if b.x > 0.0001:
st.add_vertex(p10)
st.add_vertex(p11)
st.add_vertex(p01)
st.index()
st.generate_normals()
return st.commit()
## Viertelkreis von `von` nach `bis` um die Ecke `mitte` (Profilkoordinaten).
static func _bogen(mitte: Vector2, r: float, a0: float, a1: float) -> PackedVector2Array:
var out := PackedVector2Array()
for k in range(1, FASE_SEG + 1):
var t := lerpf(a0, a1, float(k) / FASE_SEG)
out.append(mitte + Vector2(cos(t), sin(t)) * r)
return out
## Zylinder oder Kegelstumpf mit runden Kanten oben und unten.
static func rund_zylinder(r_unten: float, r_oben: float, hoehe: float, fase: float) -> ArrayMesh:
fase = minf(fase, minf(hoehe * 0.5, minf(r_unten, r_oben) * 0.9))
var p := PackedVector2Array()
p.append(Vector2(0.0, 0.0))
p.append(Vector2(r_unten - fase, 0.0))
p.append_array(_bogen(Vector2(r_unten - fase, fase), fase, -PI * 0.5, 0.0))
p.append_array(_bogen(Vector2(r_oben - fase, hoehe - fase), fase, 0.0, PI * 0.5))
p.append(Vector2(0.0, hoehe))
return drehen(p)
static func scheibe(radius: float, hoehe: float, fase: float) -> ArrayMesh:
return rund_zylinder(radius, radius, hoehe, fase)
## Ring mit rundem Querschnitt - für Ränder und Bänder.
static func ring(radius: float, dicke: float) -> TorusMesh:
var t := TorusMesh.new()
t.inner_radius = radius - dicke
t.outer_radius = radius + dicke
t.rings = 40
t.ring_segments = 10
return t
static func _teil(mesh: Mesh, stoff: Material, pos: Vector3, eltern: Node3D) -> MeshInstance3D: static func _teil(mesh: Mesh, stoff: Material, pos: Vector3, eltern: Node3D) -> MeshInstance3D:
@@ -16,65 +82,31 @@ static func _teil(mesh: Mesh, stoff: Material, pos: Vector3, eltern: Node3D) ->
return mi return mi
static func _zylinder(oben: float, unten: float, hoehe: float, segmente := 28) -> CylinderMesh: ## Würfelbecher: Kegelstumpf mit runden Kanten, oben ein Messingrand, ein
var c := CylinderMesh.new() ## Band um den Bauch. Geschlossen - die Kamera schaut von oben.
c.top_radius = oben
c.bottom_radius = unten
c.height = hoehe
c.radial_segments = segmente
return c
static func _ring(radius: float, dicke: float) -> TorusMesh:
var t := TorusMesh.new()
t.inner_radius = radius - dicke
t.outer_radius = radius + dicke
t.rings = 32
t.ring_segments = 8
return t
## Würfelbecher: Kegelstumpf aus dunklem Holz, oben ein Messingrand, zwei
## Messingbänder um den Bauch. Der Deckel oben ist geschlossen - die Kamera
## schaut von oben, ein offener Becher zeigte nur ein schwarzes Loch.
static func becher(hoehe := 0.44, oben := 0.33, unten := 0.42) -> Node3D: static func becher(hoehe := 0.44, oben := 0.33, unten := 0.42) -> Node3D:
var n := Node3D.new() var n := Node3D.new()
_teil(_zylinder(oben, unten, hoehe), Stoffe.holz_dunkel(), Vector3(0, hoehe * 0.5, 0), n) _teil(rund_zylinder(unten, oben, hoehe, 0.05), Stoffe.holz_dunkel(), Vector3.ZERO, n)
_teil(_ring(oben, 0.012), Stoffe.messing(), Vector3(0, hoehe - 0.004, 0), n) _teil(ring(oben - 0.01, 0.018), Stoffe.messing(), Vector3(0, hoehe - 0.02, 0), n)
for k in [0.22, 0.62]: _teil(ring(lerpf(unten, oben, 0.35) + 0.004, 0.012), Stoffe.messing(), Vector3(0, hoehe * 0.35, 0), n)
var r: float = lerpf(unten, oben, k)
_teil(_ring(r, 0.008), Stoffe.messing(), Vector3(0, hoehe * k, 0), n)
return n return n
## Fass: bauchiger Zylinder, drei Reifen, Boden und Deckel etwas kleiner. ## Laterne: Sockel, leuchtendes Glas, Kappe, Bügel.
static func fass(hoehe := 0.6, radius := 0.24) -> Node3D:
var n := Node3D.new()
_teil(_zylinder(radius * 0.86, radius * 0.86, hoehe), Stoffe.holz(), Vector3(0, hoehe * 0.5, 0), n)
_teil(_zylinder(radius, radius, hoehe * 0.5), Stoffe.holz(), Vector3(0, hoehe * 0.5, 0), n)
for k in [0.12, 0.5, 0.88]:
var r: float = radius if absf(k - 0.5) < 0.2 else radius * 0.88
_teil(_ring(r, 0.012), Stoffe.messing(), Vector3(0, hoehe * k, 0), n)
return n
## Laterne: Sockel, Glaskörper (leuchtend), Kappe mit Ring.
static func laterne(hoehe := 0.5) -> Node3D: static func laterne(hoehe := 0.5) -> Node3D:
var n := Node3D.new() var n := Node3D.new()
_teil(_zylinder(0.09, 0.11, 0.05), Stoffe.messing(), Vector3(0, 0.025, 0), n) _teil(rund_zylinder(0.11, 0.09, 0.05, 0.015), Stoffe.messing(), Vector3.ZERO, n)
var glas := StandardMaterial3D.new() var glas := Stoffe.eigene("glas")
glas.albedo_color = Color(1.0, 0.85, 0.5, 0.75)
glas.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
glas.emission_enabled = true glas.emission_enabled = true
glas.emission = Color(1.0, 0.75, 0.35) glas.emission = Color(1.0, 0.80, 0.40)
glas.emission_energy_multiplier = 2.5 glas.emission_energy_multiplier = 1.6
_teil(_zylinder(0.07, 0.08, hoehe * 0.6), glas, Vector3(0, 0.05 + hoehe * 0.3, 0), n) _teil(rund_zylinder(0.08, 0.07, hoehe * 0.6, 0.02), glas, Vector3(0, 0.05, 0), n)
_teil(_zylinder(0.03, 0.10, 0.08), Stoffe.messing(), Vector3(0, 0.05 + hoehe * 0.6 + 0.04, 0), n) _teil(rund_zylinder(0.10, 0.03, 0.08, 0.015), Stoffe.messing(), Vector3(0, 0.05 + hoehe * 0.6, 0), n)
_teil(_ring(0.035, 0.006), Stoffe.messing(), Vector3(0, 0.05 + hoehe * 0.6 + 0.10, 0), n) _teil(ring(0.035, 0.007), Stoffe.messing(), Vector3(0, 0.05 + hoehe * 0.6 + 0.10, 0), n)
var licht := OmniLight3D.new() var licht := OmniLight3D.new()
licht.light_color = Color(1.0, 0.8, 0.45) licht.light_color = Color(1.0, 0.85, 0.55)
licht.light_energy = 1.2 licht.light_energy = 0.8
licht.omni_range = 1.8 licht.omni_range = 1.6
licht.position = Vector3(0, 0.05 + hoehe * 0.3, 0) licht.position = Vector3(0, 0.05 + hoehe * 0.3, 0)
n.add_child(licht) n.add_child(licht)
return n return n

View File

@@ -25,6 +25,8 @@ var dice_start := START_DICE
## Nur für `make sim`; fünfzig Partien in Sekunden statt Minuten. ## Nur für `make sim`; fünfzig Partien in Sekunden statt Minuten.
var simulation := false var simulation := false
var runde := 0 ## laufende Rundennummer, für Anzeige und Messung var runde := 0 ## laufende Rundennummer, für Anzeige und Messung
var ausgeschieden: Array = [] ## Plätze in der Reihenfolge ihres Ausscheidens
var cfg_start: Dictionary = {} ## Startaufstellung, fürs Nochmal
var rng := RandomNumberGenerator.new() var rng := RandomNumberGenerator.new()
var seats: Array = [] ## [{peer, name, ai, count, alive}] var seats: Array = [] ## [{peer, name, ai, count, alive}]
@@ -67,6 +69,7 @@ func _ready() -> void:
func setup(cfg: Dictionary) -> void: func setup(cfg: Dictionary) -> void:
cfg_start = cfg.duplicate(true)
DiceAI.stufe = int(cfg.get("stufe", 1)) DiceAI.stufe = int(cfg.get("stufe", 1))
seats.clear() seats.clear()
if cfg.has("seats"): if cfg.has("seats"):
@@ -83,8 +86,11 @@ func setup(cfg: Dictionary) -> void:
for p in peers: for p in peers:
seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false, seats.append({"peer": int(p), "name": String(cfg.names[p]), "ai": false,
"count": dice, "alive": true, "verwaist": false}) "count": dice, "alive": true, "verwaist": false})
var bots: Array = cfg.get("bots", [])
for i in int(cfg.ai): for i in int(cfg.ai):
seats.append({"peer": 0, "name": "Bot %d" % (i + 1), "ai": true, var b: Dictionary = bots[i] if i < bots.size() else {}
seats.append({"peer": 0, "name": String(b.get("name", "Bot %d" % (i + 1))), "ai": true,
"stufe": int(b.get("stufe", DiceAI.stufe)),
"count": dice, "alive": true, "verwaist": false}) "count": dice, "alive": true, "verwaist": false})
# Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz. # Der Spielserver spielt nicht mit und bekommt deshalb keinen Platz.
@@ -108,6 +114,7 @@ func setup(cfg: Dictionary) -> void:
hud.challenge_pressed.connect(_on_challenge_pressed) hud.challenge_pressed.connect(_on_challenge_pressed)
hud.exact_pressed.connect(_on_exact_pressed) hud.exact_pressed.connect(_on_exact_pressed)
hud.rematch_pressed.connect(_on_rematch) hud.rematch_pressed.connect(_on_rematch)
hud.karte_pressed.connect(_on_karte)
hud.booster_chosen.connect(_on_booster_chosen) hud.booster_chosen.connect(_on_booster_chosen)
hud.quit_pressed.connect(_on_quit) hud.quit_pressed.connect(_on_quit)
hud.pause_changed.connect(_on_pause_changed) hud.pause_changed.connect(_on_pause_changed)
@@ -209,6 +216,7 @@ func _host_loop() -> void:
winner = s.name winner = s.name
banner = "%s gewinnt" % winner banner = "%s gewinnt" % winner
_log(banner) _log(banner)
Kampagne.ergebnis(seats, ausgeschieden)
_push_state() _push_state()
@@ -433,6 +441,7 @@ func _phase_bid() -> Dictionary:
await _sleep(1.1) await _sleep(1.1)
# Erst planen, dann passend dazu eine Münze werfen. Die kann die # Erst planen, dann passend dazu eine Münze werfen. Die kann die
# Lage ändern - also danach noch einmal entscheiden. # Lage ändern - also danach noch einmal entscheiden.
DiceAI.stufe = int(seats[turn].get("stufe", DiceAI.stufe))
var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid, var a := DiceAI.choose_action(host_values[turn], _total_dice(), bid,
must_bid.get(turn, false), rng, _bot_wissen(turn)) must_bid.get(turn, false), rng, _bot_wissen(turn))
if await _bot_muenze(turn, a): if await _bot_muenze(turn, a):
@@ -788,7 +797,19 @@ func _on_rematch() -> void:
for s in seats: for s in seats:
if s.ai: if s.ai:
bots += 1 bots += 1
Net.rematch = {"name": Net.player_name, "ai": bots, "stufe": DiceAI.stufe} var kap := ""
if Kampagne.kampagnenpartie and cfg_start.get("bots", []).size() > 0:
kap = String(cfg_start.bots[0].name)
Net.rematch = {"name": Net.player_name, "ai": bots, "stufe": int(cfg_start.get("stufe", 1)),
"bots": cfg_start.get("bots", []), "kampagne": kap}
_on_quit()
## Zurück auf die Seekarte: das frisch geladene Menü zeigt sie sofort.
func _on_karte() -> void:
if _quitting:
return
Kampagne.zur_karte = true
_on_quit() _on_quit()

View File

@@ -5,6 +5,7 @@ signal bid_pressed(count: int, face: int)
signal challenge_pressed signal challenge_pressed
signal exact_pressed signal exact_pressed
signal rematch_pressed signal rematch_pressed
signal karte_pressed
signal booster_chosen(id: String, target: int) signal booster_chosen(id: String, target: int)
signal quit_pressed signal quit_pressed
signal pause_changed(paused: bool) signal pause_changed(paused: bool)
@@ -54,6 +55,7 @@ var pause_slot: Control
var over_slot: Control var over_slot: Control
var over_title: Label var over_title: Label
var over_again: Button var over_again: Button
var over_karte: Button
var pause_hint: Label var pause_hint: Label
var boost_panel: PanelContainer var boost_panel: PanelContainer
var boost_cards: HBoxContainer var boost_cards: HBoxContainer
@@ -178,9 +180,12 @@ func _build() -> void:
# --- Letzte Wette in der Tischmitte, als so viele Würfel wie angesagt # --- Letzte Wette in der Tischmitte, als so viele Würfel wie angesagt
var bid_slot := _slot(0, 0, 1, 1, 0, 0, 0, 0) var bid_slot := _slot(0, 0, 1, 1, 0, 0, 0, 0)
var bid_panel := _panel(Color(0.05, 0.06, 0.09, 0.55))
bid_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
bid_slot.add_child(bid_panel)
bid_box = VBoxContainer.new() bid_box = VBoxContainer.new()
bid_box.add_theme_constant_override("separation", 8) bid_box.add_theme_constant_override("separation", 8)
bid_slot.add_child(bid_box) bid_panel.add_child(bid_box)
bid_name = _label("", 21, ACCENT) bid_name = _label("", 21, ACCENT)
bid_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER bid_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# Auf die freie Mitte begrenzen, sonst läuft der Text über die Nachbarplätze. # Auf die freie Mitte begrenzen, sonst läuft der Text über die Nachbarplätze.
@@ -205,11 +210,14 @@ func _build() -> void:
root.add_child(banner_label) root.add_child(banner_label)
# --- Wer ist dran: direkt über der Ansageleiste, wo der Blick ohnehin liegt # --- Wer ist dran: direkt über der Ansageleiste, wo der Blick ohnehin liegt
# Im eigenen Feld: auf der hellen Platte wäre gelbe Schrift unlesbar.
var tp := _panel(Color(0.05, 0.06, 0.09, 0.85))
tp.mouse_filter = Control.MOUSE_FILTER_IGNORE
_slot(0, 1, 1, 1, 16, -152, -16, -114).add_child(tp)
turn_label = _label("", 19, ACCENT) turn_label = _label("", 19, ACCENT)
turn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER turn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
turn_label.mouse_filter = Control.MOUSE_FILTER_IGNORE turn_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_place(turn_label, 0, 1, 1, 1, 16, -146, -16, -114) tp.add_child(turn_label)
root.add_child(turn_label)
# --- Ansage: kompakte Leiste unten in der Mitte # --- Ansage: kompakte Leiste unten in der Mitte
action_panel = _panel() action_panel = _panel()
@@ -312,10 +320,12 @@ func _build() -> void:
# den Tisch; hier steht nur, wer was geworfen hat und wie es fiel. # den Tisch; hier steht nur, wer was geworfen hat und wie es fiel.
wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 200) wurf_slot = _slot(0, 0, 0, 0, 16, 100, 336, 200)
wurf_slot.visible = false wurf_slot.visible = false
var wp := _panel()
wurf_slot.add_child(wp)
wurf_label = _label("", 16, ACCENT) wurf_label = _label("", 16, ACCENT)
wurf_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER wurf_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
wurf_label.mouse_filter = Control.MOUSE_FILTER_IGNORE wurf_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
wurf_slot.add_child(wurf_label) wp.add_child(wurf_label)
# --- Protokoll unten rechts: was der Topf tut, wer geht, wer zurückkommt. # --- Protokoll unten rechts: was der Topf tut, wer geht, wer zurückkommt.
var lp := _panel() var lp := _panel()
@@ -427,6 +437,12 @@ func _build() -> void:
over_again.custom_minimum_size = Vector2(0, 42) over_again.custom_minimum_size = Vector2(0, 42)
over_again.pressed.connect(func() -> void: rematch_pressed.emit()) over_again.pressed.connect(func() -> void: rematch_pressed.emit())
ov.add_child(over_again) ov.add_child(over_again)
over_karte = Button.new()
over_karte.text = "Zur Seekarte"
over_karte.custom_minimum_size = Vector2(0, 42)
over_karte.pressed.connect(func() -> void: karte_pressed.emit())
over_karte.visible = false
ov.add_child(over_karte)
var menue := Button.new() var menue := Button.new()
menue.text = "Zum Menü" menue.text = "Zum Menü"
menue.custom_minimum_size = Vector2(0, 42) menue.custom_minimum_size = Vector2(0, 42)
@@ -621,6 +637,10 @@ func update_state(st: Dictionary, my_seat: int) -> void:
banner_label.text = "" if st.phase == "over" else st.banner banner_label.text = "" if st.phase == "over" else st.banner
if st.phase == "over" and not over_slot.visible: if st.phase == "over" and not over_slot.visible:
over_title.text = String(st.banner) over_title.text = String(st.banner)
# Kampagne: Wertung und Rang statt nur des Siegers.
if Kampagne.kampagnenpartie and Kampagne.letztes != "":
over_title.text = String(st.banner) + "\n" + Kampagne.letztes
over_karte.visible = Kampagne.kampagnenpartie
# Nochmal: solo immer, im Netz nur für den Raumbesitzer - der Server # Nochmal: solo immer, im Netz nur für den Raumbesitzer - der Server
# setzt den Raum dann für alle neu auf. # setzt den Raum dann für alle neu auf.
over_again.visible = Net.room_code == Net.SOLO or Net.is_owner over_again.visible = Net.room_code == Net.SOLO or Net.is_owner

173
scripts/kampagne.gd Normal file
View File

@@ -0,0 +1,173 @@
extends Node
## Kampagne: eine Seekarte mit Kapitänen. Wen du schlägst, der verschwindet
## auf See und kommt nach zwei Partien stärker zurück - höhere Wertung, bessere
## Bots, mehr Mannschaft. Du hast eine Wertung (Elo) und daraus einen Rang.
## Der Stand liegt in user://kampagne.json.
const DATEI := "user://kampagne.json"
const K := 32.0 ## Elo-Schrittweite
const AUF_SEE := 2 ## Partien, bis ein Geschlagener zurück ist
const RAENGE := [
[0, "Schiffsjunge"], [950, "Matrose"], [1050, "Maat"],
[1150, "Steuermann"], [1280, "Kapitän"], [1420, "Admiral"],
]
## Kapitäne mit Wertung, Bot-Stufe, Mannschaft (weitere Bots eine Stufe
## darunter) und Platz auf der Karte (0..1 in Breite und Höhe).
const START_KAPITAENE := [
{"name": "Roter Jack", "elo": 900, "stufe": 0, "mannschaft": 0, "pos": Vector2(0.14, 0.32)},
{"name": "Salz-Marie", "elo": 960, "stufe": 0, "mannschaft": 1, "pos": Vector2(0.33, 0.68)},
{"name": "Einauge Bill", "elo": 1030, "stufe": 1, "mannschaft": 1, "pos": Vector2(0.52, 0.24)},
{"name": "Käpt'n Grau", "elo": 1110, "stufe": 1, "mannschaft": 2, "pos": Vector2(0.70, 0.62)},
{"name": "Die Witwe", "elo": 1210, "stufe": 2, "mannschaft": 2, "pos": Vector2(0.87, 0.30)},
{"name": "Admiral Krähe", "elo": 1360, "stufe": 2, "mannschaft": 3, "pos": Vector2(0.50, 0.86)},
]
var datei := DATEI ## Tests schreiben woandershin
var elo := 1000.0
var partien := 0
var siege := 0
var kapitaene: Array = [] ## {name, elo, stufe, mannschaft, pos, weg, geschlagen}
var aktiv := "" ## Kapitän der laufenden Partie, "" = keine Kampagnenpartie
var letztes := "" ## Text zum letzten Ergebnis, fürs Endfeld
var zur_karte := false ## nach dem Ende zurück auf die Karte statt ins Hauptmenü
var kampagnenpartie := false ## die laufende (oder gerade beendete) Partie gehört zur Kampagne
func _ready() -> void:
laden()
static func rang(wert: float) -> String:
var r := ""
for stufe in RAENGE:
if wert >= float(stufe[0]):
r = String(stufe[1])
return r
func mein_rang() -> String:
return rang(elo)
func zuruecksetzen() -> void:
elo = 1000.0
partien = 0
siege = 0
aktiv = ""
letztes = ""
kapitaene = []
for k in START_KAPITAENE:
var e: Dictionary = k.duplicate()
e.weg = 0
e.geschlagen = 0
kapitaene.append(e)
func kapitaen(name: String) -> Dictionary:
for k in kapitaene:
if k.name == name:
return k
return {}
## Partie gegen einen Kapitän: er selbst auf seiner Stufe, seine Mannschaft
## eine Stufe darunter. Der Spieler sitzt auf Platz 0.
func starten(name: String) -> void:
var k := kapitaen(name)
if k.is_empty() or int(k.weg) > 0:
return
aktiv = name
kampagnenpartie = true
var bots: Array = [{"name": name, "stufe": int(k.stufe)}]
for i in int(k.mannschaft):
bots.append({"name": "Maat %d" % (i + 1), "stufe": maxi(0, int(k.stufe) - 1)})
Net.start_singleplayer(Net.player_name, bots.size(), int(k.stufe), 5, bots)
## Wird vom Spiel am Ende gerufen. `ausgeschieden` ist die Reihenfolge der
## Plätze, die rausgeflogen sind. Geschlagen ist der Kapitän, wenn er vor dem
## Spieler ging - oder der Spieler gewonnen hat.
func ergebnis(seats: Array, ausgeschieden: Array) -> void:
if aktiv == "":
return
var k := kapitaen(aktiv)
aktiv = ""
if k.is_empty():
return
var spieler := 0
var kap := -1
for i in seats.size():
if String(seats[i].name) == String(k.name):
kap = i
var sieg: bool
if seats[spieler].alive:
sieg = true
elif kap >= 0 and ausgeschieden.has(kap) and ausgeschieden.has(spieler):
sieg = ausgeschieden.find(kap) < ausgeschieden.find(spieler)
else:
sieg = false
var erwartet := 1.0 / (1.0 + pow(10.0, (float(k.elo) - elo) / 400.0))
var punkte := 1.0 if sieg else 0.0
var vorher := elo
var delta := K * (punkte - erwartet)
elo = maxf(0.0, elo + delta)
k.elo = maxf(0.0, float(k.elo) - delta)
partien += 1
# Die anderen auf See rücken einen Schritt näher an die Rückkehr.
for o in kapitaene:
if o != k and int(o.weg) > 0:
o.weg = int(o.weg) - 1
if int(o.weg) == 0:
_zurueck(o)
if sieg:
siege += 1
k.weg = AUF_SEE
k.geschlagen = int(k.geschlagen) + 1
letztes = "Sieg gegen %s! %d → %d (%s)" % [k.name, int(round(vorher)), int(round(elo)), mein_rang()]
else:
letztes = "%s behält die Oberhand. %d → %d (%s)" % [k.name, int(round(vorher)), int(round(elo)), mein_rang()]
speichern()
## Zurück von See: besser als vorher. Stufe bis 2, dann wächst die Mannschaft.
func _zurueck(k: Dictionary) -> void:
k.elo = float(k.elo) + 60.0
if int(k.stufe) < 2:
k.stufe = int(k.stufe) + 1
else:
k.mannschaft = mini(4, int(k.mannschaft) + 1)
func speichern() -> void:
var daten := {"elo": elo, "partien": partien, "siege": siege, "kapitaene": []}
for k in kapitaene:
daten.kapitaene.append({"name": k.name, "elo": k.elo, "stufe": k.stufe,
"mannschaft": k.mannschaft, "weg": k.weg, "geschlagen": k.geschlagen})
var f := FileAccess.open(datei, FileAccess.WRITE)
if f != null:
f.store_string(JSON.stringify(daten))
f.close()
func laden() -> void:
zuruecksetzen()
if not FileAccess.file_exists(datei):
return
var text := FileAccess.get_file_as_string(datei)
var daten = JSON.parse_string(text)
if not (daten is Dictionary):
return
elo = float(daten.get("elo", 1000.0))
partien = int(daten.get("partien", 0))
siege = int(daten.get("siege", 0))
for d in daten.get("kapitaene", []):
var k := kapitaen(String(d.get("name", "")))
if k.is_empty():
continue
k.elo = float(d.get("elo", k.elo))
k.stufe = int(d.get("stufe", k.stufe))
k.mannschaft = int(d.get("mannschaft", k.mannschaft))
k.weg = int(d.get("weg", 0))
k.geschlagen = int(d.get("geschlagen", 0))

View File

@@ -35,7 +35,13 @@ func _ready() -> void:
if not Net.rematch.is_empty(): if not Net.rematch.is_empty():
var r: Dictionary = Net.rematch var r: Dictionary = Net.rematch
Net.rematch = {} Net.rematch = {}
Net.start_singleplayer(String(r.name), int(r.ai), int(r.stufe)) var kap := String(r.get("kampagne", ""))
if kap != "":
# Nochmal gegen denselben Kapitän: nur, wenn er noch da ist.
Kampagne.starten(kap)
else:
Kampagne.kampagnenpartie = false
Net.start_singleplayer(String(r.name), int(r.ai), int(r.stufe), 5, r.get("bots", []))
## Das Spiel startet im Vollbild; F11 schaltet um. ## Das Spiel startet im Vollbild; F11 schaltet um.

View File

@@ -14,6 +14,10 @@ var zurueck_btn: Button ## "Zurück in Raum XY" nach einem Abbruch
var solo_stufe: OptionButton var solo_stufe: OptionButton
var lobby_stufe: OptionButton var lobby_stufe: OptionButton
# Kampagne
var kamp_kopf: Label
var karte: Control
# Mehrspieler # Mehrspieler
var name_edit: LineEdit var name_edit: LineEdit
var code_edit: LineEdit var code_edit: LineEdit
@@ -41,11 +45,16 @@ func _ready() -> void:
screens["join"] = _screen(root, _build_join()) screens["join"] = _screen(root, _build_join())
screens["lobby"] = _screen(root, _build_lobby()) screens["lobby"] = _screen(root, _build_lobby())
screens["regeln"] = _screen(root, _build_regeln()) screens["regeln"] = _screen(root, _build_regeln())
screens["kampagne"] = _screen(root, _build_kampagne())
Net.lobby_changed.connect(_refresh) Net.lobby_changed.connect(_refresh)
Net.connection_failed.connect(_on_failed) Net.connection_failed.connect(_on_failed)
Net.server_closed.connect(_on_closed) Net.server_closed.connect(_on_closed)
_show("main") if Kampagne.zur_karte:
Kampagne.zur_karte = false
_show("kampagne")
else:
_show("main")
# ------------------------------------------------------------ Aufbau # ------------------------------------------------------------ Aufbau
@@ -63,6 +72,8 @@ func _show(which: String) -> void:
for key: String in screens: for key: String in screens:
screens[key].visible = key == which screens[key].visible = key == which
_sagen("") _sagen("")
if which == "kampagne":
_karte_fuellen()
if which == "main" and zurueck_btn != null: if which == "main" and zurueck_btn != null:
zurueck_btn.visible = not Net.letzter_raum.is_empty() zurueck_btn.visible = not Net.letzter_raum.is_empty()
if zurueck_btn.visible: if zurueck_btn.visible:
@@ -93,6 +104,82 @@ func _text(t: String, size: int, col := Color.WHITE) -> Label:
return l return l
## Gezeichnete Karte im Knete-Look: helle See, sandige Inseln unter jedem
## Hafen, eine gestrichelte Route von leicht nach schwer.
class Seekarte extends Control:
func _draw() -> void:
var g := size
draw_rect(Rect2(Vector2.ZERO, g), Color(0.56, 0.74, 0.88))
var sortiert: Array = Kampagne.kapitaene.duplicate()
sortiert.sort_custom(func(a, b): return float(a.elo) < float(b.elo))
var letzte := Vector2(-1, -1)
for k in sortiert:
var p: Vector2 = Vector2(k.pos) * g
if letzte.x >= 0.0:
var n := 14
for i in n:
if i % 2 == 0:
draw_line(letzte.lerp(p, float(i) / n), letzte.lerp(p, float(i + 1) / n),
Color(1, 1, 1, 0.45), 2.0)
letzte = p
for k in Kampagne.kapitaene:
var p: Vector2 = Vector2(k.pos) * g
var da: bool = int(k.weg) == 0
draw_circle(p + Vector2(0, 6), 62.0, Color(0.0, 0.0, 0.0, 0.10))
draw_circle(p, 62.0, Color(0.92, 0.84, 0.68) if da else Color(0.75, 0.78, 0.82))
draw_circle(p + Vector2(-10, -30), 16.0, Color(0.35, 0.72, 0.44) if da else Color(0.6, 0.65, 0.66))
draw_circle(p + Vector2(14, -34), 11.0, Color(0.35, 0.72, 0.44) if da else Color(0.6, 0.65, 0.66))
## Die Seekarte: Kapitäne als Häfen. Wer auf See ist, steht ausgegraut da
## mit der Zahl der Partien bis zur Rückkehr.
func _build_kampagne() -> Control:
var box := _box(940)
box.add_child(_text("Seekarte", 26, Color(1.0, 0.82, 0.32)))
kamp_kopf = _text("", 15, Color(0.85, 0.87, 0.92))
box.add_child(kamp_kopf)
karte = Seekarte.new()
karte.custom_minimum_size = Vector2(920, 440)
box.add_child(karte)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
box.add_child(row)
_button("Neu anfangen", row, func() -> void:
Kampagne.zuruecksetzen()
Kampagne.speichern()
_karte_fuellen())
_button("Zurück", row, _show.bind("main"))
return box
func _karte_fuellen() -> void:
kamp_kopf.text = "Du: %s, Wertung %d · %d Partien, %d Siege" % [
Kampagne.mein_rang(), int(round(Kampagne.elo)), Kampagne.partien, Kampagne.siege]
for c in karte.get_children():
if c is Button:
karte.remove_child(c)
c.queue_free()
karte.queue_redraw()
var groesse := karte.custom_minimum_size
for k in Kampagne.kapitaene:
var b := Button.new()
var weg := int(k.weg)
if weg > 0:
b.text = "%s\nauf See · zurück in %d" % [k.name, weg]
b.disabled = true
else:
b.text = "%s\n%s · %d" % [k.name, ["leicht", "normal", "schwer"][int(k.stufe)], int(round(k.elo))]
b.pressed.connect(_on_kapitaen.bind(String(k.name)))
b.custom_minimum_size = Vector2(170, 52)
var p: Vector2 = k.pos
b.position = Vector2(p.x * groesse.x - 85, p.y * groesse.y + 4)
karte.add_child(b)
func _on_kapitaen(name: String) -> void:
Kampagne.starten(name)
func _on_wieder_rein() -> void: func _on_wieder_rein() -> void:
_show("join") _show("join")
_sagen("Verbinde mit Raum %s …" % Net.letzter_raum.get("code", "")) _sagen("Verbinde mit Raum %s …" % Net.letzter_raum.get("code", ""))
@@ -184,6 +271,7 @@ func _build_main() -> Control:
# Nach einem Verbindungsabbruch: derselbe Name, derselbe Code, ein Klick. # Nach einem Verbindungsabbruch: derselbe Name, derselbe Code, ein Klick.
zurueck_btn = _button("", box, _on_wieder_rein) zurueck_btn = _button("", box, _on_wieder_rein)
zurueck_btn.visible = false zurueck_btn.visible = false
_button("Kampagne", box, _show.bind("kampagne"))
_button("Einzelspieler", box, _show.bind("single")) _button("Einzelspieler", box, _show.bind("single"))
_button("Mehrspieler", box, _show.bind("multi")) _button("Mehrspieler", box, _show.bind("multi"))
_button("Regeln", box, _show.bind("regeln")) _button("Regeln", box, _show.bind("regeln"))
@@ -330,6 +418,7 @@ func _build_lobby() -> Control:
# ------------------------------------------------------------ Aktionen # ------------------------------------------------------------ Aktionen
func _on_solo_start() -> void: func _on_solo_start() -> void:
Kampagne.kampagnenpartie = false
Net.start_singleplayer("Spieler", solo_bots.value, solo_stufe.selected) Net.start_singleplayer("Spieler", solo_bots.value, solo_stufe.selected)

View File

@@ -258,7 +258,9 @@ func _push_lobby(code: String, data: Dictionary, besitzer: int) -> void:
# ── Start ──────────────────────────────────────────────────────────────────── # ── Start ────────────────────────────────────────────────────────────────────
## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots. ## Einzelspieler: kein Netzwerk, nur der lokale Spieler und Bots.
func start_singleplayer(pname: String, ai_count: int, stufe := 1, dice := 5) -> void: ## `bots`: optionale Liste {name, stufe} - die Kampagne setzt so Kapitän und
## Mannschaft mit eigenen Stufen an den Tisch.
func start_singleplayer(pname: String, ai_count: int, stufe := 1, dice := 5, bots: Array = []) -> void:
player_name = pname player_name = pname
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
room_code = SOLO room_code = SOLO
@@ -268,7 +270,7 @@ func start_singleplayer(pname: String, ai_count: int, stufe := 1, dice := 5) ->
in_game = true in_game = true
lobby_changed.emit() lobby_changed.emit()
game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count, game_started.emit(SOLO, {"peers": [1], "names": lobby.duplicate(), "ai": ai_count,
"stufe": stufe, "dice": dice}) "stufe": stufe, "dice": dice, "bots": bots})
## Nach einem Abbruch mit demselben Namen zurück in die laufende Partie. ## Nach einem Abbruch mit demselben Namen zurück in die laufende Partie.

View File

@@ -1,113 +1,38 @@
extends RefCounted extends RefCounted
## Materialien aus Regeln, ohne eine einzige Datei. Je Stoff ein Eintrag: ## Materialien für den Low-Poly-Look: eine flache Farbe, matt, kein Muster,
## Farbverlauf, Rauschart, Reliefstärke, Rauheit, Maßstab. Daraus baut ## kein Relief. Was "Holz" von "Filz" unterscheidet, ist die Farbe und die
## `mach()` ein StandardMaterial3D mit drei zur Laufzeit erzeugten Texturen: ## Form - nicht eine Textur. Die Palette ist pastellig und satt, wie Knete.
## Farbe (Rauschen durch einen Farbverlauf), Relief (dasselbe Rauschen als
## Normalenkarte) und Rauheit. Das Relief ist der Unterschied zwischen
## "gemustertes Plastik" und Filz, Holz, Messing.
##
## Kein class_name: den Klassencache baut nur der Editor, headless fehlt er. ## Kein class_name: den Klassencache baut nur der Editor, headless fehlt er.
const STOFFE := { const FARBEN := {
"filz": { "filz": Color(0.33, 0.70, 0.42),
# Feiner Flaum, kaum Relief, ganz matt. Grobes, glänzendes Relief sah "filz_dunkel": Color(0.26, 0.60, 0.36),
# nach nassem Moos aus. "holz": Color(0.86, 0.68, 0.46),
"farben": [Color(0.06, 0.18, 0.11), Color(0.08, 0.23, 0.14), Color(0.10, 0.26, 0.16)], "holz_dunkel": Color(0.76, 0.42, 0.30),
"art": FastNoiseLite.TYPE_SIMPLEX_SMOOTH, "freq": 0.35, "oktaven": 3, "holz_rand": Color(0.60, 0.40, 0.26),
"relief": 0.12, "rauheit": [0.96, 1.0], "metall": 0.0, "uv": Vector3(14, 14, 1), "messing": Color(0.96, 0.80, 0.36),
"glanz": 0.05, "knochen": Color(0.99, 0.96, 0.90),
}, "boden": Color(0.90, 0.84, 0.72),
"holz": { "glas": Color(1.0, 0.92, 0.55),
"farben": [Color(0.20, 0.12, 0.06), Color(0.36, 0.23, 0.11), Color(0.46, 0.31, 0.16)],
"art": FastNoiseLite.TYPE_SIMPLEX, "freq": 0.03, "oktaven": 5, "warp": 25.0,
"relief": 0.6, "rauheit": [0.45, 0.75], "metall": 0.0,
# stark gestreckt: aus dem Rauschen wird Maserung
"uv": Vector3(1.0, 9.0, 1.0),
},
"holz_dunkel": {
"farben": [Color(0.14, 0.06, 0.04), Color(0.30, 0.12, 0.07), Color(0.40, 0.18, 0.10)],
"art": FastNoiseLite.TYPE_SIMPLEX, "freq": 0.03, "oktaven": 5, "warp": 25.0,
"relief": 0.5, "rauheit": [0.4, 0.7], "metall": 0.0, "uv": Vector3(1.0, 7.0, 1.0),
},
"messing": {
"farben": [Color(0.55, 0.40, 0.14), Color(0.80, 0.62, 0.26), Color(0.95, 0.80, 0.42)],
"art": FastNoiseLite.TYPE_CELLULAR, "freq": 0.12, "oktaven": 2,
"relief": 0.25, "rauheit": [0.2, 0.5], "metall": 0.9, "uv": Vector3(3, 3, 1),
},
"knochen": {
# Farbe kommt von den Vertexfarben (Körper hell, Augen dunkel); hier
# nur feine Dellen und eine warme, leicht fettige Oberfläche.
"farben": [], "art": FastNoiseLite.TYPE_SIMPLEX_SMOOTH, "freq": 0.25, "oktaven": 3,
"relief": 0.2, "rauheit": [0.45, 0.65], "metall": 0.0, "uv": Vector3(4, 4, 1),
},
} }
static var _fertig: Dictionary = {} ## name -> StandardMaterial3D, einmal gebaut static var _fertig: Dictionary = {}
static func _rauschen(r: Dictionary, seed: int) -> FastNoiseLite: ## Ein Stoff, eine Instanz: gleiche Farbe teilt das Material.
var n := FastNoiseLite.new()
n.seed = seed
n.noise_type = r.art
n.frequency = r.freq
n.fractal_octaves = r.oktaven
if r.has("warp"):
n.domain_warp_enabled = true
n.domain_warp_amplitude = r.warp
return n
static func _textur(n: FastNoiseLite, groesse: int) -> NoiseTexture2D:
var t := NoiseTexture2D.new()
t.noise = n
t.width = groesse
t.height = groesse
t.seamless = true
t.generate_mipmaps = true
return t
## Baut das Material nach Regel `name`. Gleiche Regel, gleiches Material:
## alle Becher teilen eine Instanz, so bleibt es ein Renderobjekt je Stoff.
static func mach(name: String) -> StandardMaterial3D: static func mach(name: String) -> StandardMaterial3D:
if _fertig.has(name): if _fertig.has(name):
return _fertig[name] return _fertig[name]
var r: Dictionary = STOFFE[name]
var m := StandardMaterial3D.new() var m := StandardMaterial3D.new()
var basis := _rauschen(r, name.hash() % 1000) m.albedo_color = FARBEN[name]
m.roughness = 0.85
if not r.farben.is_empty(): m.metallic = 0.0
var verlauf := Gradient.new() # Fast kein Glanzpunkt: Knete glänzt nicht, sie leuchtet vom Licht her.
var farben: Array = r.farben m.metallic_specular = 0.15
verlauf.offsets = PackedFloat32Array([0.0, 0.5, 1.0])
verlauf.colors = PackedColorArray([farben[0], farben[1], farben[2]])
var farbe := _textur(basis, 256)
farbe.color_ramp = verlauf
m.albedo_texture = farbe
else:
m.vertex_color_use_as_albedo = true
var relief := _textur(basis, 256)
relief.as_normal_map = true
relief.bump_strength = float(r.relief) * 8.0
m.normal_enabled = true
m.normal_texture = relief
m.normal_scale = 1.0
# Rauheit schwankt mit dem Rauschen zwischen den zwei Werten.
var rau := _textur(_rauschen(r, name.hash() % 1000 + 7), 128)
m.roughness_texture = rau
m.roughness_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_RED
m.roughness = float(r.rauheit[1])
m.metallic = float(r.metall)
m.metallic_specular = float(r.get("glanz", 0.5 if r.metall > 0.5 else 0.3))
m.uv1_scale = r.uv
m.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
_fertig[name] = m _fertig[name] = m
return m return m
## Eigene Instanz, wenn ein Objekt Farbe oder Leuchten ändern muss.
static func eigene(name: String) -> StandardMaterial3D: static func eigene(name: String) -> StandardMaterial3D:
return mach(name).duplicate() return mach(name).duplicate()
@@ -116,6 +41,10 @@ static func filz() -> StandardMaterial3D:
return mach("filz") return mach("filz")
static func filz_dunkel() -> StandardMaterial3D:
return mach("filz_dunkel")
static func holz() -> StandardMaterial3D: static func holz() -> StandardMaterial3D:
return mach("holz") return mach("holz")
@@ -124,22 +53,36 @@ static func holz_dunkel() -> StandardMaterial3D:
return mach("holz_dunkel") return mach("holz_dunkel")
static func boden() -> StandardMaterial3D:
return mach("boden")
static func messing(emission := 0.0) -> StandardMaterial3D: static func messing(emission := 0.0) -> StandardMaterial3D:
if emission <= 0.0:
return mach("messing")
var m := eigene("messing") var m := eigene("messing")
if emission > 0.0: m.emission_enabled = true
m.emission_enabled = true m.emission = Color(1.0, 0.80, 0.35)
m.emission = Color(1.0, 0.75, 0.30) m.emission_energy_multiplier = emission
m.emission_energy_multiplier = emission
return m return m
## Würfel: Farbe kommt aus den Vertexfarben (Körper hell, Augen dunkel).
static func knochen() -> StandardMaterial3D: static func knochen() -> StandardMaterial3D:
return mach("knochen") if _fertig.has("knochen"):
return _fertig["knochen"]
var m := StandardMaterial3D.new()
m.vertex_color_use_as_albedo = true
m.roughness = 0.8
m.metallic_specular = 0.15
_fertig["knochen"] = m
return m
## Zonenrand: Holz, das leuchten kann, wenn der Platz dran ist. ## Zonenrand: dunkleres Holz als die Becher, damit sich beides trennt; kann
## leuchten, wenn der Platz dran ist.
static func rand() -> StandardMaterial3D: static func rand() -> StandardMaterial3D:
var m := eigene("holz") var m := eigene("holz_rand")
m.emission = Color(1.0, 0.78, 0.30) m.emission = Color(1.0, 0.78, 0.30)
m.emission_enabled = false m.emission_enabled = false
return m return m

View File

@@ -12,7 +12,7 @@ const Stoffe = preload("res://scripts/stoffe.gd") ## Materialien aus Regeln, o
const Formen = preload("res://scripts/formen.gd") ## Becher, Fass, Laterne aus Primitiven 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_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 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 TILT_DEG := 45.0 ## Neigung der Kamera gegen die Senkrechte
const HUD_TOP := 0.13 ## Bildanteil oben für die Gebotsanzeige const HUD_TOP := 0.13 ## Bildanteil oben für die Gebotsanzeige
const HUD_BOTTOM := 0.17 ## Bildanteil unten für die Ansage-Leiste const HUD_BOTTOM := 0.17 ## Bildanteil unten für die Ansage-Leiste
@@ -33,7 +33,7 @@ var _bid_anchor: Array[Vector3] = []
var plates: Array[Sprite3D] = [] var plates: Array[Sprite3D] = []
var rims: Array[MeshInstance3D] = [] ## Rand der Zone, leuchtet beim Zug var rims: Array[MeshInstance3D] = [] ## Rand der Zone, leuchtet beim Zug
var topf_muenzen: Dictionary = {} ## Wurfnummer -> Münze in der Tischmitte var topf_muenzen: Dictionary = {} ## Wurfnummer -> Münze in der Tischmitte
static var _muenz_mesh: CylinderMesh static var _muenz_mesh: ArrayMesh
static var _muenz_mat: StandardMaterial3D static var _muenz_mat: StandardMaterial3D
var dice: Array = [] ## seat -> Array[Die] var dice: Array = [] ## seat -> Array[Die]
@@ -62,96 +62,80 @@ func build(count: int, local_index: int) -> void:
## Formregeln bleibt weg: vom Boden aus ist es aus Kamerahöhe nur ein Fleck. ## Formregeln bleibt weg: vom Boden aus ist es aus Kamerahöhe nur ein Fleck.
func _deko() -> void: func _deko() -> void:
var laterne := Formen.laterne() var laterne := Formen.laterne()
laterne.position = Vector3(-(table_r + 0.35), TABLE_H, table_r * 0.55) laterne.position = Vector3(-(table_r + 0.45), 0.0, table_r * 0.55) # steht auf der Platte
add_child(laterne) add_child(laterne)
func _build_room() -> void: 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 env := WorldEnvironment.new()
var e := Environment.new() var e := Environment.new()
e.background_mode = Environment.BG_COLOR e.background_mode = Environment.BG_COLOR
e.background_color = Color(0.04, 0.05, 0.07) e.background_color = Color(0.56, 0.74, 0.88)
e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
e.ambient_light_color = Color(0.35, 0.38, 0.45) e.ambient_light_color = Color(0.70, 0.78, 0.92)
e.ambient_light_energy = 1.1 e.ambient_light_energy = 0.75
e.tonemap_mode = Environment.TONE_MAPPER_FILMIC e.tonemap_mode = Environment.TONE_MAPPER_FILMIC
e.glow_enabled = true e.tonemap_exposure = 0.9
e.glow_intensity = 0.45
e.glow_bloom = 0.05
e.glow_hdr_threshold = 0.9
e.fog_enabled = true
e.fog_light_color = Color(0.05, 0.06, 0.09)
e.fog_density = 0.02
env.environment = e env.environment = e
add_child(env) add_child(env)
var lamp := SpotLight3D.new() var sonne := DirectionalLight3D.new()
lamp.position = Vector3(0, TABLE_H + table_r * 1.45, 0) sonne.light_color = Color(1.0, 0.96, 0.90)
lamp.rotation_degrees = Vector3(-90, 0, 0) sonne.light_energy = 1.1
lamp.light_energy = 2.5 sonne.shadow_enabled = true
lamp.light_color = Color(1.0, 0.94, 0.82) sonne.light_angular_distance = 3.0 # weiche Schattenkanten
lamp.spot_range = table_r * 4.2 sonne.directional_shadow_mode = DirectionalLight3D.SHADOW_ORTHOGONAL
lamp.spot_angle = 60.0 sonne.directional_shadow_max_distance = 30.0
lamp.spot_attenuation = 0.6 sonne.rotation_degrees = Vector3(-52.0, 35.0, 0.0)
lamp.shadow_enabled = true add_child(sonne)
add_child(lamp)
# Kein sichtbarer Lampenschirm: die Kamera schaut fast senkrecht von oben, # Sockelplatte wie ein Diorama: eine runde Scheibe mit weicher Kante,
# alles über der Tischmitte hinge ihr vor der Linse. # 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)
var fill := DirectionalLight3D.new() # Tischplatte: Holz mit runder Kante, darauf die Filzeinlage.
fill.rotation_degrees = Vector3(-55, 35, 0) var tisch := MeshInstance3D.new()
fill.light_energy = 0.25 tisch.mesh = Formen.scheibe(table_r, 0.10, 0.045)
add_child(fill) tisch.position = Vector3(0, TABLE_H - 0.10, 0)
tisch.material_override = Stoffe.holz()
# Boden add_child(tisch)
var floor_body := StaticBody3D.new() var einlage := MeshInstance3D.new()
var floor_col := CollisionShape3D.new() einlage.mesh = Formen.scheibe(table_r - 0.16, 0.025, 0.012)
var fs := BoxShape3D.new() einlage.position = Vector3(0, TABLE_H - 0.012, 0)
fs.size = Vector3(24, 0.4, 24) einlage.material_override = Stoffe.filz()
floor_col.shape = fs add_child(einlage)
floor_col.position = Vector3(0, -0.2, 0) var tcol := StaticBody3D.new()
floor_body.add_child(floor_col) var tc := CollisionShape3D.new()
var floor_mi := MeshInstance3D.new()
var fpm := PlaneMesh.new()
fpm.size = Vector2(24, 24)
floor_mi.mesh = fpm
# Bewusst glatt und dunkel: eine Maserung auf der riesigen Fläche wird aus
# Kamerahöhe zu Rauschen, und der Nebel soll den Boden ohnehin schlucken.
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() var tcs := CylinderShape3D.new()
tcs.radius = table_r tcs.radius = table_r
tcs.height = 0.12 tcs.height = 0.12
tcol.shape = tcs tc.shape = tcs
tcol.position = Vector3(0, TABLE_H - 0.06, 0) # Oberkante auf Höhe des Zonenfilzes, damit Würfel darauf liegen, nicht darin.
table.add_child(tcol) tc.position = Vector3(0, TABLE_H - 0.048, 0)
var tmi := MeshInstance3D.new() tcol.add_child(tc)
var tcm := CylinderMesh.new() add_child(tcol)
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 = Stoffe.filz()
table.add_child(tmi)
add_child(table)
# Tischbein # Ein dicker Mittelfuss statt vier Beinen - aus Kamerahöhe sieht man ohnehin
var leg := MeshInstance3D.new() # nur den Ansatz, und rund passt zur Knete.
var lcm := CylinderMesh.new() var fuss := MeshInstance3D.new()
lcm.top_radius = table_r * 0.09 fuss.mesh = Formen.rund_zylinder(0.55, 0.35, TABLE_H - 0.10, 0.06)
lcm.bottom_radius = table_r * 0.22 fuss.material_override = Stoffe.holz()
lcm.height = TABLE_H - 0.12 add_child(fuss)
leg.mesh = lcm
leg.position = Vector3(0, (TABLE_H - 0.12) * 0.5, 0)
leg.material_override = Stoffe.holz()
add_child(leg)
func _build_seat(i: int) -> void: func _build_seat(i: int) -> void:
@@ -180,24 +164,14 @@ func _build_seat(i: int) -> void:
# Filzfläche # Filzfläche
var felt := MeshInstance3D.new() var felt := MeshInstance3D.new()
var fm := CylinderMesh.new() felt.mesh = Formen.scheibe(ZONE_RAD + 0.02, 0.012, 0.006)
fm.top_radius = ZONE_RAD felt.position = Vector3(0, 0.0, 0)
fm.bottom_radius = ZONE_RAD felt.material_override = Stoffe.filz_dunkel()
fm.height = 0.012
fm.radial_segments = 32
felt.mesh = fm
felt.position = Vector3(0, 0.006, 0)
felt.material_override = Stoffe.filz()
zone.add_child(felt) zone.add_child(felt)
# Sichtbarer Rand # Sichtbarer Rand
var rim := MeshInstance3D.new() var rim := MeshInstance3D.new()
var tm := TorusMesh.new() rim.mesh = Formen.ring(ZONE_RAD + 0.045, 0.035)
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.position = Vector3(0, 0.02, 0)
rim.material_override = Stoffe.rand() rim.material_override = Stoffe.rand()
zone.add_child(rim) zone.add_child(rim)
@@ -287,6 +261,10 @@ func _frame_camera() -> void:
var band := 1.0 - HUD_TOP - HUD_BOTTOM var band := 1.0 - HUD_TOP - HUD_BOTTOM
var half_v := tan(fov_v * 0.5) var half_v := tan(fov_v * 0.5)
var dist := (table_r * cos(tilt)) / (band * half_v) 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. # Waagerecht ist normalerweise reichlich Platz, in schmalen Fenstern nicht.
dist = maxf(dist, table_r / (0.98 * tan(fov_v * 0.5) * aspect)) 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. # Das freie Band liegt nicht mittig, der Tisch rückt entsprechend nach oben.
@@ -527,7 +505,7 @@ func set_topf(liste: Array, ziel := -1) -> void:
var ang := r.randf() * TAU var ang := r.randf() * TAU
var rad := r.randf_range(0.30, 0.55) var rad := r.randf_range(0.30, 0.55)
var wappen: bool = m.get("stark", false) var wappen: bool = m.get("stark", false)
var lage := Vector3(cos(ang) * rad, TABLE_H + 0.007, sin(ang) * rad) var lage := Vector3(cos(ang) * rad, TABLE_H + 0.013, sin(ang) * rad) # auf der Einlage
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
mi.mesh = _muenz_form() mi.mesh = _muenz_form()
mi.material_override = _muenz_stoff(wappen) mi.material_override = _muenz_stoff(wappen)
@@ -565,15 +543,11 @@ static var _praeg_mesh: TorusMesh
static var _praeg_mat: StandardMaterial3D static var _praeg_mat: StandardMaterial3D
static func _muenz_form() -> CylinderMesh: static func _muenz_form() -> ArrayMesh:
if _muenz_mesh == null: if _muenz_mesh == null:
_muenz_mesh = CylinderMesh.new()
# Aus Kamerahöhe muss eine Münze fast so gross sein wie ein Würfel, # Aus Kamerahöhe muss eine Münze fast so gross sein wie ein Würfel,
# sonst ist sie ein Punkt. # sonst ist sie ein Punkt.
_muenz_mesh.top_radius = 0.11 _muenz_mesh = Formen.scheibe(0.11, 0.016, 0.006)
_muenz_mesh.bottom_radius = 0.11
_muenz_mesh.height = 0.014
_muenz_mesh.radial_segments = 24
return _muenz_mesh return _muenz_mesh

78
tests/kampagne.gd Normal file
View File

@@ -0,0 +1,78 @@
extends "res://tests/basis.gd"
## Kampagne: Wertung, Rang, Verschwinden und Rückkehr der Kapitäne,
## Speichern und Laden, und eine echte Partie gegen einen Kapitän im
## Simulationsmodus.
var game: Node
func _ready() -> void:
Kampagne.datei = "user://kampagne_test.json"
Kampagne.zuruecksetzen()
add_child(preload("res://scenes/main.tscn").instantiate())
await get_tree().process_frame
_lauf()
func _seats(alive: Array, namen: Array) -> Array:
var out: Array = []
for i in namen.size():
out.append({"name": namen[i], "alive": alive[i]})
return out
func _lauf() -> void:
# --- Wertung und Rang
_pruef("Start: 1000, Matrose", int(Kampagne.elo) == 1000 and Kampagne.mein_rang() == "Matrose")
_pruef("Rang steigt mit der Wertung", Kampagne.rang(1300) == "Kapitän" and Kampagne.rang(800) == "Schiffsjunge")
# --- Sieg: Kapitän verschwindet, Wertung steigt
Kampagne.aktiv = "Roter Jack"
Kampagne.ergebnis(_seats([true, false], ["Spieler", "Roter Jack"]), [1])
var jack: Dictionary = Kampagne.kapitaen("Roter Jack")
_pruef("Sieg: Wertung steigt", Kampagne.elo > 1000.0)
_pruef("Sieg: Kapitän geht auf See", int(jack.weg) == Kampagne.AUF_SEE and int(jack.geschlagen) == 1)
_pruef("Ergebnistext steht", Kampagne.letztes.begins_with("Sieg gegen Roter Jack"))
Kampagne.starten("Roter Jack")
_pruef("Kein Start gegen einen auf See", Kampagne.aktiv == "" and _find(get_tree().root, "Game") == null)
var vorher := Kampagne.elo
# --- Niederlage: Spieler fliegt vor dem Kapitän
Kampagne.aktiv = "Salz-Marie"
Kampagne.ergebnis(_seats([false, false, true], ["Spieler", "Salz-Marie", "Maat 1"]), [0, 1])
_pruef("Niederlage: Wertung sinkt", Kampagne.elo < vorher)
_pruef("Niederlage: Kapitänin bleibt", int(Kampagne.kapitaen("Salz-Marie").weg) == 0)
_pruef("Jack rückt näher", int(Kampagne.kapitaen("Roter Jack").weg) == Kampagne.AUF_SEE - 1)
# --- Sieg, weil der Kapitän vor dem Spieler ging, obwohl ein Maat gewann
Kampagne.aktiv = "Salz-Marie"
Kampagne.ergebnis(_seats([false, false, true], ["Spieler", "Salz-Marie", "Maat 1"]), [1, 0])
_pruef("Kapitän vor mir raus zählt als Sieg", int(Kampagne.kapitaen("Salz-Marie").weg) == Kampagne.AUF_SEE)
jack = Kampagne.kapitaen("Roter Jack")
_pruef("Jack ist zurück, stärker", int(jack.weg) == 0 and int(jack.stufe) == 1 and float(jack.elo) > 900.0)
# --- Speichern und Laden
var elo_alt := Kampagne.elo
Kampagne.speichern()
Kampagne.elo = 0.0
Kampagne.laden()
_pruef("Laden stellt Wertung und Kapitäne wieder her",
absf(Kampagne.elo - elo_alt) < 0.01 and int(Kampagne.kapitaen("Salz-Marie").weg) == Kampagne.AUF_SEE
and int(Kampagne.kapitaen("Roter Jack").stufe) == 1)
# --- Echte Partie gegen Jack im Simulationsmodus
var partien_vorher := Kampagne.partien
Kampagne.starten("Roter Jack")
await _warte(func(): game = _find(get_tree().root, "Game"); return game != null, 20.0)
_pruef("Partie gegen den Kapitän steht", game != null and game.seats.size() == 2
and String(game.seats[1].name) == "Roter Jack" and int(game.seats[1].stufe) == 1)
game.tempo = 0.0
game.simulation = true
game.seats[game.local_seat].ai = true
await _warte(func(): return game.phase == "over", 120.0)
_pruef("Partie endet", game.phase == "over")
_pruef("Ergebnis verbucht", Kampagne.partien == partien_vorher + 1 and Kampagne.aktiv == "" and Kampagne.letztes != "")
_pruef("Endfeld zeigt Wertung", game.hud.over_slot.visible and Kampagne.letztes in game.hud.over_title.text
and game.hud.over_karte.visible)
DirAccess.remove_absolute(ProjectSettings.globalize_path(Kampagne.datei))
_fertig()

6
tests/kampagne.tscn Normal file
View File

@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/kampagne.gd" id="1"]
[node name="Test" type="Node"]
script = ExtResource("1")

View File

@@ -22,6 +22,7 @@ starte() { # name, timeout, argumente... -> Hintergrund
starte logik 90 starte logik 90
starte genau 90 starte genau 90
starte kampagne 90
starte nochmal 60 starte nochmal 60
starte bots 90 2 starte bots 90 2
rm -f "$TMP/code.txt" "$TMP/code2.txt" rm -f "$TMP/code.txt" "$TMP/code2.txt"
@@ -52,7 +53,7 @@ done
wait 2>/dev/null wait 2>/dev/null
ergebnis=0 ergebnis=0
for name in logik genau nochmal bots mp_start:host mp_start:join mp_start:host2 mp_start:join2 mp_start:srv; do for name in logik genau kampagne nochmal bots mp_start:host mp_start:join mp_start:host2 mp_start:join2 mp_start:srv; do
log="$TMP/$name.log" log="$TMP/$name.log"
echo "=== $name" echo "=== $name"
grep -E "^(OK |FEHL|SERVER:| |TEST:)" "$log" grep -E "^(OK |FEHL|SERVER:| |TEST:)" "$log"

View File

@@ -19,6 +19,9 @@ func _ready() -> void:
main.menu._show("main") main.menu._show("main")
await get_tree().create_timer(0.2).timeout await get_tree().create_timer(0.2).timeout
await _bild("menue_start") await _bild("menue_start")
main.menu._show("kampagne")
await get_tree().create_timer(0.2).timeout
await _bild("menue_seekarte")
Net.start_singleplayer("Tester", 3, 1) Net.start_singleplayer("Tester", 3, 1)
_lauf() _lauf()