diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c646b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Python bytecode from tools/ — generated on every validator run, never committed. +__pycache__/ +*.py[cod] diff --git a/content/approved/items/vault_items.json b/content/approved/items/vault_items.json new file mode 100644 index 0000000..9de38ab --- /dev/null +++ b/content/approved/items/vault_items.json @@ -0,0 +1,11 @@ +[ + { + "id": "item_threshold_stone", + "name": "Threshold Stone", + "category": "key_item", + "rarity": "rare", + "value": 0, + "description": "The keystone from the Perceptron Vault's output gate, still warm. It only ever answered one question — enough, or not enough — and it answered honestly every time.", + "found_in": ["datasedge_meadows"] + } +] diff --git a/content/approved/monsters/vault_monsters.json b/content/approved/monsters/vault_monsters.json new file mode 100644 index 0000000..b878a2b --- /dev/null +++ b/content/approved/monsters/vault_monsters.json @@ -0,0 +1,18 @@ +[ + { + "id": "mon_the_gatekeeper", + "name": "the Gatekeeper", + "regions": ["datasedge_meadows"], + "tier": "dungeon_boss", + "hearts": 1, + "attack": 0.5, + "behavior": "tank", + "spawn": { "time_of_day": "any", "weather": "any" }, + "drops": [ + { "item_id": "item_threshold_stone", "chance": 1 }, + { "item_id": "item_data_shard", "chance": 0.5 } + ], + "ml_flavor": "A threshold unit made walkable: it adds up everything that reaches it and does nothing at all until the total crosses its line.", + "description": "A doorway three times Kern's height that unmoored itself from its own arch, and the last thing the vault's builders left awake. It keeps a running total of everything that touches it and opens for nothing else. Half the time it draws a blow in; the rest of the time it refuses, and a blow landed then is a blow taken back. Overload it and it comes apart into the sum it was holding." + } +] diff --git a/content/approved/pois/perceptron_vault.json b/content/approved/pois/perceptron_vault.json new file mode 100644 index 0000000..73eb7c5 --- /dev/null +++ b/content/approved/pois/perceptron_vault.json @@ -0,0 +1,12 @@ +[ + { + "id": "poi_perceptron_vault", + "name": "The Perceptron Vault", + "region": "datasedge_meadows", + "kind": "mini_dungeon", + "description": "A half-buried monolith south-east of Bootstrap, its facade carved with two marks, then three, then one. Inside: a hall of founts, three chambers that each open for a sum rather than a key, and whatever has been counting in the dark since before the town had a mayor.", + "reward_items": ["item_threshold_stone"], + "reward_tokens": 120, + "bit_sense": false + } +] diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 8d98c58..80ab2b0 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -4,6 +4,43 @@ --- +## 2026-07-27 (repository recovery) — Vault and Iris branches integrated + +**DONE** +- Recovered the previously stranded milestone-12 branch onto the current + post-split `main` without replacing newer Kern, town, inventory, sky, or + meadow work. +- Integrated the 2-3-1 walkable network dungeon, Gatekeeper encounter, + threshold/fount/weight-stone interactions, approved vault content, rune + shader, and five dungeon screenshot angles. +- Reconciled screenshot positioning so existing terrain-relative, posed-Kern, + and new absolute-height interior shots share one capture path. +- Recovered the milestone-13 Iris field and compendium without reverting the + newer photoreal multi-carpet grass system: 700 blooms now carry generated + Fisher-statistic specimens, proximity collection, boundary-bloom markings, + and a J/Start field notebook. +- Restored the six missing milestone-8 Bootstrap progress frames (square, + market, inn, hall, night, and dialogue) from their completed remote branch. +- Resolved the pre-existing controller collision between the pack and field + notebook: Back/Select remains the pack; Start/Menu now opens the compendium. + +**VERIFIED** +- `validate_content.py --inbox` and `--all` pass (111 approved entries). +- Godot 4.7.1 completed a clean editor parse/import and registered all 56 + script classes, including every recovered vault and Iris class. +- A 25-second clean runtime smoke boot built the 2-3-1 vault and generated 700 + Iris blooms over 150 specimens (15 boundary blooms) with zero script or + runtime errors. +- The restored Bootstrap square and dialogue frames were opened and inspected; + the six PNGs are valid progress evidence. + +**NEXT UP** +- A future live visual session should still capture and inspect the recovered + vault interiors and hands-on Iris collection/compendium interaction under + the shipping Forward+ renderer, per the project's visual-quality rule. + +--- + ## 2026-07-24 (live session, Kern/character lane) — BASE MESH GENERATED; FITTING GATED ON AN ENGINE BUG *Commits `613652f` → `8aaca35` (branch fast-forwarded to `main`). Danny gave diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 972b481..2d8cf78 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,12 +30,12 @@ Prove every system small, then scale outward. One region done completely. - [x] Bit the fairy: follow behavior, look-at naming, hint lines *(built: exp-smoothed hover-follow with idle orbit/bob, sprint catch-up, and canon water-fear over the millpond; BitLandmark look-at naming across 8 canon meadow sites (remembered in save flags); in-voice barks — greeting, idle/hint, quiz/item/region reactions — on a floating Label3D + EventBus.bit_spoke. **UNSEEN**: no Godot in this env — needs a live session to import (.uid gen), confirm clean boot, and eyes per GDD §10)* - [x] Combat v1: sword (combo, dodge, block), enemy AI (melee + ranged), hearts, data-shard death VFX *(built: 3-hit sword combo with soft-target facing, roll-dodge with i-frames, hold-block + tight parry; juice = hitstop, knockback, trauma camera-shake; data-driven `Enemy` brain (melee/ranged/swarm/dummy) with wind-up telegraphs, drop rolls → ContentDB/GameState, and canon shard dissolves; reusable `Health` hearts + a minimal combat HUD (hearts/focus/damage-vignette — the full HUD stays its own later milestone); `MonsterSpawner` fields the approved Stray Glitchling and stands up a proving ground (melee/ranged/dummy sparring rigs) until batch_04's monsters land; a focus/knowledge-charge special is wired as the hook milestone 7 fills.* **UNSEEN**: no Godot in this env — a live session must import (generate `.uid`s), confirm a clean boot, and lay eyes/fight per GDD §10 before this ticks fully clean)* - [x] Knowledge charge v1: quiz prompt in combat charges a special ability *(built to Danny's design: the focus special is a combined Kern+Bit attack CAST by answering — Q at part-charge opens a code-built quiz card (slow-mo + safe, real-time countdown, difficulty-gated per WORLDBOOK, explanation shown every answer); correct answers feed the existing meter and the strike auto-fires when it fills; wrong/timeout fizzles but keeps focus; Bit flies in to channel with new in-voice lines. New `QuizPicker` + `KnowledgePrompt`; wired through the milestone-6 hooks (`quiz_answered`→`add_charge`).* **UNSEEN**: built alongside the visual sessions without touching the editor — a live session must import (gen `.uid`s), boot, and cast/fizzle/complete a channel on eyes per GDD §10 before this ticks fully clean)* -- [ ] Town of Bootstrap: buildings, 6–8 NPCs (mixed personalities), dialogue UI +- [x] Town of Bootstrap: buildings, 6–8 NPCs (mixed personalities), dialogue UI *(built: 13 approved villagers across a procedural town square, market, inn, hall, forge, mill, homes, and dusk-lit streets; proximity dialogue has prompts, typewriter text, shuffled greetings/idle/farewell lines, and first-meeting persistence. The recovered six-frame milestone-8 set documents the square, market, inn, hall, night lighting, and dialogue composition; the current integrated build also completed a clean Godot 4.7.1 runtime smoke boot.)* - [ ] Quest system + journal: main hook quest + 3 side quests (from content DB) - [ ] Inventory, items, Tokens, one vendor - [ ] Crafting v1: recipes at a campfire/bench -- [ ] Dungeon 1: **the Perceptron Vault** — traverse an actual neural network; 1 boss -- [ ] Iris flowers as collectible flora + compendium v1 +- [x] Dungeon 1: **the Perceptron Vault** — traverse an actual neural network; 1 boss *(built: the vault IS a 2-3-1 network laid along its own axis — two Signal Founts (input bits, struck to toggle) → three walk-in Neuron Chambers, each with two tunable Weight Stones on restricted ladders, a sum column and a room-wide threshold ring → a Junction carrying the output cell's three weights → **the Gatekeeper**, a boss with no hearts that sums what hits it and alternates polarity, so a blow landed while it refuses is a blow taken back → the output gate and the reliquary. Both routes work by design (GDD §3): solve the network and it feeds the Gatekeeper while you dodge, or overload it by sword alone against its leak. Puzzle verified exhaustively offline — 12 of 2048 configurations open it, both founts must be lit, two chambers have exactly one correct weight pair. Also: 2-3-1 sigil on facade and gate (matching `icon.svg`), self-surveying plinth + approach ramp, `mon_the_gatekeeper` + `item_threshold_stone` + `poi_perceptron_vault` through the pipeline, five new screenshot angles. The recovered build completed a clean Godot 4.7.1 parse/import and 25-second runtime smoke boot; a future live Forward+ session should still inspect the interior shots per GDD §10.)* +- [x] Iris flowers as collectible flora + compendium v1 *(built: the 700 blooms of the western flats now carry real Iris specimen records — sepal length, petal length, petal width in cm, drawn from Fisher 1936's published per-family statistics, so the family separations, the spread, and the versicolor/virginica overlap are all true. Collection is proximity, not a button: walk through a bloom and Kern presses it, it shrinks away, and the specimen enters the compendium. `CompendiumUi` (J, or Start/Menu on a pad) shows three family columns with each pressed specimen's triplet, calls out the ones its nearest-centroid classifier gets WRONG, and keeps a boundary-bloom roll — ambiguous specimens wear their own near-white and are the collector prize. Verified offline over 300 seeds: setosa stays perfectly separable, 100% of confusion sits in the real versicolor/virginica overlap, and the boundary rate was retuned from a broken 1.7% (some meadows had none at all) to ~11%. Progress rides in `GameState.flags` as one bool per specimen — no save-shape change and no SAVE_VERSION bump. Irises moved out of `meadow_flora.gd` into their own `IrisField`, same seed and clusters, so the flats look exactly as they did. The recovered build completed a clean Godot 4.7.1 parse/import and runtime smoke boot; hands-on collection and notebook interaction remain a future live visual check.)* - [ ] Save/load: versioned save format, multiple slots, title screen - [ ] HUD: hearts, Tokens, minimap v1 diff --git a/docs/progress/milestone8_dialogue.png b/docs/progress/milestone8_dialogue.png new file mode 100644 index 0000000..39b4f22 Binary files /dev/null and b/docs/progress/milestone8_dialogue.png differ diff --git a/docs/progress/milestone8_hall.png b/docs/progress/milestone8_hall.png new file mode 100644 index 0000000..2a643b2 Binary files /dev/null and b/docs/progress/milestone8_hall.png differ diff --git a/docs/progress/milestone8_inn_mara.png b/docs/progress/milestone8_inn_mara.png new file mode 100644 index 0000000..f35a5f3 Binary files /dev/null and b/docs/progress/milestone8_inn_mara.png differ diff --git a/docs/progress/milestone8_market.png b/docs/progress/milestone8_market.png new file mode 100644 index 0000000..f7789d9 Binary files /dev/null and b/docs/progress/milestone8_market.png differ diff --git a/docs/progress/milestone8_night.png b/docs/progress/milestone8_night.png new file mode 100644 index 0000000..78bbb3b Binary files /dev/null and b/docs/progress/milestone8_night.png differ diff --git a/docs/progress/milestone8_square.png b/docs/progress/milestone8_square.png new file mode 100644 index 0000000..87b9c75 Binary files /dev/null and b/docs/progress/milestone8_square.png differ diff --git a/game/assets/shaders/vault_rune.gdshader b/game/assets/shaders/vault_rune.gdshader new file mode 100644 index 0000000..59441c5 --- /dev/null +++ b/game/assets/shaders/vault_rune.gdshader @@ -0,0 +1,95 @@ +shader_type spatial; +// depth_prepass_alpha: the shader writes ALPHA, which makes every surface +// using it transparent. Without a prepass the glass-tube columns sort badly +// against each other; with it they behave like solids where alpha is 1. +render_mode unshaded, cull_disabled, depth_prepass_alpha; +// The Perceptron Vault's one light-language: every glowing surface in the +// dungeon — signal conduits, weight glyphs, sum columns, threshold beams, +// door runes, the Gatekeeper's charge core — is this shader with different +// uniforms. Unshaded on purpose: these read as light sources embedded in the +// stone, so they must stay bright inside a sealed vault where the sun never +// reaches (GDD §10 "nothing pops in or out without a transition"). +// +// Three independent effects, each switchable to zero cost by its amount +// uniform: +// * pulse — a slow breathing brightness (idle runes) +// * flow — a travelling bright band along local +Y (signal in a conduit) +// * fill — a hard cutoff along local Y, so one mesh can act as a meter +// (sum columns, the Gatekeeper's charge core) +// +// Conventions: meshes are authored with their long axis on local Y, and +// `fill` is a 0..1 fraction of that axis measured from the bottom. Alpha +// below `fill` is 1 and above it is `dim_alpha`, so an unfilled column still +// reads as a glass tube rather than vanishing. + +group_uniforms color; +uniform vec4 glow_color : source_color = vec4(1.0, 0.78, 0.32, 1.0); +// Multiplies the final emission. Above 1.0 the bloom in the world +// environment takes over and the surface blooms — that is the intent. +uniform float energy : hint_range(0.0, 8.0) = 2.4; + +group_uniforms pulse; +// Fraction of `energy` that breathes (0 = steady, 1 = full on/off). +uniform float pulse_amount : hint_range(0.0, 1.0) = 0.18; +uniform float pulse_speed : hint_range(0.0, 8.0) = 1.6; +// Phase offset in radians, so neighbouring runes do not breathe in lockstep. +uniform float pulse_phase = 0.0; + +group_uniforms flow; +// Brightness of the travelling band, in multiples of `energy`. +uniform float flow_amount : hint_range(0.0, 3.0) = 0.0; +uniform float flow_speed = 1.2; +// Bands per meter of local Y. Lower = longer, lazier pulses of signal. +uniform float flow_density = 0.35; + +group_uniforms meter; +// 1.0 = the whole mesh is lit. Below that, the part above the cutoff drops +// to dim_alpha — the mesh becomes a fill meter. +uniform float fill : hint_range(0.0, 1.0) = 1.0; +// Local-Y extent (meters) the fill fraction is measured against. Set from +// code to the mesh's height so `fill` stays a plain 0..1 fraction. +uniform float fill_height = 1.0; +// Local-Y coordinate of the mesh's bottom. BoxMesh is origin-centered, so +// this is normally -height/2. +uniform float fill_base = -0.5; +uniform float dim_alpha : hint_range(0.0, 1.0) = 0.12; + +varying vec3 v_local; + +void vertex() { + v_local = VERTEX; +} + +void fragment() { + float e = energy; + + // Breathing. sin() is remapped to 0..1 so pulse_amount is a clean + // fraction of energy rather than a signed swing. + if (pulse_amount > 0.001) { + float p = sin(TIME * pulse_speed + pulse_phase) * 0.5 + 0.5; + e *= 1.0 - pulse_amount + pulse_amount * p; + } + + // Travelling band: a sawtooth along local Y, sharpened by smoothstep so + // the band has a bright head and a soft tail — signal moving, not a + // blinking stripe. + if (flow_amount > 0.001) { + float band = fract(v_local.y * flow_density - TIME * flow_speed); + e += energy * flow_amount * smoothstep(0.55, 1.0, band); + } + + float alpha = 1.0; + if (fill < 0.999) { + // Height of the fill line in local space. + float cut = fill_base + fill * fill_height; + // Two-pixel-ish soft edge so the meter's top does not crawl and + // alias as it rises. + float lit = 1.0 - smoothstep(cut - 0.02, cut + 0.02, v_local.y); + alpha = mix(dim_alpha, 1.0, lit); + e *= mix(0.25, 1.0, lit); + } + + ALBEDO = glow_color.rgb; + EMISSION = glow_color.rgb * e; + ALPHA = glow_color.a * alpha; +} diff --git a/game/assets/shaders/vault_rune.gdshader.uid b/game/assets/shaders/vault_rune.gdshader.uid new file mode 100644 index 0000000..e9558a6 --- /dev/null +++ b/game/assets/shaders/vault_rune.gdshader.uid @@ -0,0 +1 @@ +uid://dk81l4p56g15i diff --git a/game/src/dungeon/neuron_chamber.gd b/game/src/dungeon/neuron_chamber.gd new file mode 100644 index 0000000..255ec47 --- /dev/null +++ b/game/src/dungeon/neuron_chamber.gd @@ -0,0 +1,239 @@ +class_name NeuronChamber +extends Node3D +## One chamber of the Perceptron Vault's hidden row — a single neuron you can +## walk around inside. +## +## The chamber takes two signals in, multiplies each by the weight sitting in +## its socket, stacks the results into the sum column at the room's center, +## and fires if that column reaches the threshold beam. That is the whole of +## `y = step(w1*x1 + w2*x2 - θ)`, at walking scale, with the weights as +## objects you can hit. +## +## Reading the room, with no text at all: +## * the two wall sockets are the inputs — gold stone adds, blue takes away +## * the column in the middle is the running sum; gold rising means the sum +## is positive, blue means it went the wrong way +## * the ring of light around the room at a fixed height is the threshold +## * column over ring = the chamber fires, the room warms, the outbound +## conduit lights and carries signal onward +## +## Where it sits: built by PerceptronVault, which owns the two SignalFounts +## and calls `recompute()` whenever any fount or stone changes. The chamber +## owns its two WeightStones and reports its own firing state upward; it +## never reaches for the network around it. +## +## Units: sums are plain integers (weights are integers, inputs are bits), and +## the column converts them to meters through `METERS_PER_UNIT`. + +## Emitted when the chamber's firing state changes, so the vault can relight +## the conduits into the output chamber. +signal fired_changed(firing: bool) +## Emitted when one of this chamber's weight stones was struck. The chamber +## cannot see the founts, so it asks the vault to re-run the whole network +## rather than guessing — one code path for every recompute. +signal needs_recompute() + +## How tall one unit of sum stands. With weights in ±2 and two inputs the sum +## spans -4..4, so the column tops out at 3.6 m inside an 8 m room. +const METERS_PER_UNIT: float = 0.9 +## Interior clear volume of a chamber, meters (x, y, z). +const INNER: Vector3 = Vector3(14.0, 8.0, 18.0) +const WALL_THICKNESS: float = 1.2 +## Door opening cut through the north and south walls. +const DOOR_WIDTH: float = 5.0 +const DOOR_HEIGHT: float = 4.6 +## The sum column's footprint. Tall and thin so its top edge is unambiguous. +const COLUMN_WIDTH: float = 1.15 +## Full height of the column mesh — the meter's 100% mark. Sized to the +## largest sum the ladders can produce so the fill never clips. +const COLUMN_HEIGHT: float = 4.0 +## Seconds the column takes to slide to a new sum. Instant would make the +## cause-and-effect of a strike harder to read, not easier. +const COLUMN_EASE: float = 6.0 + +## Display name, used on the chamber's plaque ("the First Chamber"). +var chamber_name: String = "Chamber" +## The sum this chamber must reach to fire — its bias, with the sign flipped +## into the form a stonecutter would carve. +var threshold: int = 1 +## True while `sum >= threshold`. +var firing: bool = false +## The chamber's live weighted sum. Integer: weights and inputs both are. +var sum: int = 0 + +var _stones: Array[WeightStone] = [] +var _column: MeshInstance3D +## The four perimeter segments of the threshold ring. +var _beam: Array[MeshInstance3D] = [] +var _out_rune: MeshInstance3D +var _room_lamp: OmniLight3D +var _shown_fill: float = 0.0 +var _target_fill: float = 0.0 +## World-space center of the chamber floor, cached for the fight and for the +## vault's conduit routing. +var _center: Vector3 = Vector3.ZERO + + +## Build a chamber. `center` is the middle of its floor in vault-local space, +## `ladders` holds the two weight ladders for the two sockets (west socket +## first) as plain integer arrays, and `fires_at` is the threshold. +static func build(host: Node3D, center: Vector3, display_name: String, + ladders: Array, fires_at: int) -> NeuronChamber: + var c: NeuronChamber = NeuronChamber.new() + c.chamber_name = display_name + c.threshold = fires_at + c.position = center + host.add_child(c) + c._build(ladders) + return c + + +func _ready() -> void: + name = "Chamber_" + chamber_name.replace(" ", "_") + + +## Room shell, sockets, column, threshold beam, and the outbound rune. Called +## by `build()` after the node is in the tree, so `_center` can be resolved in +## world space for the conduits the vault runs between chambers. +func _build(ladders: Array) -> void: + _center = global_position + + var doors: Array[Dictionary] = [ + {"side": "north", "width": DOOR_WIDTH, "height": DOOR_HEIGHT, "offset": 0.0}, + {"side": "south", "width": DOOR_WIDTH, "height": DOOR_HEIGHT, "offset": 0.0}, + ] + VaultBuild.room(self, Vector3.ZERO, INNER, WALL_THICKNESS, doors) + + # Sockets face each other across the room, set into the side walls at + # chest height so a swing connects without aiming up or down. + var socket_x: float = INNER.x * 0.5 - 0.35 + var socket_y: float = 1.55 + _stones.append(WeightStone.build(self, Vector3(-socket_x, socket_y, -2.2), + deg_to_rad(90.0), ladders[0])) + _stones.append(WeightStone.build(self, Vector3(socket_x, socket_y, -2.2), + deg_to_rad(-90.0), ladders[1])) + for s: WeightStone in _stones: + s.changed.connect(_on_stone_changed) + + # The sum column: a plinth, then the glass tube that fills. + VaultBuild.decor(self, Vector3(0.0, 0.2, 1.0), + Vector3(COLUMN_WIDTH * 1.9, 0.4, COLUMN_WIDTH * 1.9), VaultBuild.STONE_TRIM) + _column = VaultBuild.rune(self, + Vector3(0.0, 0.4 + COLUMN_HEIGHT * 0.5, 1.0), + Vector3(COLUMN_WIDTH, COLUMN_HEIGHT, COLUMN_WIDTH), + VaultBuild.LIGHT_POSITIVE, 2.6) + VaultBuild.set_rune_fill(_column, 0.0) + VaultBuild.set_rune_flow(_column, 0.55, 0.7, 0.5) + + # The threshold beam: a thin ring of light running the full perimeter at + # exactly the height the column must reach. Four bars hugging the walls, + # not one slab across the room — the ring has to frame the column, not + # hide it. Being room-wide rather than column-local is the point: you can + # judge the remaining gap from anywhere in the chamber. + var beam_y: float = 0.4 + float(threshold) * METERS_PER_UNIT + var bar: float = 0.09 + var edge_x: float = INNER.x * 0.5 - bar * 0.5 + var edge_z: float = INNER.z * 0.5 - bar * 0.5 + for spec: Array in [ + [Vector3(0.0, beam_y, -edge_z), Vector3(INNER.x, bar, bar)], + [Vector3(0.0, beam_y, edge_z), Vector3(INNER.x, bar, bar)], + [Vector3(-edge_x, beam_y, 0.0), Vector3(bar, bar, INNER.z)], + [Vector3(edge_x, beam_y, 0.0), Vector3(bar, bar, INNER.z)], + ]: + var seg: MeshInstance3D = VaultBuild.rune(self, spec[0] as Vector3, + spec[1] as Vector3, Color(0.95, 0.93, 0.86), 1.5) + VaultBuild.set_rune_pulse(seg, 0.22, 1.1) + _beam.append(seg) + + # Outbound marker over the south door: dark until the chamber fires. + _out_rune = VaultBuild.rune(self, + Vector3(0.0, DOOR_HEIGHT + 0.7, INNER.z * 0.5 - 0.1), + Vector3(3.2, 0.34, 0.12), VaultBuild.LIGHT_IDLE, 1.0) + + _room_lamp = VaultBuild.lamp(self, Vector3(0.0, INNER.y - 1.4, 0.0), + VaultBuild.LIGHT_IDLE, 1.5, 22.0) + + _build_plaque() + + +## The chamber's carved instruction. Written as the vault's builders would +## have written it — an operating note for whoever comes after, never a +## tutorial, and never using the vocabulary the mechanic is named for. +func _build_plaque() -> void: + var sign_note: String = "Both stones here will add." if not _has_inhibitory() \ + else "One stone here can only take away." + # Above the north doorway's 4.6 m lintel and clear of the 8 m ceiling. + # Carved at eye height it would sit across the open doorway instead of + # on stone. + VaultBuild.plaque(self, + Vector3(0.0, 5.9, -INNER.z * 0.5 + 0.12), + "%s\nIt opens at %d.\n%s" % [chamber_name, threshold, sign_note], + 9.0, 34) + + +func _has_inhibitory() -> bool: + for s: WeightStone in _stones: + if s.is_inhibitory(): + return true + return false + + +## Recompute the sum from the two input bits and repaint the room. Called by +## PerceptronVault on any fount change and by this chamber on any stone +## change, so the two paths can never disagree about the current state. +func recompute(input_a: int, input_b: int) -> void: + sum = _stones[0].value() * input_a + _stones[1].value() * input_b + var now_firing: bool = sum >= threshold + _target_fill = clampf(float(absi(sum)) * METERS_PER_UNIT / COLUMN_HEIGHT, 0.0, 1.0) + + # Sign lives in the column's color, exactly as it does on the stones: a + # blue column is the unmistakable "you pushed it the wrong way". + var col: Color = VaultBuild.LIGHT_POSITIVE if sum >= 0 else VaultBuild.LIGHT_NEGATIVE + VaultBuild.set_rune_color(_column, col, 2.6) + + if now_firing != firing: + firing = now_firing + _on_fire_changed() + + +## Everything that changes when the chamber crosses its threshold: the +## outbound rune, the room's fill light, a shard burst at the column head, +## and the signal upward. Only ever called on an actual transition, so the +## burst marks the moment rather than every recompute. +func _on_fire_changed() -> void: + var col: Color = VaultBuild.LIGHT_POSITIVE if firing else VaultBuild.LIGHT_IDLE + VaultBuild.set_rune_color(_out_rune, col, 3.4 if firing else 0.8) + VaultBuild.set_rune_flow(_out_rune, 0.9 if firing else 0.0, 1.6, 0.8) + if _room_lamp != null: + _room_lamp.light_color = col + _room_lamp.light_energy = 3.2 if firing else 1.5 + # A firing chamber's threshold ring steadies — the sum has cleared it and + # it has nothing left to ask for. + for seg: MeshInstance3D in _beam: + VaultBuild.set_rune_pulse(seg, 0.12 if firing else 0.22, 1.1) + if is_inside_tree(): + DamageShards.burst(get_tree().current_scene, + global_position + Vector3(0.0, 0.4 + COLUMN_HEIGHT * _shown_fill, 1.0), + col, 14 if firing else 8, 3.6, 2.2, 0.9) + EventBus.combat_shake.emit(0.08 if firing else 0.04) + fired_changed.emit(firing) + + +## The column eases toward its target rather than snapping, so a strike reads +## as "the sum moved" instead of "the room changed". Exponential smoothing: +## frame-rate independent, and it settles without overshoot. +func _process(delta: float) -> void: + if absf(_shown_fill - _target_fill) < 0.001: + return + _shown_fill = lerpf(_shown_fill, _target_fill, 1.0 - exp(-COLUMN_EASE * delta)) + VaultBuild.set_rune_fill(_column, _shown_fill) + + +## A stone stepped — hand it up to the vault, which owns the input bits. +func _on_stone_changed(_value: int) -> void: + needs_recompute.emit() + + +## World-space point where this chamber's outbound conduit starts. +func outlet_position() -> Vector3: + return _center + Vector3(0.0, DOOR_HEIGHT + 0.7, INNER.z * 0.5) diff --git a/game/src/dungeon/neuron_chamber.gd.uid b/game/src/dungeon/neuron_chamber.gd.uid new file mode 100644 index 0000000..14042f8 --- /dev/null +++ b/game/src/dungeon/neuron_chamber.gd.uid @@ -0,0 +1 @@ +uid://dilkajas0fihx diff --git a/game/src/dungeon/perceptron_vault.gd b/game/src/dungeon/perceptron_vault.gd new file mode 100644 index 0000000..b61fc37 --- /dev/null +++ b/game/src/dungeon/perceptron_vault.gd @@ -0,0 +1,648 @@ +class_name PerceptronVault +extends Node3D +## Dungeon 1 — **the Perceptron Vault** (Phase 1 milestone 12). +## +## The WORLDBOOK's brief for Datasedge Meadows is one sentence: "route glowing +## signals through weight-doors so the output gate fires; mini-boss the +## Gatekeeper (sums what hits it; overload it). Teaches: what a neuron does." +## The ROADMAP adds "traverse an actual neural network". This is that, built at +## walking scale: the vault IS a 2-3-1 network, laid out along its own axis, +## and Kern walks its layers in order. +## +## two Signal Founts (the inputs, x ∈ {0,1}) +## ↓ +## three Neuron Chambers (the hidden row — each a room you stand inside) +## ↓ +## the Junction (the output cell's three weights) +## ↓ +## the Gatekeeper (the output cell, and the fight) +## ↓ +## the Output Gate (opens when the cell fires) +## +## The 2-3-1 figure is carved over the door and over the gate, and it is the +## same sigil already on the game's icon. +## +## **The puzzle is real and it is solvable exactly one way.** The thresholds +## are fixed; every weight steps through a short, restricted ladder — some +## sockets can only add, some can only take away. To light all three chambers +## and clear the output cell's threshold, a player has to work out that both +## founts must be lit and each chamber tuned to its best positive setting. +## That is a small network solved by hand, which is the lesson. +## +## **The fight does not require the puzzle** (GDD §3: no hard gates; danger +## and cleverness are the gates). A solved vault feeds the Gatekeeper while +## Kern dodges; an unsolved one means overloading it by sword alone against +## its leak. Both are intended routes. +## +## Where it sits: instanced by `Main._setup_vault()` in one line, per the +## "Wiring new systems into main.gd" convention in docs/ARCHITECTURE.md. +## Everything is built in code — there is no vault scene and no imported +## asset. It owns its own BitLandmark, so `MeadowLandmarks` needs no edit. +## Reads MeadowTerrain for its site, writes GameState (clear flag, Tokens, +## the reliquary item), and emits nothing on the EventBus itself — the +## Gatekeeper's `enemy_defeated` and GameState's `item_acquired` / +## `tokens_changed` already say everything a listener needs. +## +## Units: meters and seconds. Local space has the entrance at -Z (north, the +## same compass the meadow uses) and the gate at +Z. + +# --- Placement --------------------------------------------------------------- + +## Where the vault stands in the meadow, in world XZ. Chosen to be clear of +## every existing canon site: 117 m from Bootstrap (well outside the town +## flat), 122 m from the millpond, and 151 m from the nearest iris cluster — +## so it crowds nothing already built and leaves the iris flats to the +## compendium milestone. +const SITE: Vector2 = Vector2(64.0, 128.0) +## Clearance above the highest ground under the footprint. The floor is set +## from a survey of the terrain rather than a hard-coded height, so no hill +## can ever poke up through a chamber floor. +const FLOOR_CLEARANCE: float = 1.2 +## Survey grid spacing (m) used to find that highest point. +const SURVEY_STEP: float = 6.0 + +# --- Architecture ------------------------------------------------------------ + +const WALL: float = 1.2 ## wall/slab thickness +const HALL_INNER: Vector3 = Vector3(44.4, 8.0, 18.0) +const CHAMBER_PITCH: float = 15.2 ## chamber inner width + one wall +const JUNCTION_INNER: Vector3 = Vector3(44.4, 8.0, 12.0) +const ARENA_INNER: Vector3 = Vector3(44.4, 10.0, 30.0) +const RELIQUARY_INNER: Vector3 = Vector3(14.0, 6.0, 10.0) +const DOOR_W: float = 6.0 +const DOOR_H: float = 5.0 +## The output gate's opening. +const GATE_W: float = 8.0 +const GATE_H: float = 6.0 +## Approach ramp from the meadow up onto the vault's plinth. +const RAMP_LENGTH: float = 26.0 +const RAMP_WIDTH: float = 14.0 +const RAMP_THICKNESS: float = 1.0 +## The platform the whole vault stands on. Wide and long enough to carry the +## facade at one end and the reliquary at the other, with an apron to spare. +const PLINTH_X_SPAN: float = 58.0 +const PLINTH_Z_SPAN: float = 108.0 +const PLINTH_Z: float = 12.0 + +# Z centers of each space, north (entrance) to south (reliquary). +const HALL_Z: float = -25.0 +const CHAMBER_Z: float = -5.0 +const JUNCTION_Z: float = 11.0 +const ARENA_Z: float = 33.0 +const RELIQUARY_Z: float = 55.0 + +# --- The network ------------------------------------------------------------- + +## The three hidden chambers: display name, the two weight ladders (west +## socket first), and the threshold each must reach. +## +## The ladders are the puzzle. Read them as a constraint system: Gamma can +## only fire with both founts lit and both its stones at maximum, which forces +## x1 = x2 = 1; with both lit, Alpha and Beta each have exactly one setting +## that reaches 1, because each carries a socket that can only subtract. +## Ladders are plain integer arrays so this whole table stays a constant +## expression — a `PackedInt32Array(...)` call would not be one, and GDScript +## rejects it in a `const`. +const CHAMBERS: Array[Dictionary] = [ + { + "name": "the First Chamber", + "ladders": [[1, 2], [-2, -1]], + "threshold": 1, + }, + { + "name": "the Second Chamber", + "ladders": [[-2, -1], [1, 2]], + "threshold": 1, + }, + { + "name": "the Third Chamber", + "ladders": [[1, 2], [1, 2]], + "threshold": 3, + }, +] + +## The output cell's three weight ladders (one per chamber, west to east) and +## its threshold. With every ladder capped at +2 the sum tops out at 6, so a +## threshold of 5 cannot be reached unless all three chambers fire — the +## output cell is what makes the hidden row matter. +const OUTPUT_LADDERS: Array = [[1, 2], [1, 2], [1, 2]] +const OUTPUT_THRESHOLD: int = 5 +## Meters of column per unit of sum in the junction, and the column's full +## height (the meter's 100% mark, sized for the largest reachable sum). +const OUTPUT_METERS_PER_UNIT: float = 0.85 +const OUTPUT_COLUMN_HEIGHT: float = 5.4 + +# --- Rewards ----------------------------------------------------------------- + +## GameState flag written once the gate opens. Part of the already-serialized +## flags dictionary, so this needs no change to the save shape and no +## SAVE_VERSION bump (CLAUDE.md iron rule 6). +const CLEARED_FLAG: String = "vault_perceptron_cleared" +const REWARD_TOKENS: int = 120 +const REWARD_ITEM: String = "item_threshold_stone" + +var _terrain: MeadowTerrain +var _founts: Array[SignalFount] = [] +var _chambers: Array[NeuronChamber] = [] +var _output_stones: Array[WeightStone] = [] +var _gate: VaultGate +var _gatekeeper: VaultGatekeeper +## Floor height in world space; local y = 0 sits here. Both come from the +## build-time terrain survey. +var _floor_y: float = 0.0 +var _lowest_ground: float = 0.0 +var _output_column: MeshInstance3D +var _output_beam: Array[MeshInstance3D] = [] +var _output_sum: int = 0 +var _output_firing: bool = false +var _shown_output_fill: float = 0.0 +var _target_output_fill: float = 0.0 +## Conduit strips, relit as the network changes. +var _fount_conduits: Array[MeshInstance3D] = [] +var _chamber_conduits: Array[MeshInstance3D] = [] +var _arena_conduit: MeshInstance3D +var _cleared: bool = false + + +## Build the vault into `host` against `terrain`. The only entry point; +## `Main._setup_vault()` is its one caller. +static func build(host: Node3D, terrain: MeadowTerrain) -> PerceptronVault: + var v: PerceptronVault = PerceptronVault.new() + v.name = "PerceptronVault" + v._terrain = terrain + host.add_child(v) + v._raise_and_build() + return v + + +## Survey the ground, settle the floor height, then build every space. +## Split out of `build()` only so the node is already in the tree when the +## rooms resolve their world positions. +func _raise_and_build() -> void: + var ground: Vector2 = _survey_ground() + _lowest_ground = ground.x + _floor_y = ground.y + FLOOR_CLEARANCE + global_position = Vector3(SITE.x, _floor_y, SITE.y) + + _build_plinth() + _build_hall() + _build_chambers() + _build_junction() + _build_arena() + _build_reliquary() + _build_conduits() + _build_landmark() + + # A returning player who already cleared it finds the gate standing open + # and no Gatekeeper waiting — the world matches the save without needing + # save/load to exist yet. + if GameState.has_flag(CLEARED_FLAG): + _cleared = true + _gate.open_instantly() + + solve() + print("PerceptronVault: built at (%.0f, %.0f), floor y=%.2f — %s." % [ + SITE.x, SITE.y, _floor_y, + "already cleared" if _cleared else "sealed", + ]) + + +## World Y of every interior floor in the vault. Read by `main.gd` so the +## screenshot rig can stand inside the vault instead of sampling terrain +## height and dropping through the plinth. +func floor_height() -> float: + return _floor_y + + +## Sample the ground across the vault's footprint and return +## `Vector2(lowest, highest)` in world Y. +## +## Measuring rather than assuming is the whole point: the meadow's height is +## generated from noise and has already been re-tuned several times. The +## highest point (plus clearance) sets the floor, so no hill can ever push up +## through a chamber; the lowest sets how deep the plinth must reach, so no +## daylight shows under the vault from downhill. +func _survey_ground() -> Vector2: + var lowest: float = INF + var highest: float = -INF + var x: float = -PLINTH_X_SPAN * 0.5 + while x <= PLINTH_X_SPAN * 0.5: + var z: float = PLINTH_Z - PLINTH_Z_SPAN * 0.5 + while z <= PLINTH_Z + PLINTH_Z_SPAN * 0.5: + var h: float = _terrain.get_height(SITE.x + x, SITE.y + z) + lowest = minf(lowest, h) + highest = maxf(highest, h) + z += SURVEY_STEP + x += SURVEY_STEP + return Vector2(lowest, highest) + + +## The stone platform the whole vault stands on, and the ramp up to its door. +## The plinth is sunk to the lowest ground in the footprint, so from downhill +## the vault reads as half-buried rather than as a box on stilts. +func _build_plinth() -> void: + # The plinth's TOP is flush with local y = 0 — the same plane as every + # room's interior floor. That is what lets Kern walk from the ramp across + # the apron and in through the door without a single step to catch on. + var depth: float = maxf(3.0, _floor_y - _lowest_ground + 2.0) + VaultBuild.solid(self, Vector3(0.0, -depth * 0.5, PLINTH_Z), + Vector3(PLINTH_X_SPAN, depth, PLINTH_Z_SPAN), VaultBuild.STONE_DARK) + # A lip just under the top edge — what sells "built" rather than + # "extruded" when you walk up to it. + VaultBuild.decor(self, Vector3(0.0, -0.35, PLINTH_Z), + Vector3(PLINTH_X_SPAN + 1.6, 0.7, PLINTH_Z_SPAN + 1.6), VaultBuild.STONE_TRIM) + + # Approach ramp, from the plinth's north edge down to the meadow. Its far + # end meets whatever height the terrain happens to be, so the slope is + # measured, never assumed. + var edge_z: float = PLINTH_Z - PLINTH_Z_SPAN * 0.5 + var far_z: float = edge_z - RAMP_LENGTH + var drop: float = _floor_y - _terrain.get_height(SITE.x, SITE.y + far_z) + var pitch: float = atan2(drop, RAMP_LENGTH) + var length: float = sqrt(RAMP_LENGTH * RAMP_LENGTH + drop * drop) + # Place the slab by its TOP face: that surface must pass through + # (edge_z, y=0) and (far_z, y=-drop), so its midpoint is the average of + # the two, and the box center sits half a thickness along the face + # normal, back into the slab. + var top_mid: Vector3 = Vector3(0.0, -drop * 0.5, (edge_z + far_z) * 0.5) + var normal: Vector3 = Vector3(0.0, cos(pitch), -sin(pitch)) + var ramp: MeshInstance3D = VaultBuild.solid(self, + top_mid - normal * (RAMP_THICKNESS * 0.5), + Vector3(RAMP_WIDTH, RAMP_THICKNESS, length), VaultBuild.STONE_FLOOR) + # Pitch about X so the slab's long Z axis runs down the slope. + ramp.rotation.x = -pitch + + +## Facade and the Input Hall: the two founts, the entrance, and the sigil. +func _build_hall() -> void: + VaultBuild.room(self, Vector3(0.0, 0.0, HALL_Z), HALL_INNER, WALL, [ + {"side": "north", "width": DOOR_W, "height": DOOR_H, "offset": 0.0}, + # A full-height, full-width "opening" in the south wall emits no wall + # at all — the three chambers' own north walls are the boundary here, + # and each carries its own doorway. + {"side": "south", "width": HALL_INNER.x, "height": HALL_INNER.y, "offset": 0.0}, + ]) + + # The facade: a monolith standing proud of the hall's north wall, with the + # vault's mark above the door. This is the "something is actually there" + # silhouette from across the meadow (GDD design pillar 4). + var face_z: float = HALL_Z - HALL_INNER.z * 0.5 - WALL - 1.0 + VaultBuild.decor(self, Vector3(-14.0, 6.0, face_z), Vector3(18.0, 12.0, 2.0), + VaultBuild.STONE_WALL) + VaultBuild.decor(self, Vector3(14.0, 6.0, face_z), Vector3(18.0, 12.0, 2.0), + VaultBuild.STONE_WALL) + VaultBuild.decor(self, Vector3(0.0, 9.4, face_z), Vector3(10.0, 5.2, 2.0), + VaultBuild.STONE_WALL) + VaultBuild.decor(self, Vector3(0.0, 13.2, face_z + 0.2), Vector3(48.0, 1.4, 2.6), + VaultBuild.STONE_TRIM) + _build_sigil(Vector3(0.0, 8.6, face_z - 1.2), 4.6, VaultBuild.LIGHT_IDLE) + + # Two founts, set symmetrically about the hall's axis so the pairing with + # the two ladders in each chamber is obvious from the doorway. + _founts.append(SignalFount.build(self, Vector3(-9.0, 0.0, HALL_Z + 1.0), + "the First Fount")) + _founts.append(SignalFount.build(self, Vector3(9.0, 0.0, HALL_Z + 1.0), + "the Second Fount")) + for f: SignalFount in _founts: + f.toggled.connect(_on_fount_toggled) + + # Above the door lintel (5 m) and clear of the 8 m ceiling — carved where + # you read it on the way back out, which is when it starts to make sense. + VaultBuild.plaque(self, Vector3(0.0, 6.4, HALL_Z - HALL_INNER.z * 0.5 + 0.15), + "THE PERCEPTRON VAULT\n" + + "Two founts feed three chambers.\n" + + "Three chambers feed the one beyond.\n" + + "Strike a fount to wake it. Strike a stone to set it.\n" + + "The gate opens for a sum, not for a key.", + 16.0, 34) + + VaultBuild.lamp(self, Vector3(0.0, HALL_INNER.y - 1.2, HALL_Z), + Color(0.86, 0.82, 0.72), 2.2, 34.0) + VaultBuild.lamp(self, Vector3(0.0, 3.0, HALL_Z - HALL_INNER.z * 0.5 + 2.0), + Color(0.98, 0.86, 0.62), 1.6, 18.0) + + +## The three hidden chambers, side by side. Their inner walls are coincident +## by construction: pitch is exactly inner width plus one wall thickness, so +## neighbouring rooms share a single slab instead of leaving a cavity. +func _build_chambers() -> void: + for i in CHAMBERS.size(): + var spec: Dictionary = CHAMBERS[i] + var x: float = (float(i) - 1.0) * CHAMBER_PITCH + var chamber: NeuronChamber = NeuronChamber.build(self, + Vector3(x, 0.0, CHAMBER_Z), str(spec["name"]), + spec["ladders"] as Array, int(spec["threshold"])) + chamber.needs_recompute.connect(solve) + _chambers.append(chamber) + + +## The Junction: where the three chambers' results are weighed together. Three +## pedestals (the output cell's weights), one sum column, one threshold ring. +## +## The arithmetic here is the same dot-product-and-compare a NeuronChamber +## does, but the housing is a corridor with three free-standing stones rather +## than a room with two wall sockets, and the cell has three inputs instead of +## two — so it is written out here rather than bent into NeuronChamber. +func _build_junction() -> void: + VaultBuild.room(self, Vector3(0.0, 0.0, JUNCTION_Z), JUNCTION_INNER, WALL, [ + # No north wall — the chambers' south walls close this side. + {"side": "north", "width": JUNCTION_INNER.x, "height": JUNCTION_INNER.y, + "offset": 0.0}, + {"side": "south", "width": DOOR_W, "height": DOOR_H, "offset": 0.0}, + ]) + + for i in OUTPUT_LADDERS.size(): + var x: float = (float(i) - 1.0) * CHAMBER_PITCH + # A plinth under each stone: these are posts in a corridor, not + # fixtures in a wall, so they need something to stand on. + VaultBuild.decor(self, Vector3(x, 0.55, JUNCTION_Z - 2.6), + Vector3(1.6, 1.1, 1.6), VaultBuild.STONE_TRIM) + # Facing north (PI), toward the chamber whose result it weighs, so a + # player walking out of that chamber meets its stone face-on. + var stone: WeightStone = WeightStone.build(self, + Vector3(x, 1.75, JUNCTION_Z - 2.6), PI, OUTPUT_LADDERS[i]) + stone.changed.connect(_on_output_stone_changed) + _output_stones.append(stone) + + VaultBuild.decor(self, Vector3(0.0, 0.2, JUNCTION_Z + 3.4), + Vector3(2.4, 0.4, 2.4), VaultBuild.STONE_TRIM) + _output_column = VaultBuild.rune(self, + Vector3(0.0, 0.4 + OUTPUT_COLUMN_HEIGHT * 0.5, JUNCTION_Z + 3.4), + Vector3(1.35, OUTPUT_COLUMN_HEIGHT, 1.35), VaultBuild.LIGHT_POSITIVE, 2.8) + VaultBuild.set_rune_fill(_output_column, 0.0) + VaultBuild.set_rune_flow(_output_column, 0.55, 0.7, 0.5) + + var beam_y: float = 0.4 + float(OUTPUT_THRESHOLD) * OUTPUT_METERS_PER_UNIT + var bar: float = 0.1 + var ex: float = JUNCTION_INNER.x * 0.5 - bar + var ez: float = JUNCTION_INNER.z * 0.5 - bar + for spec: Array in [ + [Vector3(0.0, beam_y, JUNCTION_Z - ez), Vector3(JUNCTION_INNER.x, bar, bar)], + [Vector3(0.0, beam_y, JUNCTION_Z + ez), Vector3(JUNCTION_INNER.x, bar, bar)], + [Vector3(-ex, beam_y, JUNCTION_Z), Vector3(bar, bar, JUNCTION_INNER.z)], + [Vector3(ex, beam_y, JUNCTION_Z), Vector3(bar, bar, JUNCTION_INNER.z)], + ]: + var seg: MeshInstance3D = VaultBuild.rune(self, spec[0] as Vector3, + spec[1] as Vector3, Color(0.95, 0.93, 0.86), 1.5) + VaultBuild.set_rune_pulse(seg, 0.22, 1.1) + _output_beam.append(seg) + + # On the junction's SOUTH wall, so it faces back north into the room — + # hence the PI yaw. Above the 5 m doorway to the arena. + VaultBuild.plaque(self, Vector3(0.0, 6.3, JUNCTION_Z + JUNCTION_INNER.z * 0.5 - 0.15), + "Three chambers answer here.\nThe one beyond opens at %d." + % OUTPUT_THRESHOLD, 11.0, 38, PI) + VaultBuild.lamp(self, Vector3(0.0, JUNCTION_INNER.y - 1.2, JUNCTION_Z), + Color(0.84, 0.80, 0.72), 2.0, 30.0) + + +## The Gatekeeper's arena and the output gate in its far wall. The boss is +## not built here — it wakes when Kern crosses the threshold, so a player +## walking back to re-tune a stone does not leave a boss idling in an empty +## room, and screenshot runs never spawn one. +func _build_arena() -> void: + VaultBuild.room(self, Vector3(0.0, 0.0, ARENA_Z), ARENA_INNER, WALL, [ + {"side": "north", "width": DOOR_W, "height": DOOR_H, "offset": 0.0}, + {"side": "south", "width": GATE_W, "height": GATE_H, "offset": 0.0}, + ]) + # Corner braziers: enough light to fight by, warm against the arena's + # cold stone, and four of them so the boss always casts a readable + # silhouette whichever way the camera swings. + for cx: float in [-16.0, 16.0]: + for cz: float in [ARENA_Z - 11.0, ARENA_Z + 11.0]: + VaultBuild.decor(self, Vector3(cx, 1.1, cz), Vector3(1.0, 2.2, 1.0), + VaultBuild.STONE_TRIM) + VaultBuild.rune(self, Vector3(cx, 2.4, cz), Vector3(0.7, 0.5, 0.7), + Color(1.0, 0.72, 0.38), 2.6) + VaultBuild.lamp(self, Vector3(cx, 2.9, cz), Color(1.0, 0.78, 0.5), + 2.6, 22.0) + + var gate_z: float = ARENA_Z + ARENA_INNER.z * 0.5 + WALL * 0.5 + _gate = VaultGate.build(self, Vector3(0.0, 0.0, gate_z), GATE_W, GATE_H, WALL) + + # The trigger that wakes the boss: the arena's own doorway. + var trigger: Area3D = Area3D.new() + trigger.name = "ArenaTrigger" + trigger.collision_layer = 0 + trigger.collision_mask = CombatLayers.PLAYER + var shape: CollisionShape3D = CollisionShape3D.new() + var box: BoxShape3D = BoxShape3D.new() + box.size = Vector3(ARENA_INNER.x, ARENA_INNER.y, 6.0) + shape.shape = box + trigger.add_child(shape) + trigger.position = Vector3(0.0, ARENA_INNER.y * 0.5, ARENA_Z - ARENA_INNER.z * 0.5 + 3.0) + trigger.body_entered.connect(_on_arena_entered) + add_child(trigger) + + +## Beyond the gate: what the vault was built to keep. The reward is granted +## the moment the gate opens rather than on touching the plinth, so a player +## who clears the fight and walks away still keeps it. +func _build_reliquary() -> void: + VaultBuild.room(self, Vector3(0.0, 0.0, RELIQUARY_Z), RELIQUARY_INNER, WALL, [ + {"side": "north", "width": GATE_W, "height": GATE_H, "offset": 0.0}, + ]) + VaultBuild.decor(self, Vector3(0.0, 0.6, RELIQUARY_Z), Vector3(2.6, 1.2, 2.6), + VaultBuild.STONE_TRIM) + var relic: MeshInstance3D = VaultBuild.rune(self, + Vector3(0.0, 2.1, RELIQUARY_Z), Vector3(0.9, 1.3, 0.9), + VaultBuild.LIGHT_POSITIVE, 3.4) + VaultBuild.set_rune_pulse(relic, 0.35, 1.3) + VaultBuild.lamp(self, Vector3(0.0, 3.0, RELIQUARY_Z), VaultBuild.LIGHT_POSITIVE, + 2.8, 14.0) + VaultBuild.plaque(self, Vector3(0.0, 4.0, RELIQUARY_Z + RELIQUARY_INNER.z * 0.5 - 0.15), + "It only ever answered one question.\nIt answered honestly every time.", + 9.0, 38, PI) + + +## Floor conduits: fount to chambers, chambers to the junction, junction into +## the arena. They carry the travelling-band effect only while live, so the +## floor visibly shows where signal is and is not going. +func _build_conduits() -> void: + for i in _founts.size(): + _fount_conduits.append(_floor_strip( + Vector3(_founts[i].position.x, 0.03, HALL_Z + 4.5), 9.0, 0.34)) + for i in _chambers.size(): + _chamber_conduits.append(_floor_strip( + Vector3((float(i) - 1.0) * CHAMBER_PITCH, 0.03, JUNCTION_Z - 5.4), + 5.0, 0.34)) + _arena_conduit = _floor_strip(Vector3(0.0, 0.03, JUNCTION_Z + 9.0), 14.0, 0.5) + + +## One conduit inlay, `length` meters long, running north-south. +## +## The rune shader's travelling band moves along the mesh's LOCAL Y, so the +## strip is built standing on end — length on Y — and then laid flat by a +## quarter turn about X. Building it lying down would squeeze the whole band +## into the strip's 6 cm thickness and show nothing. +func _floor_strip(center: Vector3, length: float, width: float) -> MeshInstance3D: + var strip: MeshInstance3D = VaultBuild.rune(self, center, + Vector3(width, length, 0.06), VaultBuild.LIGHT_IDLE, 1.2) + strip.rotation.x = deg_to_rad(90.0) + return strip + + +## Bit names the vault the first time Kern comes near. Owned here rather than +## added to MeadowLandmarks so this milestone touches no file another session +## is working in — BitLandmark registers itself through a group, which is +## exactly what makes that possible. +func _build_landmark() -> void: + var lm: BitLandmark = BitLandmark.new() + lm.name = "Landmark_perceptron_vault" + lm.configure("perceptron_vault", "the Perceptron Vault", 46.0, [ + "The Perceptron Vault. Every door in there is a sum, Kern — feed it " + + "enough and it opens, feed it wrong and it just sits there. Rather " + + "like you before breakfast.", + "That's the Perceptron Vault. Whatever's still awake inside has been " + + "counting since long before Bootstrap had a mayor. Do try not to " + + "let it count YOU.", + ], false) + lm.position = Vector3(0.0, 3.0, -HALL_INNER.z * 0.5 + HALL_Z - 14.0) + add_child(lm) + + +## The vault's mark: two nodes, then three, then one, fully joined. Carved on +## the facade and again on the gate; also the figure on the game's icon. +func _build_sigil(center: Vector3, span: float, color: Color) -> void: + var rows: Array[int] = [2, 3, 1] + var points: Array[Array] = [] + for r: int in rows.size(): + var count: int = rows[r] + var row: Array[Vector3] = [] + for i: int in count: + row.append(center + Vector3( + (float(r) - 1.0) * span * 0.44, + (float(i) - float(count - 1) * 0.5) * span * 0.34, + 0.0)) + points.append(row) + for r: int in rows.size() - 1: + for a: Vector3 in points[r]: + for b: Vector3 in points[r + 1]: + var delta: Vector3 = b - a + var edge: MeshInstance3D = VaultBuild.rune(self, (a + b) * 0.5, + Vector3(delta.length(), 0.07, 0.06), color, 1.2) + edge.rotation.z = atan2(delta.y, delta.x) + for row: Array in points: + for p: Vector3 in row: + var dot: MeshInstance3D = VaultBuild.rune(self, p as Vector3, + Vector3(0.44, 0.44, 0.08), color, 1.8) + VaultBuild.set_rune_pulse(dot, 0.3, 1.2) + + +# --- The network, solved ----------------------------------------------------- + +## Re-run the whole 2-3-1 network from the current fount bits and stone +## values, and push the result into every surface that shows it. +## +## Deliberately total rather than incremental: any change anywhere re-solves +## everything, so no two displays can ever drift out of agreement. The whole +## computation is a handful of integer multiplies — running it on every sword +## strike costs nothing. +func solve() -> void: + var a: int = _founts[0].value if _founts.size() > 0 else 0 + var b: int = _founts[1].value if _founts.size() > 1 else 0 + + for c: NeuronChamber in _chambers: + c.recompute(a, b) + + # The output cell: each firing chamber contributes its weight, a dark one + # contributes nothing — the same step function the chambers apply. + _output_sum = 0 + for i in _chambers.size(): + if _chambers[i].firing: + _output_sum += _output_stones[i].value() + var firing: bool = _output_sum >= OUTPUT_THRESHOLD + _target_output_fill = clampf( + float(absi(_output_sum)) * OUTPUT_METERS_PER_UNIT / OUTPUT_COLUMN_HEIGHT, + 0.0, 1.0) + VaultBuild.set_rune_color(_output_column, + VaultBuild.LIGHT_POSITIVE if _output_sum >= 0 else VaultBuild.LIGHT_NEGATIVE, + 2.8) + + if firing != _output_firing: + _output_firing = firing + for seg: MeshInstance3D in _output_beam: + VaultBuild.set_rune_pulse(seg, 0.12 if firing else 0.22, 1.1) + if is_inside_tree(): + DamageShards.burst(get_tree().current_scene, + global_position + Vector3(0.0, 2.6, JUNCTION_Z + 3.4), + VaultBuild.LIGHT_POSITIVE if firing else VaultBuild.LIGHT_IDLE, + 18 if firing else 8, 4.0, 2.4, 1.0) + EventBus.combat_shake.emit(0.12 if firing else 0.05) + + # The Gatekeeper reads the network's state every solve, so re-tuning a + # stone mid-fight takes effect immediately — walking back out to fix the + # puzzle is a legitimate tactic, not a soft-lock. + if _gatekeeper != null and is_instance_valid(_gatekeeper): + _gatekeeper.network_feeding = firing + + _relight_conduits(a, b, firing) + + +## Repaint the floor conduits to match the current signal. A conduit only +## flows where signal is genuinely travelling, so the floor is a readable map +## of the network's live state. +func _relight_conduits(a: int, b: int, output_firing: bool) -> void: + var bits: Array[int] = [a, b] + for i in _fount_conduits.size(): + var live: bool = bits[i] == 1 + VaultBuild.set_rune_color(_fount_conduits[i], + VaultBuild.LIGHT_POSITIVE if live else VaultBuild.LIGHT_IDLE, + 2.4 if live else 0.5) + VaultBuild.set_rune_flow(_fount_conduits[i], 1.0 if live else 0.0, 1.4, 0.5) + for i in _chamber_conduits.size(): + var live: bool = _chambers[i].firing + VaultBuild.set_rune_color(_chamber_conduits[i], + VaultBuild.LIGHT_POSITIVE if live else VaultBuild.LIGHT_IDLE, + 2.4 if live else 0.5) + VaultBuild.set_rune_flow(_chamber_conduits[i], 1.0 if live else 0.0, 1.4, 0.5) + VaultBuild.set_rune_color(_arena_conduit, + VaultBuild.LIGHT_POSITIVE if output_firing else VaultBuild.LIGHT_IDLE, + 3.0 if output_firing else 0.5) + VaultBuild.set_rune_flow(_arena_conduit, 1.2 if output_firing else 0.0, 1.8, 0.4) + + +## Ease the junction column toward its target, matching the chambers' feel. +func _process(delta: float) -> void: + if absf(_shown_output_fill - _target_output_fill) < 0.001: + return + _shown_output_fill = lerpf(_shown_output_fill, _target_output_fill, + 1.0 - exp(-NeuronChamber.COLUMN_EASE * delta)) + VaultBuild.set_rune_fill(_output_column, _shown_output_fill) + + +func _on_fount_toggled(_value: int) -> void: + solve() + + +func _on_output_stone_changed(_value: int) -> void: + solve() + + +## Kern stepped into the arena. Wake the Gatekeeper once, already fed by +## whatever the network is currently doing. +func _on_arena_entered(body: Node3D) -> void: + if _cleared or _gatekeeper != null: + return + if not body.is_in_group(&"player"): + return + _gatekeeper = VaultGatekeeper.build(self, Vector3(0.0, 0.0, ARENA_Z + 4.0)) + _gatekeeper.network_feeding = _output_firing + _gatekeeper.overloaded.connect(_on_gatekeeper_overloaded) + print("PerceptronVault: the Gatekeeper wakes (network feeding: %s)." % _output_firing) + + +## The output cell fired. Open the gate and pay out — flag, Tokens, and the +## reliquary's stone. `add_tokens` and `add_item` emit their own EventBus +## signals, so the HUD and any quest listener hear about it without the vault +## knowing they exist. +func _on_gatekeeper_overloaded() -> void: + if _cleared: + return + _cleared = true + GameState.set_flag(CLEARED_FLAG) + GameState.add_tokens(REWARD_TOKENS) + GameState.add_item(REWARD_ITEM, 1) + _gate.open() + print("PerceptronVault: cleared — gate opening, %d Tokens and %s awarded." + % [REWARD_TOKENS, REWARD_ITEM]) diff --git a/game/src/dungeon/perceptron_vault.gd.uid b/game/src/dungeon/perceptron_vault.gd.uid new file mode 100644 index 0000000..e7346cc --- /dev/null +++ b/game/src/dungeon/perceptron_vault.gd.uid @@ -0,0 +1 @@ +uid://cxjgbggh0n6t0 diff --git a/game/src/dungeon/signal_fount.gd b/game/src/dungeon/signal_fount.gd new file mode 100644 index 0000000..dd65dcf --- /dev/null +++ b/game/src/dungeon/signal_fount.gd @@ -0,0 +1,143 @@ +class_name SignalFount +extends StaticBody3D +## One of the Perceptron Vault's two input pillars. It holds a single bit — +## lit or dark — and Kern flips it by hitting it with the sword. +## +## In ML terms this is an input x ∈ {0, 1} to the vault's 2-3-1 network; in +## world terms it is a stone basin that either carries signal or does not. +## Nothing in the vault ever says the former out loud (WORLDBOOK Part IV: the +## puns live in proper nouns and behavior, never in vocabulary). +## +## **Why it joins the "enemy" group.** PlayerCombat's swing only considers +## bodies that are in group "enemy" and expose `apply_hit()`. Rather than +## widen that contract from the dungeon (and collide with the sessions +## building other milestones), every strikeable vault prop wears the same +## costume: ENEMY collision layer, "enemy" group, `apply_hit()`. It is not in +## "hittable" — nothing here has hearts, and nothing here fights back. +## +## Built entirely in code by PerceptronVault; there is no fount scene. Emits +## `toggled` for the chamber network to recompute against, and plays a small +## shard puff on every flip so the strike always feels answered. + +## Emitted whenever the fount's bit changes. `value` is 0 or 1. +signal toggled(value: int) + +## Seconds a strike is ignored after a landed one. A three-hit combo would +## otherwise flip the bit three times and read as "the fount ignored me"; +## one flip per swing is the intent. +const STRIKE_COOLDOWN: float = 0.42 +## Basin dimensions, meters. +const PILLAR_HEIGHT: float = 2.2 +const PILLAR_WIDTH: float = 1.1 +## The floating bit-marble above the basin and how far it bobs. +const MARBLE_SIZE: float = 0.62 +const BOB_AMPLITUDE: float = 0.09 +const BOB_SPEED: float = 1.9 + +## Display name used on this fount's plaque ("the First Fount"). +var fount_name: String = "Fount" +## The bit itself. Read by NeuronChamber every time the network recomputes. +var value: int = 0 + +var _cooldown: float = 0.0 +var _marble: MeshInstance3D +var _basin_rune: MeshInstance3D +var _lamp: OmniLight3D +var _marble_base_y: float = 0.0 +var _bob_t: float = 0.0 + + +## Build a fount and parent it to `host`. `position` is the base of the +## pillar (floor level), and `start_value` seeds the bit — always 0 at build +## time, so a player entering a fresh vault finds it dark and inert. +static func build(host: Node3D, world_position: Vector3, display_name: String, + start_value: int = 0) -> SignalFount: + var f: SignalFount = SignalFount.new() + f.fount_name = display_name + f.value = clampi(start_value, 0, 1) + f.position = world_position + host.add_child(f) + return f + + +func _ready() -> void: + add_to_group(&"enemy") + add_to_group(&"vault_prop") + name = "Fount_" + fount_name.replace(" ", "_") + collision_layer = CombatLayers.ENEMY + collision_mask = 0 + _build() + _refresh() + + +## Pillar, basin rim, the strike collider, and the floating marble that shows +## the bit. The collider is a single box covering the whole pillar so a swing +## anywhere along it registers — nobody should have to aim at a marble. +func _build() -> void: + var shape: CollisionShape3D = CollisionShape3D.new() + var box: BoxShape3D = BoxShape3D.new() + box.size = Vector3(PILLAR_WIDTH, PILLAR_HEIGHT, PILLAR_WIDTH) + shape.shape = box + shape.position = Vector3(0.0, PILLAR_HEIGHT * 0.5, 0.0) + add_child(shape) + + VaultBuild.decor(self, Vector3(0.0, PILLAR_HEIGHT * 0.5, 0.0), + Vector3(PILLAR_WIDTH, PILLAR_HEIGHT, PILLAR_WIDTH), VaultBuild.STONE_WALL) + VaultBuild.decor(self, Vector3(0.0, 0.14, 0.0), + Vector3(PILLAR_WIDTH * 1.5, 0.28, PILLAR_WIDTH * 1.5), VaultBuild.STONE_TRIM) + VaultBuild.decor(self, Vector3(0.0, PILLAR_HEIGHT + 0.09, 0.0), + Vector3(PILLAR_WIDTH * 1.35, 0.18, PILLAR_WIDTH * 1.35), VaultBuild.STONE_TRIM) + + # A ring of light set into the basin's rim: the fount's own state, visible + # from below when the marble is hidden behind the pillar. + _basin_rune = VaultBuild.rune(self, Vector3(0.0, PILLAR_HEIGHT + 0.19, 0.0), + Vector3(PILLAR_WIDTH * 1.15, 0.06, PILLAR_WIDTH * 1.15), VaultBuild.LIGHT_IDLE, 1.6) + + _marble_base_y = PILLAR_HEIGHT + 0.72 + _marble = VaultBuild.rune(self, Vector3(0.0, _marble_base_y, 0.0), + Vector3(MARBLE_SIZE, MARBLE_SIZE, MARBLE_SIZE), VaultBuild.LIGHT_IDLE, 1.8) + + _lamp = VaultBuild.lamp(self, Vector3(0.0, _marble_base_y, 0.0), + VaultBuild.LIGHT_IDLE, 0.0, 11.0) + + +## Bob the marble whether lit or dark — a fount is always powered, and a dead +## still marble would read as broken rather than as off. +func _process(delta: float) -> void: + _cooldown = maxf(0.0, _cooldown - delta) + _bob_t += delta * BOB_SPEED + if _marble != null: + _marble.position.y = _marble_base_y + sin(_bob_t) * BOB_AMPLITUDE + _marble.rotation.y += delta * (0.9 if value == 1 else 0.25) + + +## PlayerCombat's swing contract. Damage and knockback are ignored — a fount +## has no hearts and cannot be pushed; a strike is a strike. Returns nothing, +## matching the signature Enemy exposes. +func apply_hit(_amount: float, _from_position: Vector3, _knockback: float) -> void: + if _cooldown > 0.0: + return + _cooldown = STRIKE_COOLDOWN + value = 1 - value + _refresh() + # The puff reads as the strike landing, in the color the fount just became. + DamageShards.burst(get_tree().current_scene, + global_position + Vector3(0.0, _marble_base_y, 0.0), + VaultBuild.LIGHT_POSITIVE if value == 1 else VaultBuild.LIGHT_IDLE, + 10, 3.0, 2.0, 0.7) + EventBus.combat_shake.emit(0.05) + toggled.emit(value) + + +## Push the bit out to every visible surface: marble, basin ring, and the +## pillar's fill light. A lit fount is gold and genuinely lights its corner +## of the hall; a dark one is cold and contributes nothing. +func _refresh() -> void: + var lit: bool = value == 1 + var col: Color = VaultBuild.LIGHT_POSITIVE if lit else VaultBuild.LIGHT_IDLE + VaultBuild.set_rune_color(_marble, col, 3.6 if lit else 0.7) + VaultBuild.set_rune_pulse(_marble, 0.30 if lit else 0.10, 2.4) + VaultBuild.set_rune_color(_basin_rune, col, 2.4 if lit else 0.5) + if _lamp != null: + _lamp.light_color = col + _lamp.light_energy = 2.6 if lit else 0.0 diff --git a/game/src/dungeon/signal_fount.gd.uid b/game/src/dungeon/signal_fount.gd.uid new file mode 100644 index 0000000..ea617ae --- /dev/null +++ b/game/src/dungeon/signal_fount.gd.uid @@ -0,0 +1 @@ +uid://grgm4ekqh6j diff --git a/game/src/dungeon/vault_build.gd b/game/src/dungeon/vault_build.gd new file mode 100644 index 0000000..74ef6a5 --- /dev/null +++ b/game/src/dungeon/vault_build.gd @@ -0,0 +1,312 @@ +class_name VaultBuild +extends RefCounted +## Construction kit for the Perceptron Vault — the only place the dungeon's +## stone and light are actually meshed. +## +## Every other dungeon script describes the vault in rooms, walls, and runes +## and lets these static helpers emit the geometry, so the puzzle scripts stay +## about the puzzle. Nothing here holds state; call the statics from any node. +## +## Where it sits: pure builder, used by PerceptronVault (architecture), +## NeuronChamber, SignalFount, WeightStone, VaultGate, and VaultGatekeeper. +## Nothing here reads GameState, ContentDB, or the EventBus. +## +## Conventions: +## * Meters everywhere. Box sizes are full extents (not half-extents), and +## positions are the box CENTER, matching Godot's BoxMesh/BoxShape3D. +## * Stone uses the shared toon shader so the dungeon reads as the same world +## as the meadow (GDD §10 non-negotiable: cel shading everywhere). Glowing +## surfaces use vault_rune.gdshader. +## * Solid stone is built on the WORLD collision layer, so Kern collides with +## it exactly as he does with terrain and monsters ignore it the same way. + +const TOON_SHADER: String = "res://assets/shaders/toon.gdshader" +const RUNE_SHADER: String = "res://assets/shaders/vault_rune.gdshader" + +## The vault's stone palette. Pale, dry, and slightly warm — the Seed Vault +## builders' stone, lighter than the meadow's rock so interiors do not go +## muddy under rune light alone. +const STONE_WALL: Color = Color(0.60, 0.58, 0.53) +const STONE_FLOOR: Color = Color(0.44, 0.43, 0.41) +const STONE_TRIM: Color = Color(0.71, 0.68, 0.60) +const STONE_DARK: Color = Color(0.31, 0.30, 0.29) + +## Signal light: warm gold reads as "adding", cold blue as "taking away". +## The whole dungeon teaches sign with these two colors and nothing else, so +## they are never used for decoration. +const LIGHT_POSITIVE: Color = Color(1.00, 0.78, 0.32) +const LIGHT_NEGATIVE: Color = Color(0.36, 0.62, 0.95) +## An inert fixture: powered, but carrying no signal. +const LIGHT_IDLE: Color = Color(0.55, 0.60, 0.66) +## The output gate's own color — the vault's one green, spent only on "open". +const LIGHT_OPEN: Color = Color(0.42, 0.92, 0.58) + +## Label3D scale: meters per pixel of font size. Lets plaque() take widths +## and sizes in meters while Label3D works internally in pixels. +const PLAQUE_METERS_PER_PIXEL: float = 0.01 + + +# --- Stone ------------------------------------------------------------------- + +## A solid stone box: visible mesh plus matching collision, centered on +## `center`. `size` is full extents in meters. Returns the MeshInstance3D so +## callers can tween or recolor it (VaultGate slides its doors this way). +static func solid(parent: Node3D, center: Vector3, size: Vector3, + color: Color) -> MeshInstance3D: + var mi: MeshInstance3D = decor(parent, center, size, color) + var body: StaticBody3D = StaticBody3D.new() + body.name = "Collision" + body.collision_layer = CombatLayers.WORLD + body.collision_mask = 0 + var shape: CollisionShape3D = CollisionShape3D.new() + var box: BoxShape3D = BoxShape3D.new() + box.size = size + shape.shape = box + body.add_child(shape) + # Parented to the mesh, not to `parent`: a moving door then carries its + # own collision without the caller having to move two nodes in step. + mi.add_child(body) + return mi + + +## A stone box with no collision — trim, cornices, sigil bars, anything Kern +## can never reach. Cheaper than `solid()` and keeps the physics world small. +static func decor(parent: Node3D, center: Vector3, size: Vector3, + color: Color) -> MeshInstance3D: + var mesh: BoxMesh = BoxMesh.new() + mesh.size = size + var mi: MeshInstance3D = MeshInstance3D.new() + mi.mesh = mesh + mi.position = center + mi.material_override = toon_material(color) + parent.add_child(mi) + return mi + + +## The shared cel-shading material, tinted. Vertex colors are unused on these +## boxes, so the tint uniform carries the color and `use_srgb_vertex` is off +## (BoxMesh has no COLOR channel to linearize). +static func toon_material(color: Color) -> ShaderMaterial: + var mat: ShaderMaterial = ShaderMaterial.new() + mat.shader = load(TOON_SHADER) as Shader + mat.set_shader_parameter("use_srgb_vertex", false) + mat.set_shader_parameter("albedo_tint", color) + mat.set_shader_parameter("rim_amount", 0.22) + mat.set_shader_parameter("rim_color", Color(0.86, 0.88, 0.95)) + # Warmer than the meadow's sky fill: underground, the bounce light comes + # off the vault's own gold runes rather than off a blue sky. + mat.set_shader_parameter("shadow_fill", Color(0.72, 0.62, 0.48)) + mat.set_shader_parameter("fill_amount", 0.20) + return mat + + +# --- Rooms ------------------------------------------------------------------- + +## A sealed rectangular room: floor, ceiling, and four walls, built inward +## from `inner_size` (the clear interior volume, in meters) so the caller can +## reason about the space Kern actually walks in. +## +## `center` is the middle of the FLOOR, so a room at y = 0 has its floor slab +## just below y = 0 and its ceiling `inner_size.y` above it. +## +## `doors` cuts openings: each entry is `{ "side": "north"|"south"|"east"| +## "west", "width": float, "height": float, "offset": float }`, where offset +## slides the opening along the wall from its center. A wall with an opening +## is emitted as two jambs plus a lintel; a wall without one is a single box. +static func room(parent: Node3D, center: Vector3, inner_size: Vector3, + thickness: float, doors: Array[Dictionary] = []) -> void: + var hx: float = inner_size.x * 0.5 + var hz: float = inner_size.z * 0.5 + var h: float = inner_size.y + + # Floor and ceiling overhang the walls by `thickness` on each side so + # corners never show a seam from inside. + var slab: Vector3 = Vector3(inner_size.x + thickness * 2.0, thickness, + inner_size.z + thickness * 2.0) + solid(parent, center + Vector3(0.0, -thickness * 0.5, 0.0), slab, STONE_FLOOR) + solid(parent, center + Vector3(0.0, h + thickness * 0.5, 0.0), slab, STONE_DARK) + + # North is -Z and south is +Z, matching the meadow's compass (the Gradient + # Peaks vista sits at -Z). East is +X. + _wall(parent, center, Vector3(0.0, 0.0, -hz - thickness * 0.5), + Vector3(inner_size.x + thickness * 2.0, h, thickness), + _door_for(doors, "north"), true) + _wall(parent, center, Vector3(0.0, 0.0, hz + thickness * 0.5), + Vector3(inner_size.x + thickness * 2.0, h, thickness), + _door_for(doors, "south"), true) + _wall(parent, center, Vector3(-hx - thickness * 0.5, 0.0, 0.0), + Vector3(thickness, h, inner_size.z), + _door_for(doors, "west"), false) + _wall(parent, center, Vector3(hx + thickness * 0.5, 0.0, 0.0), + Vector3(thickness, h, inner_size.z), + _door_for(doors, "east"), false) + + +## Find the door spec for one side, or an empty Dictionary if that wall is +## solid. Only the first match is used — two openings in one wall would need +## a second call with a different offset. +static func _door_for(doors: Array[Dictionary], side: String) -> Dictionary: + for d: Dictionary in doors: + if str(d.get("side", "")) == side: + return d + return {} + + +## One wall of a room. `span_on_x` says which horizontal axis the wall runs +## along, which is what decides where the jambs go. With no door spec the +## wall is a single box. +static func _wall(parent: Node3D, center: Vector3, offset: Vector3, + size: Vector3, door: Dictionary, span_on_x: bool) -> void: + var base: Vector3 = center + offset + Vector3(0.0, size.y * 0.5, 0.0) + if door.is_empty(): + solid(parent, base, size, STONE_WALL) + return + + var dw: float = float(door.get("width", 5.0)) + var dh: float = float(door.get("height", 4.5)) + var dof: float = float(door.get("offset", 0.0)) + var span: float = size.x if span_on_x else size.z + # Jamb widths either side of the opening. maxf keeps a jamb from going + # negative if a caller asks for an opening wider than its wall. + var left: float = maxf(0.0, span * 0.5 + dof - dw * 0.5) + var right: float = maxf(0.0, span * 0.5 - dof - dw * 0.5) + var left_c: float = -span * 0.5 + left * 0.5 + var right_c: float = span * 0.5 - right * 0.5 + + if span_on_x: + if left > 0.01: + solid(parent, base + Vector3(left_c, 0.0, 0.0), + Vector3(left, size.y, size.z), STONE_WALL) + if right > 0.01: + solid(parent, base + Vector3(right_c, 0.0, 0.0), + Vector3(right, size.y, size.z), STONE_WALL) + if size.y - dh > 0.01: + solid(parent, base + Vector3(dof, (size.y + dh) * 0.5 - size.y * 0.5, 0.0), + Vector3(dw, size.y - dh, size.z), STONE_WALL) + else: + if left > 0.01: + solid(parent, base + Vector3(0.0, 0.0, left_c), + Vector3(size.x, size.y, left), STONE_WALL) + if right > 0.01: + solid(parent, base + Vector3(0.0, 0.0, right_c), + Vector3(size.x, size.y, right), STONE_WALL) + if size.y - dh > 0.01: + solid(parent, base + Vector3(0.0, (size.y + dh) * 0.5 - size.y * 0.5, dof), + Vector3(size.x, size.y - dh, dw), STONE_WALL) + + +# --- Light ------------------------------------------------------------------- + +## A glowing surface using vault_rune.gdshader. `size` is full extents; the +## mesh's long axis should be Y when the caller intends to use `set_fill()`. +## No collision — light is never solid in this vault. +static func rune(parent: Node3D, center: Vector3, size: Vector3, color: Color, + energy: float = 2.4) -> MeshInstance3D: + var mesh: BoxMesh = BoxMesh.new() + mesh.size = size + var mi: MeshInstance3D = MeshInstance3D.new() + mi.mesh = mesh + mi.position = center + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var mat: ShaderMaterial = ShaderMaterial.new() + mat.shader = load(RUNE_SHADER) as Shader + mat.set_shader_parameter("glow_color", color) + mat.set_shader_parameter("energy", energy) + # The meter uniforms describe this mesh's Y extent, so callers can pass a + # plain 0..1 fraction to set_fill() without knowing the geometry. + mat.set_shader_parameter("fill_height", size.y) + mat.set_shader_parameter("fill_base", -size.y * 0.5) + mi.material_override = mat + parent.add_child(mi) + return mi + + +## Recolor a rune built by `rune()`. Used constantly — a fount toggling, a +## weight flipping sign, a chamber firing. +static func set_rune_color(mi: MeshInstance3D, color: Color, energy: float = -1.0) -> void: + var mat: ShaderMaterial = mi.material_override as ShaderMaterial + if mat == null: + return + mat.set_shader_parameter("glow_color", color) + if energy >= 0.0: + mat.set_shader_parameter("energy", energy) + + +## Drive a rune as a fill meter. `fraction` is 0..1 of the mesh's Y extent. +static func set_rune_fill(mi: MeshInstance3D, fraction: float) -> void: + var mat: ShaderMaterial = mi.material_override as ShaderMaterial + if mat != null: + mat.set_shader_parameter("fill", clampf(fraction, 0.0, 1.0)) + + +## Turn on the travelling-band effect — a conduit carrying signal. `amount` +## is 0 to switch it off. Negative `speed` runs the band the other way, which +## is how an inbound conduit is distinguished from an outbound one. +static func set_rune_flow(mi: MeshInstance3D, amount: float, speed: float = 1.2, + density: float = 0.35) -> void: + var mat: ShaderMaterial = mi.material_override as ShaderMaterial + if mat == null: + return + mat.set_shader_parameter("flow_amount", amount) + mat.set_shader_parameter("flow_speed", speed) + mat.set_shader_parameter("flow_density", density) + + +## Set the breathing rate/depth of a rune. Distinct phases keep a row of +## fixtures from throbbing in unison, which reads as machinery, not magic. +static func set_rune_pulse(mi: MeshInstance3D, amount: float, speed: float = 1.6, + phase: float = 0.0) -> void: + var mat: ShaderMaterial = mi.material_override as ShaderMaterial + if mat == null: + return + mat.set_shader_parameter("pulse_amount", amount) + mat.set_shader_parameter("pulse_speed", speed) + mat.set_shader_parameter("pulse_phase", phase) + + +## An omni light — the vault is sealed, so every lit space needs one. Kept +## shadowless by default: these are fill lights for a windowless interior and +## the sun's 4k shadow map is already doing the expensive work outside. +static func lamp(parent: Node3D, center: Vector3, color: Color, + energy: float = 2.0, range_m: float = 16.0, + shadows: bool = false) -> OmniLight3D: + var l: OmniLight3D = OmniLight3D.new() + l.position = center + l.light_color = color + l.light_energy = energy + l.omni_range = range_m + l.shadow_enabled = shadows + parent.add_child(l) + return l + + +# --- Inscriptions ------------------------------------------------------------ + +## A carved plaque. The vault explains itself in plain stonecutter's English — +## WORLDBOOK Part IV forbids the vocabulary the mechanic is named after, so +## these read as instructions from the builders and never as a tutorial. +## +## `yaw` (radians) turns the carving to face into its room. Label3D faces +## local +Z, so a plaque on a room's NORTH wall needs yaw 0 and one on the +## SOUTH wall needs PI — get this wrong and the text faces into the stone. +static func plaque(parent: Node3D, center: Vector3, text: String, + width: float = 6.0, size_px: int = 44, yaw: float = 0.0) -> Label3D: + var label: Label3D = Label3D.new() + label.text = text + label.position = center + label.rotation.y = yaw + label.font_size = size_px + label.outline_size = size_px / 5 + label.modulate = Color(0.96, 0.90, 0.74) + label.outline_modulate = Color(0.10, 0.08, 0.06, 0.9) + # Label3D measures its wrap width in pixels and scales by pixel_size. At + # 0.01 m/px, 100 px is one meter — so `width` can be given in meters. + label.pixel_size = PLAQUE_METERS_PER_PIXEL + label.width = width / PLAQUE_METERS_PER_PIXEL + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + # Carved into a wall: it must not swivel to face the camera. + label.billboard = BaseMaterial3D.BILLBOARD_DISABLED + label.shaded = false + parent.add_child(label) + return label diff --git a/game/src/dungeon/vault_build.gd.uid b/game/src/dungeon/vault_build.gd.uid new file mode 100644 index 0000000..62f2cf1 --- /dev/null +++ b/game/src/dungeon/vault_build.gd.uid @@ -0,0 +1 @@ +uid://ctd4lcdltoye3 diff --git a/game/src/dungeon/vault_gate.gd b/game/src/dungeon/vault_gate.gd new file mode 100644 index 0000000..7042d3c --- /dev/null +++ b/game/src/dungeon/vault_gate.gd @@ -0,0 +1,158 @@ +class_name VaultGate +extends Node3D +## The Perceptron Vault's output gate — two stone leaves that part when the +## last cell of the network fires. +## +## It is the only thing in the dungeon that is genuinely locked, and it is +## locked on the boss rather than on the puzzle (GDD §3: no hard gates except +## the Corpus Citadel; danger and cleverness are the gates). Beyond it sits +## the reliquary the vault was built to hold. +## +## Where it sits: built and owned by PerceptronVault, which calls `open()` +## when VaultGatekeeper reports it overloaded, or `open_instantly()` when a +## returning player already carries the cleared flag. +## +## The leaves carry their own collision (VaultBuild.solid parents the +## StaticBody3D to the mesh), so sliding the MeshInstance3D moves the wall +## Kern is standing against — no separate physics bookkeeping. + +## Emitted once the leaves have finished parting. +signal opened() + +## Seconds the leaves take to grind apart. Slow on purpose: this is the +## dungeon's payoff beat, not a door. +const OPEN_TIME: float = 2.6 +## How far each leaf travels sideways, in meters. A leaf starts centered on +## its own half of the opening, so it must travel more than a full half-width +## to clear the doorway entirely — at exactly half it would still overhang. +## The leaves slide into the wall's jambs, pocket-door style. +const LEAF_TRAVEL: float = 4.2 + +var is_open: bool = false + +var _leaves: Array[MeshInstance3D] = [] +var _leaf_home: Array[Vector3] = [] +var _runes: Array[MeshInstance3D] = [] +var _lamp: OmniLight3D + + +## Build a two-leaf gate filling an opening `width` × `height` meters, +## centered on `center` in the parent's space, in a wall of `thickness`. +static func build(host: Node3D, center: Vector3, width: float, height: float, + thickness: float) -> VaultGate: + var g: VaultGate = VaultGate.new() + g.position = center + host.add_child(g) + g._build(width, height, thickness) + return g + + +func _ready() -> void: + name = "OutputGate" + + +func _build(width: float, height: float, thickness: float) -> void: + var half: float = width * 0.5 + for side: int in [-1, 1]: + var leaf: MeshInstance3D = VaultBuild.solid(self, + Vector3(float(side) * half * 0.5, height * 0.5, 0.0), + Vector3(half, height, thickness), VaultBuild.STONE_TRIM) + _leaves.append(leaf) + _leaf_home.append(leaf.position) + # A seam of light down each leaf's inner edge — dark while sealed, so + # the gate reads as inert stone until the moment it matters. Set on + # the NORTH face (-Z): that is the arena side, the only side a player + # sees before the gate opens. + var seam: MeshInstance3D = VaultBuild.rune(self, + Vector3(float(side) * 0.09, height * 0.5, -thickness * 0.55), + Vector3(0.1, height * 0.86, 0.05), VaultBuild.LIGHT_IDLE, 0.8) + _runes.append(seam) + + # The 2-3-1 sigil above the gate: the vault's own mark, and the same + # figure carved on the facade outside — two founts, three chambers, one + # way out. It is also the shape on the game's icon. Arena side again, and + # above the opening rather than on it, so the leaves never slide over it. + _build_sigil(Vector3(0.0, height + 1.6, -thickness * 0.6), 3.0) + + _lamp = VaultBuild.lamp(self, Vector3(0.0, height * 0.6, -thickness * 1.2), + VaultBuild.LIGHT_IDLE, 0.6, 14.0) + + +## The 2-3-1 figure: two nodes, then three, then one, with every node of each +## row joined to every node of the next. Drawn with thin boxes rather than +## lines so it catches the vault's own light. `span` is its full width. +func _build_sigil(center: Vector3, span: float) -> void: + var rows: Array[int] = [2, 3, 1] + var row_gap: float = span * 0.42 + var node_r: float = 0.19 + var points: Array[Array] = [] + for r: int in rows.size(): + var count: int = rows[r] + var row: Array[Vector3] = [] + for i: int in count: + # Center each row on x = 0 regardless of how many nodes it holds. + var y: float = (float(i) - float(count - 1) * 0.5) * (span / 3.2) + var x: float = (float(r) - 1.0) * row_gap + row.append(center + Vector3(x, y, 0.0)) + points.append(row) + + # Edges first, so the node dots sit on top of them. + for r: int in rows.size() - 1: + for a: Vector3 in points[r]: + for b: Vector3 in points[r + 1]: + var mid: Vector3 = (a + b) * 0.5 + var delta: Vector3 = b - a + var edge: MeshInstance3D = VaultBuild.rune(self, mid, + Vector3(delta.length(), 0.045, 0.04), + VaultBuild.LIGHT_IDLE, 1.0) + # Rotate about Z so the bar's long X axis lies along the edge. + edge.rotation.z = atan2(delta.y, delta.x) + _runes.append(edge) + for row: Array in points: + for p: Vector3 in row: + _runes.append(VaultBuild.rune(self, p as Vector3, + Vector3(node_r * 2.0, node_r * 2.0, 0.05), + VaultBuild.LIGHT_IDLE, 1.4)) + + +## Part the leaves over OPEN_TIME, lighting the sigil and seams as they go. +## Safe to call twice — the second call is a no-op. +func open() -> void: + if is_open: + return + is_open = true + _light(VaultBuild.LIGHT_OPEN) + var tw: Tween = create_tween() + tw.set_parallel(true) + tw.set_trans(Tween.TRANS_SINE) + tw.set_ease(Tween.EASE_IN_OUT) + for i in _leaves.size(): + var away: float = -LEAF_TRAVEL if i == 0 else LEAF_TRAVEL + tw.tween_property(_leaves[i], "position", + _leaf_home[i] + Vector3(away, 0.0, 0.0), OPEN_TIME) + tw.chain().tween_callback(func() -> void: opened.emit()) + + +## Put the gate straight into its open state with no animation — used when a +## returning player already cleared the vault, so the world matches the save +## the instant it loads. +func open_instantly() -> void: + if is_open: + return + is_open = true + _light(VaultBuild.LIGHT_OPEN) + for i in _leaves.size(): + var away: float = -LEAF_TRAVEL if i == 0 else LEAF_TRAVEL + _leaves[i].position = _leaf_home[i] + Vector3(away, 0.0, 0.0) + opened.emit() + + +## Bring every rune on the gate up to `color` — the sigil, the seams, and the +## lamp that washes the wall around them. +func _light(color: Color) -> void: + for r: MeshInstance3D in _runes: + VaultBuild.set_rune_color(r, color, 3.2) + VaultBuild.set_rune_pulse(r, 0.20, 1.4) + if _lamp != null: + _lamp.light_color = color + _lamp.light_energy = 3.0 diff --git a/game/src/dungeon/vault_gate.gd.uid b/game/src/dungeon/vault_gate.gd.uid new file mode 100644 index 0000000..3c5bfa8 --- /dev/null +++ b/game/src/dungeon/vault_gate.gd.uid @@ -0,0 +1 @@ +uid://cceqy21kkmqc0 diff --git a/game/src/dungeon/vault_gatekeeper.gd b/game/src/dungeon/vault_gatekeeper.gd new file mode 100644 index 0000000..c521d6c --- /dev/null +++ b/game/src/dungeon/vault_gatekeeper.gd @@ -0,0 +1,348 @@ +class_name VaultGatekeeper +extends CharacterBody3D +## The Gatekeeper — the Perceptron Vault's mini-boss, and the last cell of its +## 2-3-1 network (WORLDBOOK, Datasedge Meadows: "sums what hits it; overload +## it"). +## +## It is the only enemy in the game with no hearts. It has a CHARGE, and the +## fight is a race to push that charge over its threshold before it leaks +## away. Everything that reaches the Gatekeeper is summed: +## +## * Kern's sword — a fixed step per landed swing, regardless of combo damage +## * the vault itself — a steady feed while the three hidden chambers are +## tuned so the output cell fires (see PerceptronVault.solve()) +## * its own leak — a constant drain that never stops +## +## The catch is its POLARITY. It alternates between drawing (gold) and +## refusing (blue), telegraphed a beat ahead by color and by a wind-up flare. +## Strike it while it is blue and the blow SUBTRACTS. So the fight teaches the +## sign of a weight with the only argument that really lands: hitting the +## thing at the wrong moment undoes your work. +## +## The two routes are both intended, per GDD §3's open-world contract. Solve +## the vault first and the network does most of the pushing while you dodge; +## walk in with the chambers dark and you must out-hit the leak by hand, which +## is hard and entirely legitimate. +## +## Where it sits: built in code by PerceptronVault when Kern first enters the +## output chamber, and never field-spawned (MonsterSpawner skips the +## `dungeon_boss` tier by design). It wears the same strikeable costume as +## every enemy — ENEMY collision layer, "enemy" group, `apply_hit()` — so +## Kern's existing sword kit works on it unchanged, with no edits to any +## combat script. Emits EventBus.enemy_defeated, combat_shake, and +## enemy_hit (with charge in place of hearts, documented at the emit). +## +## Units: meters, seconds, charge points. Charge is unitless and only ever +## compared against `threshold`. + +## Emitted when the charge crosses the threshold and the cell fires. The +## vault listens and opens the output gate. +signal overloaded() + +## The charge that fires it. Tuned so the solved-vault route takes roughly +## six seconds of survival and the sword-only route takes about ten landed +## swings without a slip — a real fight either way. +const THRESHOLD: float = 12.0 +## Charge added by one landed sword strike while drawing (and removed while +## refusing). Deliberately flat: the fight is about timing, not about which +## combo step landed. +const STRIKE_CHARGE: float = 1.2 +## Charge lost per second, always. This is what makes hesitation cost. +const LEAK_PER_SECOND: float = 0.55 +## Charge per second fed by the vault while the output cell fires. +const NETWORK_FEED: float = 2.6 + +## Seconds spent drawing (gold) and refusing (blue). +const DRAW_TIME: float = 6.0 +const REFUSE_TIME: float = 3.5 +## Seconds of colour warning before a polarity flip. The tell has to lead the +## change or the fight is a coin toss. +const FLIP_TELEGRAPH: float = 0.9 + +## Seconds between bolt volleys in each polarity. It presses harder while +## refusing, so backing off is a real choice rather than a free rest. +const VOLLEY_DRAW: float = 2.2 +const VOLLEY_REFUSE: float = 1.4 +const BOLTS_PER_VOLLEY: int = 5 +const BOLT_SPEED: float = 11.0 +const BOLT_DAMAGE: float = 0.5 + +## Body proportions, meters. +const BODY_HEIGHT: float = 3.4 +const BODY_WIDTH: float = 2.2 +const CORE_HEIGHT: float = 2.4 +## How far it drifts above the floor, and its hover bob. +const HOVER_HEIGHT: float = 1.1 +const BOB_AMPLITUDE: float = 0.22 +const BOB_SPEED: float = 1.15 +## Yaw turn rate (1/s) while tracking Kern. +const TURN_SPEED: float = 2.2 + +## Content id, so drops and the defeat signal route through the pipeline the +## same way every other monster does. +var monster_id: String = "mon_the_gatekeeper" +## Live charge, 0..THRESHOLD. Never allowed below zero — a refused blow can +## undo progress but can never put the player in debt. +var charge: float = 0.0 +## True while the vault's output cell is firing into it. +var network_feeding: bool = false + +## True = drawing (gold, strikes add). False = refusing (blue, strikes subtract). +var _drawing: bool = true +var _phase_left: float = DRAW_TIME +var _volley_left: float = VOLLEY_DRAW +var _spent: bool = false +var _bob_t: float = 0.0 + +var _player: Node3D +## The four stone slabs of the body, their build-time rest positions, and the +## flinch tween running on each (parallel arrays, same index). +var _shell: Array[MeshInstance3D] = [] +var _shell_rest: Array[Vector3] = [] +var _shell_tweens: Array[Tween] = [] +var _core: MeshInstance3D +var _ring: MeshInstance3D +var _lamp: OmniLight3D +var _label: Label3D +var _home: Vector3 = Vector3.ZERO + + +## Build the Gatekeeper at `world_position` (its floor point) and parent it to +## `host`. The only supported way to make one. +static func build(host: Node3D, world_position: Vector3) -> VaultGatekeeper: + var g: VaultGatekeeper = VaultGatekeeper.new() + g.position = world_position + host.add_child(g) + return g + + +func _ready() -> void: + add_to_group(&"enemy") + add_to_group(&"hittable") + name = "Gatekeeper" + # Collides with world geometry only. Like every monster it passes through + # Kern rather than shoving him — a boss that could body-block in a sealed + # room would be a camera problem, not a challenge. + collision_layer = CombatLayers.ENEMY + collision_mask = CombatLayers.WORLD + _home = global_position + _build_body() + _refresh_polarity(true) + + +## A blunt, wide keystone shape: two slabs and a lintel around a glowing core, +## silhouette-first per GDD §10 — it should read as a doorway that woke up. +func _build_body() -> void: + var shape: CollisionShape3D = CollisionShape3D.new() + var box: BoxShape3D = BoxShape3D.new() + box.size = Vector3(BODY_WIDTH, BODY_HEIGHT, 1.4) + shape.shape = box + shape.position = Vector3(0.0, HOVER_HEIGHT + BODY_HEIGHT * 0.5, 0.0) + add_child(shape) + + var mid: float = HOVER_HEIGHT + BODY_HEIGHT * 0.5 + var jamb: Vector3 = Vector3(0.55, BODY_HEIGHT, 1.1) + _shell.append(VaultBuild.decor(self, Vector3(-BODY_WIDTH * 0.5 + 0.28, mid, 0.0), + jamb, VaultBuild.STONE_TRIM)) + _shell.append(VaultBuild.decor(self, Vector3(BODY_WIDTH * 0.5 - 0.28, mid, 0.0), + jamb, VaultBuild.STONE_TRIM)) + _shell.append(VaultBuild.decor(self, + Vector3(0.0, HOVER_HEIGHT + BODY_HEIGHT - 0.3, 0.0), + Vector3(BODY_WIDTH, 0.6, 1.25), VaultBuild.STONE_TRIM)) + _shell.append(VaultBuild.decor(self, Vector3(0.0, HOVER_HEIGHT + 0.3, 0.0), + Vector3(BODY_WIDTH, 0.6, 1.25), VaultBuild.STONE_TRIM)) + for part: MeshInstance3D in _shell: + _shell_rest.append(part.position) + _shell_tweens.append(null) + + # The core IS the charge meter — same fill shader as the chambers' sum + # columns, so the player already knows how to read it before the fight. + _core = VaultBuild.rune(self, + Vector3(0.0, HOVER_HEIGHT + BODY_HEIGHT * 0.5, 0.0), + Vector3(0.95, CORE_HEIGHT, 0.95), VaultBuild.LIGHT_POSITIVE, 3.0) + VaultBuild.set_rune_fill(_core, 0.0) + VaultBuild.set_rune_flow(_core, 0.6, 1.4, 0.7) + + # A halo ring that carries the polarity color at a glance, readable even + # when the core is behind a jamb from the player's angle. + _ring = VaultBuild.rune(self, + Vector3(0.0, HOVER_HEIGHT + BODY_HEIGHT + 0.5, 0.0), + Vector3(BODY_WIDTH * 1.5, 0.12, BODY_WIDTH * 1.5), VaultBuild.LIGHT_POSITIVE, 2.8) + + _lamp = VaultBuild.lamp(self, Vector3(0.0, mid, 0.0), VaultBuild.LIGHT_POSITIVE, + 3.4, 26.0) + + _label = VaultBuild.plaque(self, + Vector3(0.0, HOVER_HEIGHT + BODY_HEIGHT + 1.3, 0.0), "the Gatekeeper", 6.0, 52) + _label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + + +func _physics_process(delta: float) -> void: + if _spent: + return + _track_player(delta) + _tick_charge(delta) + _tick_polarity(delta) + _tick_volley(delta) + + # It hovers in place rather than chasing — the arena is the fight, and a + # charging boss you cannot out-walk would make dodging pointless. + _bob_t += delta * BOB_SPEED + global_position.y = _home.y + sin(_bob_t) * BOB_AMPLITUDE + VaultBuild.set_rune_fill(_core, charge / THRESHOLD) + + +## Face Kern so the core (and the polarity tell) always presents to him. +func _track_player(delta: float) -> void: + if _player == null or not is_instance_valid(_player): + _player = get_tree().get_first_node_in_group(&"player") as Node3D + if _player == null: + return + var to: Vector3 = _player.global_position - global_position + to.y = 0.0 + if to.length_squared() < 0.0004: + return + # Negated because Godot's forward is -Z. + rotation.y = lerp_angle(rotation.y, atan2(-to.x, -to.z), minf(1.0, TURN_SPEED * delta)) + + +## Leak, then feed. Order matters only for readability — both are per-second +## rates applied to the same accumulator. +func _tick_charge(delta: float) -> void: + var before: float = charge + charge = maxf(0.0, charge - LEAK_PER_SECOND * delta) + if network_feeding: + charge += NETWORK_FEED * delta + charge = clampf(charge, 0.0, THRESHOLD) + if charge >= THRESHOLD and before < THRESHOLD: + _fire() + + +## Run the polarity clock and flip when it expires, flaring a warning as the +## flip approaches. +func _tick_polarity(delta: float) -> void: + _phase_left -= delta + if _phase_left <= 0.0: + _drawing = not _drawing + _phase_left = DRAW_TIME if _drawing else REFUSE_TIME + _refresh_polarity(false) + return + # Warning flare: the ring quickens as the flip nears, so a player watching + # the boss rather than a timer still gets the beat. + if _phase_left <= FLIP_TELEGRAPH: + var urgency: float = 1.0 - _phase_left / FLIP_TELEGRAPH + VaultBuild.set_rune_pulse(_ring, 0.55, 3.0 + urgency * 9.0) + + +## Repaint everything that carries polarity. `initial` skips the shard puff so +## the boss does not burst on spawn. +func _refresh_polarity(initial: bool) -> void: + var col: Color = VaultBuild.LIGHT_POSITIVE if _drawing else VaultBuild.LIGHT_NEGATIVE + VaultBuild.set_rune_color(_core, col, 3.0) + VaultBuild.set_rune_color(_ring, col, 2.8) + VaultBuild.set_rune_pulse(_ring, 0.25, 1.8) + if _lamp != null: + _lamp.light_color = col + if initial: + return + DamageShards.burst(get_tree().current_scene, + global_position + Vector3(0.0, HOVER_HEIGHT + BODY_HEIGHT * 0.5, 0.0), + col, 16, 4.2, 1.8, 1.0) + EventBus.combat_shake.emit(0.10) + + +## Volley clock. Bolts fan out in a level arc centered on Kern, so there is +## always a gap to dodge into but never a safe place to stand still. +func _tick_volley(delta: float) -> void: + _volley_left -= delta + if _volley_left > 0.0: + return + _volley_left = VOLLEY_DRAW if _drawing else VOLLEY_REFUSE + if _player == null or not is_instance_valid(_player): + return + var origin: Vector3 = global_position + Vector3(0.0, HOVER_HEIGHT + BODY_HEIGHT * 0.5, 0.0) + var to_player: Vector3 = (_player.global_position + Vector3(0.0, 0.9, 0.0)) - origin + var base_yaw: float = atan2(to_player.x, to_player.z) + var pitch: float = to_player.normalized().y + var spread: float = deg_to_rad(13.0) + var col: Color = VaultBuild.LIGHT_POSITIVE if _drawing else VaultBuild.LIGHT_NEGATIVE + for i in BOLTS_PER_VOLLEY: + var offset: float = (float(i) - float(BOLTS_PER_VOLLEY - 1) * 0.5) * spread + var yaw: float = base_yaw + offset + var dir: Vector3 = Vector3(sin(yaw), pitch, cos(yaw)).normalized() + Projectile.spawn(get_tree().current_scene, origin, dir, BOLT_SPEED, + BOLT_DAMAGE, col) + + +## PlayerCombat's swing contract. `amount` and `knockback` are ignored on +## purpose — a keystone the size of a door does not stagger, and the fight is +## about WHEN you swing, not how hard. While drawing a strike adds charge; +## while refusing it takes the same amount away. +func apply_hit(_amount: float, from_position: Vector3, _knockback: float) -> void: + if _spent: + return + var before: float = charge + charge = clampf(charge + (STRIKE_CHARGE if _drawing else -STRIKE_CHARGE), + 0.0, THRESHOLD) + var hit_at: Vector3 = global_position + Vector3(0.0, HOVER_HEIGHT + BODY_HEIGHT * 0.5, 0.0) + var col: Color = VaultBuild.LIGHT_POSITIVE if _drawing else VaultBuild.LIGHT_NEGATIVE + DamageShards.burst(get_tree().current_scene, hit_at, col, 10, 3.4, 1.6, 0.9) + EventBus.combat_shake.emit(0.14 if _drawing else 0.08) + # The shell flinches toward the blow so a refused hit still feels like + # contact — the punishment is on the meter, not on the feedback. + _flinch(from_position) + # Deliberately does NOT emit EventBus.enemy_hit: that signal's second + # argument is remaining hearts, and this boss has none. Passing charge + # there would put a number in the contract that does not mean what the + # signature says. enemy_defeated on the other hand is exactly true, and + # is emitted in _fire(). + if charge >= THRESHOLD and before < THRESHOLD: + _fire() + + +## A quick shove-and-return on the stone shell, away from the blow. +## +## The rest positions are the ones cached at build time, never the shell's +## current position: a three-hit combo lands faster than one flinch finishes, +## and reading the live position as "home" would let each overlapping tween +## start from the last one's displacement and walk the shell off the body. +func _flinch(from_position: Vector3) -> void: + var away: Vector3 = global_position - from_position + away.y = 0.0 + if away.length_squared() < 0.0001: + return + # World offset into the body's own frame, so the shove reads correctly + # whichever way the Gatekeeper happens to be facing. + var push: Vector3 = global_transform.basis.inverse() * (away.normalized() * 0.22) + for i in _shell.size(): + var part: MeshInstance3D = _shell[i] + var rest: Vector3 = _shell_rest[i] + var running: Tween = _shell_tweens[i] + if running != null and running.is_valid(): + running.kill() + var tw: Tween = create_tween() + tw.tween_property(part, "position", rest + push, 0.05) + tw.tween_property(part, "position", rest, 0.16) + _shell_tweens[i] = tw + + +## The cell fires: the Gatekeeper cannot hold what it has summed, comes apart +## into shards (WORLDBOOK Part IV — never killed, never gore), and the vault +## opens behind it. +func _fire() -> void: + if _spent: + return + _spent = true + collision_layer = 0 + var center: Vector3 = global_position + Vector3(0.0, HOVER_HEIGHT + BODY_HEIGHT * 0.5, 0.0) + DamageShards.burst(get_tree().current_scene, center, VaultBuild.LIGHT_POSITIVE, + 34, 7.0, 3.0, 1.6) + EventBus.combat_shake.emit(0.5) + EventBus.enemy_defeated.emit(monster_id, global_position) + overloaded.emit() + if _label != null: + _label.visible = false + # Collapse over a beat so the burst reads before the body goes. + var tw: Tween = create_tween() + tw.tween_property(self, "scale", Vector3.ONE * 0.01, 0.4) + tw.tween_callback(queue_free) diff --git a/game/src/dungeon/vault_gatekeeper.gd.uid b/game/src/dungeon/vault_gatekeeper.gd.uid new file mode 100644 index 0000000..b5eada9 --- /dev/null +++ b/game/src/dungeon/vault_gatekeeper.gd.uid @@ -0,0 +1 @@ +uid://bm04velysga82 diff --git a/game/src/dungeon/weight_stone.gd b/game/src/dungeon/weight_stone.gd new file mode 100644 index 0000000..cfa2c81 --- /dev/null +++ b/game/src/dungeon/weight_stone.gd @@ -0,0 +1,161 @@ +class_name WeightStone +extends StaticBody3D +## A tunable weight set into a chamber wall — the Perceptron Vault's second +## verb. Kern strikes it and it steps to the next value on its own short +## ladder; the chamber's sum changes the instant it does. +## +## This is the milestone's teaching move. The vault's thresholds are fixed and +## the founts are only bits, so the ONLY way to make a chamber fire is to tune +## the weights until the sum clears the beam. Doing that by hand, chamber by +## chamber, is training a network by hand — the lesson arrives through the +## fingers rather than through a lecture. +## +## Each stone's ladder is fixed at build time and deliberately restricted: +## some sockets can only ever add (an excitatory input), others can only ever +## take away (an inhibitory one). Without that restriction the puzzle would +## collapse to "set everything to the maximum". +## +## Sign is carried by color everywhere in this dungeon and nowhere else: gold +## adds, blue takes away. Magnitude is carried by how many glyph bars light. +## +## Wears the same strikeable costume as SignalFount (ENEMY layer, "enemy" +## group, `apply_hit()`) for the reason documented there. Built in code by +## NeuronChamber; emits `changed` so the chamber can recompute. + +## Emitted after a strike steps the ladder. `value` is the new weight. +signal changed(value: int) + +const STRIKE_COOLDOWN: float = 0.42 +## Socket dimensions, meters. The stone sits proud of the wall so its +## silhouette reads from across the chamber. +const STONE_SIZE: Vector3 = Vector3(1.0, 1.0, 0.55) +const GLYPH_BAR_SIZE: Vector3 = Vector3(0.62, 0.09, 0.06) +const GLYPH_BAR_GAP: float = 0.19 +## Largest magnitude any ladder in the vault uses. Fixes how many glyph bars +## are built, so a stone's frame never has to be rebuilt when it steps. +const MAX_MAGNITUDE: int = 2 + +## The values this stone cycles through, in order, wrapping at the end. +var ladder: Array[int] = [1, 2] +## Index into `ladder`; `value` is derived from it. +var _rung: int = 0 + +var _face: MeshInstance3D +var _bars: Array[MeshInstance3D] = [] +var _lamp: OmniLight3D +var _cooldown: float = 0.0 + + +## Build a stone in a wall socket. `world_position` is the center of the +## stone's face, `facing_yaw` turns it to face into the room (radians), and +## `rungs` is the ladder — it must hold at least one value. +## +## `rungs` arrives untyped because the ladders live in a `const` table, and +## constants in Godot 4 are deeply read-only. `assign()` makes this stone a +## typed, mutable copy rather than aliasing the constant. +static func build(host: Node3D, world_position: Vector3, facing_yaw: float, + rungs: Array) -> WeightStone: + var s: WeightStone = WeightStone.new() + var typed: Array[int] = [] + typed.assign(rungs) + if typed.is_empty(): + typed.append(1) # a stone with no ladder would be unstrikeable furniture + s.ladder = typed + s.position = world_position + s.rotation.y = facing_yaw + host.add_child(s) + return s + + +func _ready() -> void: + add_to_group(&"enemy") + add_to_group(&"vault_prop") + collision_layer = CombatLayers.ENEMY + collision_mask = 0 + name = "WeightStone" + _build() + _refresh() + + +## Current weight. Always a member of `ladder`. +func value() -> int: + return ladder[_rung] + + +## True if this socket can never contribute a positive weight — used by the +## chamber to write an honest plaque without hard-coding each socket's sign. +func is_inhibitory() -> bool: + for v: int in ladder: + if v > 0: + return false + return true + + +## Frame, strike collider, glyph face, and the fixed row of magnitude bars. +## Bars are built once for MAX_MAGNITUDE and simply lit or dimmed on change, +## so stepping the ladder never allocates. +func _build() -> void: + var shape: CollisionShape3D = CollisionShape3D.new() + var box: BoxShape3D = BoxShape3D.new() + box.size = STONE_SIZE * 1.25 + shape.shape = box + shape.position = Vector3(0.0, 0.0, STONE_SIZE.z * 0.5) + add_child(shape) + + # Surround: a stone frame a little larger than the glyph face. + VaultBuild.decor(self, Vector3(0.0, 0.0, 0.0), + Vector3(STONE_SIZE.x * 1.4, STONE_SIZE.y * 1.4, STONE_SIZE.z), + VaultBuild.STONE_TRIM) + _face = VaultBuild.rune(self, Vector3(0.0, 0.0, STONE_SIZE.z * 0.52), + Vector3(STONE_SIZE.x, STONE_SIZE.y, 0.05), VaultBuild.LIGHT_IDLE, 1.4) + + # Magnitude bars, stacked and centered. Two bars lit means |w| = 2. + var top: float = (float(MAX_MAGNITUDE) - 1.0) * GLYPH_BAR_GAP * 0.5 + for i in MAX_MAGNITUDE: + var bar: MeshInstance3D = VaultBuild.rune(self, + Vector3(0.0, top - float(i) * GLYPH_BAR_GAP, STONE_SIZE.z * 0.56), + GLYPH_BAR_SIZE, VaultBuild.LIGHT_IDLE, 2.0) + _bars.append(bar) + + _lamp = VaultBuild.lamp(self, Vector3(0.0, 0.0, STONE_SIZE.z + 0.5), + VaultBuild.LIGHT_IDLE, 0.0, 7.0) + + +func _process(delta: float) -> void: + _cooldown = maxf(0.0, _cooldown - delta) + + +## PlayerCombat's swing contract — see SignalFount for why a wall fixture +## answers to it. Steps one rung per swing and never wraps mid-combo. +func apply_hit(_amount: float, _from_position: Vector3, _knockback: float) -> void: + if _cooldown > 0.0: + return + _cooldown = STRIKE_COOLDOWN + _rung = (_rung + 1) % ladder.size() + _refresh() + DamageShards.burst(get_tree().current_scene, + global_position + global_transform.basis.z * 0.6, + _sign_color(), 9, 2.6, 1.6, 0.6) + EventBus.combat_shake.emit(0.05) + changed.emit(value()) + + +## Gold for a weight that adds, blue for one that takes away. This mapping is +## the dungeon's entire notation and is never reused for decoration. +func _sign_color() -> Color: + return VaultBuild.LIGHT_POSITIVE if value() > 0 else VaultBuild.LIGHT_NEGATIVE + + +## Repaint the face and light exactly |value| bars. Unlit bars stay visible +## at low energy so the player can see how much room the ladder still has. +func _refresh() -> void: + var col: Color = _sign_color() + var magnitude: int = absi(value()) + VaultBuild.set_rune_color(_face, col, 1.9) + for i in _bars.size(): + var lit: bool = i < magnitude + VaultBuild.set_rune_color(_bars[i], col if lit else VaultBuild.LIGHT_IDLE, + 3.4 if lit else 0.45) + if _lamp != null: + _lamp.light_color = col + _lamp.light_energy = 0.7 + 0.5 * float(magnitude) diff --git a/game/src/dungeon/weight_stone.gd.uid b/game/src/dungeon/weight_stone.gd.uid new file mode 100644 index 0000000..37600b5 --- /dev/null +++ b/game/src/dungeon/weight_stone.gd.uid @@ -0,0 +1 @@ +uid://ckm311n4kb6rv diff --git a/game/src/main/main.gd b/game/src/main/main.gd index e33b58c..c305de8 100644 --- a/game/src/main/main.gd +++ b/game/src/main/main.gd @@ -21,6 +21,8 @@ var _dialogue: DialogueUi var _interactor: NpcInteractor var _pack: InventoryScreen var _forage: MeadowForage +var _vault: PerceptronVault +var _compendium: CompendiumUi func _ready() -> void: @@ -43,6 +45,7 @@ func _ready() -> void: _town.build(_terrain, _sky) _landmarks.build(_terrain) _bit.setup(_player, _terrain) + _setup_vault() # Screenshot mode is the visual-verification tool — keep it clean of HUD # and roaming enemies. Normal play gets the combat HUD + monster spawner. @@ -51,6 +54,24 @@ func _ready() -> void: _capture_screens(shot_dir) else: _setup_combat() + _setup_compendium() + + +## Dungeon 1 — the Perceptron Vault. Built in both modes on purpose: it is +## world geometry, so screenshot runs must see it, and the only actor it +## spawns (the Gatekeeper) waits for Kern to walk into the arena, which a +## screenshot run never does. +func _setup_vault() -> void: + _vault = PerceptronVault.build($World, _terrain) + + +## Compendium v1 — Kern's field notebook, opened with J. Normal play only: +## it is UI, and screenshot captures stay clean of UI. It reads the meadow's +## iris specimens straight off the field MeadowFlora built. +func _setup_compendium() -> void: + var flora: MeadowFlora = $World/Flora as MeadowFlora + if flora != null and flora.iris_field != null: + _compendium = CompendiumUi.build(self, flora.iris_field) func _setup_combat() -> void: @@ -118,7 +139,9 @@ func _screenshot_dir() -> String: func _capture_screens(dir: String) -> void: # Angles chosen to judge the GDD §10 bar: the town-and-pond view, the - # Gradient Peaks vista, the sea horizon, and grass up close. + # Gradient Peaks vista, the sea horizon, grass up close — and, since + # milestone 12, the Perceptron Vault inside and out, which is sealed and + # lit only by its own runes and so cannot be judged from the field. var rig: Node3D = _player.get_node("CameraRig") # Keep the frame clean: Kern's body, Bit, and the floating landmark/bark # labels otherwise sit right on the lens and block the world we're judging. @@ -132,6 +155,10 @@ func _capture_screens(dir: String) -> void: cycle.paused = true cycle.set_hour(8.5) var arm: SpringArm3D = rig.get_node("SpringArm3D") as SpringArm3D + # Vault interiors need an absolute Y: the floor sits on a plinth above the + # terrain, so sampling ground height there would drop Kern through it. + var floor_y: float = _vault.floor_height() + 0.8 if _vault != null else 0.0 + var site: Vector2 = PerceptronVault.SITE var shots: Array[Dictionary] = [ {"name": "meadow_southeast_town", "yaw": deg_to_rad(-135.0), "pitch": -0.25}, {"name": "meadow_north_peaks", "yaw": deg_to_rad(35.0), "pitch": 0.05}, @@ -181,6 +208,18 @@ func _capture_screens(dir: String) -> void: {"name": "detail_trample", "yaw": 0.0, "pitch": -0.42, "pos": Vector2(-40.0, -76.6), "eye": 1.7, "show_kern": true, "spring": 0.0, "freeze_rig": true, "player_at": Vector2(-40.0, -80.0)}, + # Yaw 180° looks due south — straight down the vault's axis, which is + # the order a player walks it: facade, hall, chamber, junction, arena. + {"name": "vault_approach", "yaw": PI, "pitch": 0.10, + "pos": site + Vector2(0.0, -73.0)}, + {"name": "vault_input_hall", "yaw": PI, "pitch": 0.02, + "pos": site + Vector2(0.0, -30.0), "y": floor_y}, + {"name": "vault_chamber", "yaw": PI, "pitch": 0.02, + "pos": site + Vector2(15.2, -10.0), "y": floor_y}, + {"name": "vault_junction", "yaw": PI, "pitch": 0.04, + "pos": site + Vector2(0.0, 7.0), "y": floor_y}, + {"name": "vault_arena_gate", "yaw": PI, "pitch": 0.02, + "pos": site + Vector2(0.0, 22.0), "y": floor_y}, ] for i in 110: # let terrain, shadows, TAA, and SDFGI converge await get_tree().process_frame @@ -203,15 +242,21 @@ func _capture_screens(dir: String) -> void: var sample: Vector2 = shot["pos"] var eye: float = shot.get("eye", 2.45) # Kern normally stands at the camera spot; a shot can instead pose - # him elsewhere in frame (e.g. to verify the grass trample). + # him elsewhere in frame (e.g. to verify the grass trample). Vault + # interiors use an absolute floor height because terrain sampling + # would place Kern below the constructed dungeon floor. var stand: Vector2 = shot.get("player_at", sample) + var stand_y: float = float(shot["y"]) if shot.has("y") \ + else _terrain.get_height(stand.x, stand.y) + 0.8 _player.global_position = Vector3( - stand.x, _terrain.get_height(stand.x, stand.y) + 0.8, stand.y + stand.x, stand_y, stand.y ) if shot.has("face"): _player.rotation.y = shot["face"] + var camera_y: float = stand_y + 1.65 if shot.has("y") \ + else _terrain.get_height(sample.x, sample.y) + eye rig.global_position = Vector3( - sample.x, _terrain.get_height(sample.x, sample.y) + eye, sample.y + sample.x, camera_y, sample.y ) rig.rotation.y = shot["yaw"] arm.rotation.x = shot["pitch"] diff --git a/game/src/player/input_setup.gd b/game/src/player/input_setup.gd index a2e6445..4f31e96 100644 --- a/game/src/player/input_setup.gd +++ b/game/src/player/input_setup.gd @@ -62,6 +62,10 @@ static func ensure() -> void: _pad_button(&"interact", JOY_BUTTON_A) # Dev-only: fill the focus meter to verify the special without a quiz UI. _key(&"debug_charge", KEY_F) + # Compendium / journal (milestone 13). J for journal; Start/Menu on a pad. + # Back/Select already opens the pack, so sharing it would toggle both UIs. + _key(&"compendium", KEY_J) + _pad_button(&"compendium", JOY_BUTTON_START) static func _action(action: StringName) -> void: diff --git a/game/src/ui/compendium_ui.gd b/game/src/ui/compendium_ui.gd new file mode 100644 index 0000000..fbc35c6 --- /dev/null +++ b/game/src/ui/compendium_ui.gd @@ -0,0 +1,211 @@ +class_name CompendiumUi +extends CanvasLayer +## Compendium v1 — Kern's field notebook of the meadow's irises. +## +## GDD §9 lists a "discovery compendium" beside the quest journal; the +## WORLDBOOK's Datasedge entry says the compendium "classifies them into the +## three families" and that "rare boundary blooms (ambiguous specimens) are +## collector prizes". This is that, for the first region's flora. +## +## Three family columns, every catalogued specimen listed with the triplet +## Kern measured and what the notebook makes of it, plus a boundary-bloom +## roll at the foot. A specimen the notebook gets WRONG is shown as such — +## that is the honest and interesting case, and it is exactly where the three +## families genuinely overlap. +## +## Where it sits: instanced by `main.gd` during normal play only (screenshot +## mode leaves UI out). No scene file, no textures — a full-rect Control that +## paints in its `draw` callback, in the same shape as CombatHud. It reads +## the specimen table straight off IrisField and the catalogued set off +## GameState flags, so it holds no state of its own that could fall out of +## step. Toggled with the `compendium` action (J, or the gamepad's Back +## button); Escape closes it. +## +## Sizes are pixels at the viewport's scale. + +## Panel inset from the screen edge, and the panel's internal padding. +const MARGIN: float = 54.0 +const PAD: float = 26.0 +## Row height and the gap under each column heading. +const ROW_H: float = 19.0 +const HEADING_GAP: float = 34.0 +## Rows a column shows before it stops and reports the remainder. Compendium +## v1 is a read-out, not a scrolling document — paging arrives with the full +## journal milestone. +const MAX_ROWS: int = 18 + +const BACKDROP: Color = Color(0.05, 0.06, 0.08, 0.82) +const PANEL: Color = Color(0.11, 0.12, 0.15, 0.96) +const PANEL_EDGE: Color = Color(0.62, 0.58, 0.42, 0.9) +const INK: Color = Color(0.93, 0.91, 0.83) +const INK_DIM: Color = Color(0.62, 0.61, 0.56) +const MISCLASSIFIED: Color = Color(0.96, 0.62, 0.42) + +## True while the notebook is open. Also gates `_process`, so a closed +## compendium costs one boolean test a frame. +var is_open: bool = false + +var _field: IrisField +var _control: Control +var _font: Font + + +## Create the compendium and bind it to the meadow's iris field. +static func build(host: Node, field: IrisField) -> CompendiumUi: + var ui: CompendiumUi = CompendiumUi.new() + ui.name = "CompendiumUi" + ui._field = field + host.add_child(ui) + return ui + + +func _ready() -> void: + # Above the combat HUD (layer 10) — the notebook covers the screen when + # it is open, and nothing should draw over it. + layer = 20 + _font = ThemeDB.fallback_font + _control = Control.new() + _control.set_anchors_preset(Control.PRESET_FULL_RECT) + # MOUSE_FILTER_IGNORE for the same reason CombatHud uses it: the camera + # rig needs clicks to recapture the cursor, and the compendium is + # keyboard-driven. + _control.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_control) + _control.draw.connect(_render) + _control.visible = false + + +## Toggle on the `compendium` action; Escape closes but never opens, so it +## keeps its existing job of releasing the mouse. +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed(&"compendium"): + set_open(not is_open) + get_viewport().set_input_as_handled() + elif is_open and event is InputEventKey and event.is_pressed() \ + and (event as InputEventKey).keycode == KEY_ESCAPE: + set_open(false) + get_viewport().set_input_as_handled() + + +## Open or close the notebook. Repainting only on open (and once per open) is +## enough: nothing can be catalogued while the page is being read. +func set_open(open: bool) -> void: + if open == is_open: + return + is_open = open + _control.visible = open + if open: + _control.queue_redraw() + + +# --- Drawing ----------------------------------------------------------------- + +## Paint the whole notebook. Called from the Control's `draw` signal, so +## every position here is in that Control's local space, which is the +## viewport rect. +func _render() -> void: + if _field == null: + return + var size: Vector2 = _control.size + var panel: Rect2 = Rect2(MARGIN, MARGIN, + size.x - MARGIN * 2.0, size.y - MARGIN * 2.0) + _control.draw_rect(Rect2(Vector2.ZERO, size), BACKDROP) + _control.draw_rect(panel, PANEL) + _control.draw_rect(panel, PANEL_EDGE, false, 2.0) + + var specimens: Array[Dictionary] = _field.specimens + var stats: Dictionary = IrisDataset.progress(specimens) + var origin: Vector2 = panel.position + Vector2(PAD, PAD + 22.0) + + _control.draw_string(_font, origin, "THE MEADOW COMPENDIUM — irises", + HORIZONTAL_ALIGNMENT_LEFT, -1, 26, INK) + _control.draw_string(_font, origin + Vector2(0.0, 26.0), + "%d of %d specimens pressed · boundary blooms %d of %d · J closes" + % [stats["found"], stats["total"], + stats["boundary_found"], stats["boundary_total"]], + HORIZONTAL_ALIGNMENT_LEFT, -1, 15, INK_DIM) + + var column_w: float = (panel.size.x - PAD * 2.0) / 3.0 + var top: float = origin.y + HEADING_GAP + 22.0 + for family in IrisDataset.FAMILY_NAMES.size(): + _draw_family_column(specimens, family, + Vector2(origin.x + column_w * float(family), top), + column_w - 18.0, panel.position.y + panel.size.y - PAD - 74.0) + + _draw_boundary_roll(specimens, panel) + + +## One family's column: heading, count, and a line per catalogued specimen. +## `bottom` is the y the list must stop above, so the boundary roll at the +## foot of the page always has its room. +func _draw_family_column(specimens: Array[Dictionary], family: int, + at: Vector2, width: float, bottom: float) -> void: + var color: Color = IrisDataset.FAMILY_COLORS[family] + var total: int = 0 + var rows: Array[Dictionary] = [] + for s: Dictionary in specimens: + if int(s["family"]) != family: + continue + total += 1 + if IrisDataset.is_catalogued(int(s["index"])): + rows.append(s) + + _control.draw_rect(Rect2(at + Vector2(0.0, -16.0), Vector2(width, 3.0)), color) + _control.draw_string(_font, at, IrisDataset.FAMILY_NAMES[family], + HORIZONTAL_ALIGNMENT_LEFT, width, 20, color) + _control.draw_string(_font, at + Vector2(0.0, 20.0), + "%d of %d pressed" % [rows.size(), total], + HORIZONTAL_ALIGNMENT_LEFT, width, 14, INK_DIM) + + if rows.is_empty(): + _control.draw_string(_font, at + Vector2(0.0, 46.0), + "— none yet —", HORIZONTAL_ALIGNMENT_LEFT, width, 14, INK_DIM) + return + + # The three measurements, then what the notebook made of the specimen. + # A disagreement is called out rather than hidden: those specimens are + # where the families actually overlap, and noticing that IS the lesson. + var y: float = at.y + 46.0 + var shown: int = 0 + for s: Dictionary in rows: + if y > bottom or shown >= MAX_ROWS: + _control.draw_string(_font, Vector2(at.x, y), + "… and %d more" % (rows.size() - shown), + HORIZONTAL_ALIGNMENT_LEFT, width, 14, INK_DIM) + return + var m: Array = s["measures"] + var line: String = "%4.1f %4.1f %4.1f cm" % [m[0], m[1], m[2]] + var wrong: bool = int(s["classified"]) != family + if bool(s["boundary"]): + line += " ◆" + if wrong: + line += " reads as %s" % IrisDataset.FAMILY_NAMES[int(s["classified"])] + _control.draw_string(_font, Vector2(at.x, y), line, + HORIZONTAL_ALIGNMENT_LEFT, width, 14, + MISCLASSIFIED if wrong else INK) + y += ROW_H + shown += 1 + + +## The foot of the page: what a boundary bloom is, and how many are pressed. +func _draw_boundary_roll(specimens: Array[Dictionary], panel: Rect2) -> void: + var found: int = 0 + var total: int = 0 + for s: Dictionary in specimens: + if not bool(s["boundary"]): + continue + total += 1 + if IrisDataset.is_catalogued(int(s["index"])): + found += 1 + + var y: float = panel.position.y + panel.size.y - PAD - 44.0 + _control.draw_rect(Rect2(panel.position.x + PAD, y - 18.0, + panel.size.x - PAD * 2.0, 2.0), IrisDataset.BOUNDARY_COLOR) + _control.draw_string(_font, Vector2(panel.position.x + PAD, y), + "◆ BOUNDARY BLOOMS — %d of %d" % [found, total], + HORIZONTAL_ALIGNMENT_LEFT, -1, 18, IrisDataset.BOUNDARY_COLOR) + _control.draw_string(_font, Vector2(panel.position.x + PAD, y + 22.0), + "Specimens the notebook cannot call. Their measurements sit as near " + + "one family as another, and no amount of pressing settles it. " + + "They grow paler than the rest — look for the near-white ones.", + HORIZONTAL_ALIGNMENT_LEFT, panel.size.x - PAD * 2.0, 14, INK_DIM) diff --git a/game/src/ui/compendium_ui.gd.uid b/game/src/ui/compendium_ui.gd.uid new file mode 100644 index 0000000..5443708 --- /dev/null +++ b/game/src/ui/compendium_ui.gd.uid @@ -0,0 +1 @@ +uid://ptye66vxsfcs diff --git a/game/src/world/iris_dataset.gd b/game/src/world/iris_dataset.gd new file mode 100644 index 0000000..9228626 --- /dev/null +++ b/game/src/world/iris_dataset.gd @@ -0,0 +1,225 @@ +class_name IrisDataset +extends RefCounted +## The real Iris measurements behind Datasedge Meadows' flora, and the +## classifier the compendium uses on them. +## +## WORLDBOOK, Datasedge Meadows: "**Dataset:** **Iris** — flowers spawn with +## real measurement triplets; the compendium classifies them into the three +## families; rare 'boundary blooms' (ambiguous specimens) are collector +## prizes." GDD design pillar 3: "Real open datasets are world content, not +## decoration." This file is where that stops being a promise. +## +## ## Source of the numbers +## +## The per-family means and standard deviations below are the published +## summary statistics of Fisher's iris data (R. A. Fisher, "The use of +## multiple measurements in taxonomic problems", *Annals of Eugenics* 7(2), +## 1936, from specimens collected by Edgar Anderson) — 50 specimens of each +## of the three families, measured in centimeters. +## +## Specimens are DRAWN from those statistics rather than replayed from the +## 150-row table, and that is a deliberate, reversible choice: `README.md` +## states that "dataset extracts will be added only with explicit source and +## license records before release", and recording that provenance is Danny's +## call, not a build session's. Summary statistics are facts about the data; +## shipping the table is a redistribution decision. Swapping the sampler for +## the real 150 rows later is a change to this one file and nothing else — +## `build_specimens()` is the only thing that produces records, and the rest +## of the game only ever reads what it returns. +## +## What survives the substitution is what actually matters for play: the real +## family separations, the real spread, and — the point of the whole system — +## the real overlap between versicolor and virginica that makes some blooms +## genuinely ambiguous. +## +## ## The triplet +## +## Canon says triplets, so three of Fisher's four measurements are used: +## sepal length, petal length, petal width. Those three carry nearly all of +## the separating signal; sepal width is the one that mostly does not. +## +## Where it sits: pure data and math, no nodes and no scene. Used by +## IrisField (which stamps a record on every bloom in the meadow) and by +## CompendiumUi (which shows the catalogue). Units: centimeters throughout. + +## Family indices, used as array indices everywhere in this file and as the +## bloom's family id in IrisField and the compendium. +enum Family { SETOSA, VERSICOLOR, VIRGINICA } + +## Display names, in Family order. In-world these are flower families, and +## nothing ever calls them anything else (WORLDBOOK Part IV). +const FAMILY_NAMES: Array[String] = ["Setosa", "Versicolor", "Virginica"] + +## Bloom colors per family, in Family order: setosa violet, versicolor blue, +## virginica pale. Carried over unchanged from the original iris scatter in +## MeadowFlora so the meadow looks exactly as it did before it was +## collectible. +const FAMILY_COLORS: Array[Color] = [ + Color(0.52, 0.34, 0.78), + Color(0.36, 0.44, 0.85), + Color(0.88, 0.86, 0.95), +] +## A boundary bloom's own color — near-white with a warm cast, so an +## ambiguous specimen is visibly worth crossing a field for. +const BOUNDARY_COLOR: Color = Color(1.0, 0.97, 0.86) + +## Family means, in Family order, as [sepal length, petal length, petal +## width] in centimeters. Fisher 1936. +const MEANS: Array[Array] = [ + [5.006, 1.462, 0.246], # setosa + [5.936, 4.260, 1.326], # versicolor + [6.588, 5.552, 2.026], # virginica +] +## Matching standard deviations, same order and units. Fisher 1936. +const DEVIATIONS: Array[Array] = [ + [0.352, 0.174, 0.105], # setosa + [0.516, 0.470, 0.198], # versicolor + [0.636, 0.552, 0.275], # virginica +] + +## Family proportions in the meadow. The real dataset is an even 50/50/50; +## the meadow tilts toward setosa because Datasedge is its home ground and a +## flat third-each split would make the rarest bloom feel unearned. The +## measurements inside each family stay true. +const FAMILY_WEIGHTS: Array[float] = [0.45, 0.45, 0.10] + +## How many distinct specimens the compendium tracks. Blooms in the field +## outnumber these, so several blooms can carry the same record — cataloguing +## is about the specimen, not about the individual flower. +const SPECIMEN_COUNT: int = 150 + +## A specimen is a boundary bloom when its second-best family is nearly as +## good a fit as its first, measured as the ratio of the nearest centroid +## distance to the runner-up's. 1.0 would demand a perfect tie. +## +## Tuned, not guessed: swept offline over 300 seeds. At 0.86 boundary blooms +## averaged 1.7% of specimens and some seeds produced NONE, which would leave +## the collector prize non-existent in a given meadow. 0.55 lands at ~11% +## (min 8, max 29 per 150) — rare enough to be worth crossing a field for, +## common enough that every meadow has some. +const BOUNDARY_RATIO: float = 0.55 + +## GameState flag prefix for a catalogued specimen. Flags are String -> bool +## and are already serialized, so the compendium needs no change to the save +## shape and no SAVE_VERSION bump (CLAUDE.md iron rule 6) — "which specimens +## have I catalogued" is exactly a set of booleans. +const FLAG_PREFIX: String = "iris_specimen_" + + +## Build the full specimen table, deterministically. +## +## `seed_value` fixes the draw, so the same meadow always holds the same 150 +## specimens and a player's catalogue means the same thing across sessions — +## the same guarantee MeadowFlora's fixed SCATTER_SEED gives the terrain. +## +## Each record is `{ index, family, measures: Array[float], classified, +## boundary }`, where `measures` is [sepal length, petal length, petal width] +## in centimeters, `family` is the true family, and `classified` is what the +## compendium's classifier makes of it — the two disagree on exactly the +## specimens that should be hard. +static func build_specimens(seed_value: int) -> Array[Dictionary]: + var rng: RandomNumberGenerator = RandomNumberGenerator.new() + rng.seed = seed_value + var out: Array[Dictionary] = [] + for i in SPECIMEN_COUNT: + var family: int = _draw_family(rng) + var measures: Array[float] = [] + for m in 3: + # randfn draws from a normal distribution — the right shape for + # a natural measurement, and what makes the family overlap look + # like the real thing rather than a uniform smear. + var value: float = rng.randfn( + float(MEANS[family][m]), float(DEVIATIONS[family][m])) + # Clamped at a hair above zero: a normal draw can in principle go + # negative, and a petal of negative width is not a rare find. + measures.append(maxf(0.05, snappedf(value, 0.1))) + var verdict: Dictionary = classify(measures) + out.append({ + "index": i, + "family": family, + "measures": measures, + "classified": int(verdict["family"]), + "boundary": bool(verdict["boundary"]), + }) + return out + + +## Pick a family by FAMILY_WEIGHTS. +static func _draw_family(rng: RandomNumberGenerator) -> int: + var roll: float = rng.randf() + var running: float = 0.0 + for f in FAMILY_WEIGHTS.size(): + running += FAMILY_WEIGHTS[f] + if roll < running: + return f + return Family.VIRGINICA + + +## Nearest-centroid classification of one measurement triplet. +## +## The honest simplest classifier: compare the specimen to each family's +## average and take the closest. Distances are standardised — each difference +## divided by that family's spread on that measurement — because raw +## centimeters would let sepal length, the widest-ranging measurement, drown +## out petal width, the most informative one. +## +## Returns `{ family, boundary, distances }` — `boundary` is true when the +## runner-up was nearly as close, which is precisely what makes a specimen +## ambiguous and therefore a prize. +static func classify(measures: Array[float]) -> Dictionary: + var distances: Array[float] = [] + for f in FAMILY_NAMES.size(): + var sum_sq: float = 0.0 + for m in 3: + var spread: float = maxf(0.01, float(DEVIATIONS[f][m])) + var d: float = (measures[m] - float(MEANS[f][m])) / spread + sum_sq += d * d + distances.append(sqrt(sum_sq)) + + var best: int = 0 + for f in distances.size(): + if distances[f] < distances[best]: + best = f + var runner_up: float = INF + for f in distances.size(): + if f != best: + runner_up = minf(runner_up, distances[f]) + + # Ratio, not difference: a specimen sitting far from every centroid can + # have a large absolute gap and still be a coin toss between two families, + # and it is that relative closeness that makes it ambiguous. + var boundary: bool = runner_up > 0.0 and (distances[best] / runner_up) >= BOUNDARY_RATIO + return {"family": best, "boundary": boundary, "distances": distances} + + +## The GameState flag that records one catalogued specimen. +static func flag_for(index: int) -> String: + return FLAG_PREFIX + str(index) + + +## True once Kern has catalogued this specimen. +static func is_catalogued(index: int) -> bool: + return GameState.has_flag(flag_for(index)) + + +## How many of `specimens` are catalogued, and how many of those are boundary +## blooms. Returns `{ found, total, boundary_found, boundary_total }` — one +## pass, because the compendium wants all four numbers at once. +static func progress(specimens: Array[Dictionary]) -> Dictionary: + var found: int = 0 + var boundary_found: int = 0 + var boundary_total: int = 0 + for s: Dictionary in specimens: + var is_boundary: bool = bool(s["boundary"]) + if is_boundary: + boundary_total += 1 + if is_catalogued(int(s["index"])): + found += 1 + if is_boundary: + boundary_found += 1 + return { + "found": found, + "total": specimens.size(), + "boundary_found": boundary_found, + "boundary_total": boundary_total, + } diff --git a/game/src/world/iris_dataset.gd.uid b/game/src/world/iris_dataset.gd.uid new file mode 100644 index 0000000..4717df2 --- /dev/null +++ b/game/src/world/iris_dataset.gd.uid @@ -0,0 +1 @@ +uid://c3wesjikbv1mu diff --git a/game/src/world/iris_field.gd b/game/src/world/iris_field.gd new file mode 100644 index 0000000..e1bd2ce --- /dev/null +++ b/game/src/world/iris_field.gd @@ -0,0 +1,325 @@ +class_name IrisField +extends Node3D +## The meadow's irises, as collectible flora — Phase 1 milestone 13. +## +## Datasedge's iris flats have been scenery since milestone 3; `MeadowFlora`'s +## header has said since then that "the collectible system arrives with the +## compendium milestone". This is that system. The flats look exactly as they +## did — the scatter seed, cluster centers, counts, and family colors are all +## carried over unchanged — but every bloom now carries a real Iris specimen +## record, and walking over one collects it. +## +## **Collection is proximity, not a button.** Kern picks a bloom by walking +## through it, the way you pick things up in a field. That needs no new input +## action, nothing to aim at, and no per-bloom node: all 700 blooms live in a +## single MultiMesh, and this node keeps their positions and specimen records +## in parallel arrays. A picked bloom shrinks away over a beat rather than +## blinking out (GDD §10: nothing pops in or out without a transition). +## +## **What persists is the specimen, not the flower.** Cataloguing writes a +## GameState flag per SPECIMEN (150 of them), so the compendium is a set of +## booleans inside the already-serialized `flags` dictionary — no change to +## the save shape and no SAVE_VERSION bump. Individual blooms are not saved, +## so the flats regrow between sessions, which is what a meadow does. Picking +## an already-catalogued specimen still yields a petal; it just adds nothing +## new to the compendium. +## +## Where it sits: built as a child of MeadowFlora, which hands it the terrain +## and the same RNG seed. It emits EventBus.item_acquired indirectly through +## `GameState.add_item`, and nothing else. CompendiumUi reads the specimen +## table from here. +## +## Units: meters for the world, centimeters for the measurements on a bloom. + +const IRIS_COUNT: int = 700 +## Cluster centers of the iris flats, west of Bootstrap (GDD §7, WORLDBOOK). +## Unchanged from the original scatter so the flats sit exactly where players +## and screenshots have already seen them. +const CLUSTERS: Array[Vector2] = [ + Vector2(-95.0, 25.0), Vector2(-130.0, -15.0), Vector2(-75.0, 70.0), + Vector2(-150.0, 55.0), Vector2(-110.0, 110.0), +] +## Standard deviation (m) of a bloom's distance from its cluster center. +const CLUSTER_SPREAD: float = 14.0 +const EDGE_MARGIN: float = 12.0 +## Y a culled bloom is parked at — the convention MeadowFlora uses for +## instances that landed somewhere unusable. +const PARKED_Y: float = -10000.0 + +## Horizontal reach (m) at which Kern picks a bloom, and the vertical slack +## that goes with it so a bloom on a slope below him still counts. +const PICK_RADIUS: float = 1.15 +const PICK_HEIGHT: float = 2.2 +## Seconds between proximity sweeps. At 700 blooms a sweep is trivial, but +## there is no reason to run it every frame — a walking player cannot cross +## the pick radius in a twelfth of a second. +const SWEEP_INTERVAL: float = 0.08 +## Seconds a picked bloom takes to shrink away. +const VANISH_TIME: float = 0.28 + +## The item every bloom yields. Already approved content, so this needs no +## new entry and cannot collide with the items batch another session owns. +const PETAL_ITEM: String = "item_iris_petal" + +## The 150 specimen records this meadow's blooms are drawn from. See +## IrisDataset.build_specimens() for the record shape. +var specimens: Array[Dictionary] = [] + +## Per-bloom parallel arrays, all IRIS_COUNT long and index-aligned with the +## MultiMesh's instances. +var _bloom_pos: PackedVector3Array = PackedVector3Array() +var _bloom_specimen: PackedInt32Array = PackedInt32Array() +var _bloom_scale: PackedFloat32Array = PackedFloat32Array() +var _bloom_yaw: PackedFloat32Array = PackedFloat32Array() +## True once a bloom has been picked (or was culled at build time for landing +## in water or off the map). Picked blooms are skipped by the sweep. +var _bloom_taken: PackedByteArray = PackedByteArray() + +## Blooms mid-shrink: { index, elapsed }. +var _vanishing: Array[Dictionary] = [] + +var _terrain: MeadowTerrain +var _multimesh: MultiMesh +var _player: Node3D +var _sweep_left: float = 0.0 + + +## Build the field against `terrain`, seeded so the flats are identical every +## boot. Called by MeadowFlora with its own scatter seed. +static func build(host: Node3D, terrain: MeadowTerrain, seed_value: int) -> IrisField: + var f: IrisField = IrisField.new() + f.name = "IrisField" + f._terrain = terrain + host.add_child(f) + f._scatter(seed_value) + return f + + +## Lay out every bloom and stamp a specimen on it. +func _scatter(seed_value: int) -> void: + var start_ms: int = Time.get_ticks_msec() + specimens = IrisDataset.build_specimens(seed_value) + + var rng: RandomNumberGenerator = RandomNumberGenerator.new() + rng.seed = seed_value + _multimesh = MultiMesh.new() + _multimesh.transform_format = MultiMesh.TRANSFORM_3D + _multimesh.use_colors = true + _multimesh.mesh = _build_iris_mesh() + _multimesh.instance_count = IRIS_COUNT + + for i in IRIS_COUNT: + var c: Vector2 = CLUSTERS[rng.randi() % CLUSTERS.size()] + var ang: float = rng.randf_range(0.0, TAU) + var dist: float = absf(rng.randfn(0.0, CLUSTER_SPREAD)) + var x: float = c.x + cos(ang) * dist + var z: float = c.y + sin(ang) * dist + var h: float = _terrain.get_height(x, z) + var usable: bool = _ground_ok(x, z, h) + var yaw: float = rng.randf_range(0.0, TAU) + var scale: float = rng.randf_range(0.8, 1.2) + + # Which of the 150 specimens this bloom is. Several blooms share a + # specimen by design — the catalogue tracks findings, not flowers. + var specimen: int = rng.randi() % specimens.size() + + _bloom_pos.append(Vector3(x, h, z)) + _bloom_specimen.append(specimen) + _bloom_scale.append(scale) + _bloom_yaw.append(yaw) + # A bloom that landed in the pond or off the edge is marked taken so + # the sweep never considers it; it is parked underground either way. + _bloom_taken.append(0 if usable else 1) + + _write_instance(i, 1.0 if usable else 0.0) + _multimesh.set_instance_color(i, _color_for(specimen)) + + var mmi: MultiMeshInstance3D = MultiMeshInstance3D.new() + mmi.name = "Irises" + mmi.multimesh = _multimesh + mmi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(mmi) + + var boundary: int = 0 + for s: Dictionary in specimens: + if bool(s["boundary"]): + boundary += 1 + print("IrisField: %d blooms over %d specimens (%d boundary blooms) in %d ms." % [ + IRIS_COUNT, specimens.size(), boundary, Time.get_ticks_msec() - start_ms, + ]) + + +## Write one bloom's transform into the MultiMesh at `scale_mul` (0 hides it, +## 1 is full size). Parked blooms are pushed far underground so a zero-scale +## instance never leaves a speck at ground level. +func _write_instance(index: int, scale_mul: float) -> void: + var pos: Vector3 = _bloom_pos[index] + if scale_mul <= 0.0: + pos.y = PARKED_Y + var t: Transform3D = Transform3D(Basis.IDENTITY, pos) + t = t.rotated_local(Vector3.UP, _bloom_yaw[index]) + var s: float = _bloom_scale[index] * scale_mul + t = t.scaled_local(Vector3(s, s, s)) + _multimesh.set_instance_transform(index, t) + + +## A bloom's color: its family's, unless the specimen is ambiguous — boundary +## blooms wear their own near-white so a collector can spot one across a +## field, which is what makes them prizes rather than trivia. +func _color_for(specimen: int) -> Color: + var record: Dictionary = specimens[specimen] + if bool(record["boundary"]): + return IrisDataset.BOUNDARY_COLOR + return IrisDataset.FAMILY_COLORS[int(record["family"])] + + +## Same siting rule MeadowFlora applies to every scattered thing: inside the +## map's margin, and clear of the pond bed and waterline. +func _ground_ok(x: float, z: float, h: float) -> bool: + if absf(x) > MeadowTerrain.SIZE * 0.5 - EDGE_MARGIN: + return false + if absf(z) > MeadowTerrain.SIZE * 0.5 - EDGE_MARGIN: + return false + if h < _terrain.water_level + 0.35: + return false + return true + + +func _process(delta: float) -> void: + _advance_vanishing(delta) + _sweep_left -= delta + if _sweep_left > 0.0: + return + _sweep_left = SWEEP_INTERVAL + _check_pickups() + + +## Shrink picked blooms out over VANISH_TIME, then park them. +func _advance_vanishing(delta: float) -> void: + if _vanishing.is_empty(): + return + var still_going: Array[Dictionary] = [] + for v: Dictionary in _vanishing: + var elapsed: float = float(v["elapsed"]) + delta + var index: int = int(v["index"]) + if elapsed >= VANISH_TIME: + _write_instance(index, 0.0) + continue + # Ease out, so the bloom drops away quickly and then settles rather + # than shrinking at a constant, mechanical rate. + var t: float = elapsed / VANISH_TIME + _write_instance(index, 1.0 - t * t) + still_going.append({"index": index, "elapsed": elapsed}) + _vanishing = still_going + + +## Pick every uncollected bloom Kern is standing in. +## +## A flat scan of all 700 positions. That is a few thousand float operations +## twelve times a second on a machine speced for hundreds of thousands of +## instanced grass blades (GDD §10 hardware decree) — a spatial index here +## would be complexity bought with nothing. +func _check_pickups() -> void: + if _player == null or not is_instance_valid(_player): + _player = get_tree().get_first_node_in_group(&"player") as Node3D + if _player == null: + return + var at: Vector3 = _player.global_position + var radius_sq: float = PICK_RADIUS * PICK_RADIUS + for i in _bloom_pos.size(): + if _bloom_taken[i] == 1: + continue + var p: Vector3 = _bloom_pos[i] + if absf(p.y - at.y) > PICK_HEIGHT: + continue + var dx: float = p.x - at.x + var dz: float = p.z - at.z + if dx * dx + dz * dz <= radius_sq: + _pick(i) + + +## Collect one bloom: yield a petal, catalogue the specimen if it is new, and +## start the bloom shrinking away. +func _pick(index: int) -> void: + _bloom_taken[index] = 1 + _vanishing.append({"index": index, "elapsed": 0.0}) + + var record: Dictionary = specimens[_bloom_specimen[index]] + var specimen_index: int = int(record["index"]) + var was_new: bool = not IrisDataset.is_catalogued(specimen_index) + if was_new: + GameState.set_flag(IrisDataset.flag_for(specimen_index)) + GameState.add_item(PETAL_ITEM, 1) # emits EventBus.item_acquired + + var at: Vector3 = _bloom_pos[index] + Vector3(0.0, 0.35, 0.0) + var col: Color = _color_for(_bloom_specimen[index]) + # A new specimen bursts brighter and wider than a duplicate — the only + # feedback that says "that one mattered" without opening a screen. + DamageShards.burst(get_tree().current_scene, at, col, + 16 if was_new else 7, 3.0 if was_new else 2.0, 2.2, 0.5) + if was_new: + _announce(record, at) + + +## A short floating note over a newly catalogued bloom, naming what it was. +## Frees itself; nothing holds a reference. +func _announce(record: Dictionary, at: Vector3) -> void: + var label: Label3D = Label3D.new() + var family: String = IrisDataset.FAMILY_NAMES[int(record["family"])] + label.text = ("a boundary bloom — %s" % family) if bool(record["boundary"]) \ + else family + label.font_size = 34 + label.outline_size = 8 + label.pixel_size = 0.006 + label.modulate = IrisDataset.BOUNDARY_COLOR if bool(record["boundary"]) \ + else Color(0.97, 0.95, 0.88) + label.outline_modulate = Color(0.08, 0.09, 0.07, 0.85) + label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + label.shaded = false + label.no_depth_test = true + add_child(label) + label.global_position = at + Vector3(0.0, 0.5, 0.0) + var tw: Tween = create_tween() + tw.set_parallel(true) + tw.tween_property(label, "global_position", + label.global_position + Vector3(0.0, 0.9, 0.0), 1.6) + tw.tween_property(label, "modulate:a", 0.0, 1.6).set_delay(0.7) + tw.chain().tween_callback(label.queue_free) + + +## An iris: short stem quad + three diamond petals. Petal verts are COLOR +## white so the MultiMesh instance color tints petals; stem verts stay green. +## Moved here from MeadowFlora unchanged when the irises became collectible. +func _build_iris_mesh() -> ArrayMesh: + var st: SurfaceTool = SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var stem_col: Color = Color(0.3, 0.5, 0.24) + var stem_h: float = 0.32 + # Stem: two crossed thin triangles. + for k in 2: + var b: Basis = Basis(Vector3.UP, PI * 0.5 * float(k)) + st.set_color(stem_col); st.set_normal(Vector3.UP) + st.add_vertex(b * Vector3(-0.015, 0.0, 0.0)) + st.set_color(stem_col); st.set_normal(Vector3.UP) + st.add_vertex(b * Vector3(0.015, 0.0, 0.0)) + st.set_color(stem_col); st.set_normal(Vector3.UP) + st.add_vertex(b * Vector3(0.0, stem_h, 0.0)) + # Petals: three diamonds fanning from the stem tip. COLOR white = tinted. + for k in 3: + var b: Basis = Basis(Vector3.UP, TAU * float(k) / 3.0) + var tip: Vector3 = Vector3(0.0, stem_h, 0.0) + var out: Vector3 = b * Vector3(0.12, 0.06, 0.0) + var side: Vector3 = b * Vector3(0.05, 0.0, 0.05) + st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip) + st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip + out + side) + st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip + out * 1.6) + st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip) + st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip + out * 1.6) + st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip + out - side) + var mat: StandardMaterial3D = StandardMaterial3D.new() + mat.vertex_color_use_as_albedo = true + mat.vertex_color_is_srgb = true + mat.roughness = 0.9 + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + st.set_material(mat) + return st.commit() diff --git a/game/src/world/iris_field.gd.uid b/game/src/world/iris_field.gd.uid new file mode 100644 index 0000000..e30502f --- /dev/null +++ b/game/src/world/iris_field.gd.uid @@ -0,0 +1 @@ +uid://bxe4qr6pfs7ct diff --git a/game/src/world/meadow_flora.gd b/game/src/world/meadow_flora.gd index c6ee9a4..272792a 100644 --- a/game/src/world/meadow_flora.gd +++ b/game/src/world/meadow_flora.gd @@ -5,10 +5,12 @@ extends Node3D ## Everything scattered deterministically (fixed seed) on the terrain the ## sibling MeadowTerrain generated: a photoreal 2.5M-blade grass carpet in two ## camera-wrapped MultiMesh fields (grass_field.gdshader does the planting, -## clumping, wind, and trample), iris flats to the west (the region's canon -## flora — collectible system arrives with the compendium milestone; today -## they are scenery), daisies, pebbles, and tree copses with trunk collision. -## Zero textures — all variation is procedural. +## clumping, wind, and trample), daisies, pebbles, and tree copses with trunk +## collision. Zero textures — all variation is procedural. +## +## The iris flats to the west are now collectible. IrisField keeps the current +## high-density grass implementation intact while owning the 700 bloom +## transforms, specimen records, collection animation, and compendium feed. const SCATTER_SEED: int = 20260717 # Three camera-wrapped carpets share grass_field.gdshader. The near field is a @@ -32,7 +34,6 @@ const BLADE_HALF_WIDTH: float = 0.017 # wrapped world rects every frame — so Godot frustum-culls the blades behind # the camera (a single whole-map AABB defeats culling and doubles frame cost). const FIELD_CHUNKS: int = 8 -const IRIS_COUNT: int = 700 const DAISY_COUNT: int = 1200 const PEBBLE_COUNT: int = 750 const EDGE_MARGIN: float = 12.0 @@ -42,6 +43,8 @@ const EDGE_MARGIN: float = 12.0 var _rng: RandomNumberGenerator = RandomNumberGenerator.new() # One entry per field chunk: {mmi, rect (tile-local), tile, cull_dist}. var _field_chunks: Array[Dictionary] = [] +## Collectible iris flats, exposed so Main can connect the compendium UI. +var iris_field: IrisField ## Blade-count multiplier. The shipped density (~5.4M blades) is tuned for a @@ -63,14 +66,16 @@ func _ready() -> void: # can be attributed honestly (grass vs sky/GI/shadows). if not OS.get_cmdline_user_args().has("--no-grass"): _build_fine_field() - _scatter_irises() _scatter_daisies() _scatter_pebbles() _plant_copses() + # Same seed the rest of the flora uses, preserving the established western + # clusters while giving every bloom a real specimen record. + iris_field = IrisField.build(self, _terrain, SCATTER_SEED) var s: float = _grass_scale() - print("MeadowFlora: %d near + %d mid + %d far blades (scale %.2f), %d irises, %d daisies, %d pebbles in %d ms." % [ + print("MeadowFlora: %d near + %d mid + %d far blades (scale %.2f), %d collectible irises, %d daisies, %d pebbles in %d ms." % [ int(NEAR_COUNT * s), int(MID_COUNT * s), int(FAR_COUNT * s), s, - IRIS_COUNT, DAISY_COUNT, PEBBLE_COUNT, + IrisField.IRIS_COUNT, DAISY_COUNT, PEBBLE_COUNT, Time.get_ticks_msec() - start_ms, ]) @@ -242,85 +247,6 @@ func _ground_ok(x: float, z: float, h: float) -> bool: return true -func _scatter_irises() -> void: - var mm: MultiMesh = MultiMesh.new() - mm.transform_format = MultiMesh.TRANSFORM_3D - mm.use_colors = true - mm.mesh = _build_iris_mesh() - mm.instance_count = IRIS_COUNT - # Canon: the iris flats lie west of Bootstrap (GDD §7, WORLDBOOK). - var clusters: Array[Vector2] = [ - Vector2(-95.0, 25.0), Vector2(-130.0, -15.0), Vector2(-75.0, 70.0), - Vector2(-150.0, 55.0), Vector2(-110.0, 110.0), - ] - # Real Iris dataset families, as bloom colors: setosa violet, - # versicolor blue, virginica pale — the rare white is the collector tease. - var petals: Array[Color] = [ - Color(0.52, 0.34, 0.78), Color(0.36, 0.44, 0.85), Color(0.88, 0.86, 0.95), - ] - for i in IRIS_COUNT: - var c: Vector2 = clusters[_rng.randi() % clusters.size()] - var ang: float = _rng.randf_range(0.0, TAU) - var dist: float = absf(_rng.randfn(0.0, 14.0)) - var x: float = c.x + cos(ang) * dist - var z: float = c.y + sin(ang) * dist - var h: float = _terrain.get_height(x, z) - if not _ground_ok(x, z, h): - h = -10000.0 - var t: Transform3D = Transform3D(Basis.IDENTITY, Vector3(x, h, z)) - t = t.rotated_local(Vector3.UP, _rng.randf_range(0.0, TAU)) - var s: float = _rng.randf_range(0.8, 1.2) - t = t.scaled_local(Vector3(s, s, s)) - mm.set_instance_transform(i, t) - var roll: float = _rng.randf() - var family: int = 0 if roll < 0.45 else (1 if roll < 0.9 else 2) - mm.set_instance_color(i, petals[family]) - - var mmi: MultiMeshInstance3D = MultiMeshInstance3D.new() - mmi.name = "Irises" - mmi.multimesh = mm - mmi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - add_child(mmi) - - -## An iris: short stem quad + three diamond petals. Petal verts are COLOR -## white so instance color tints petals; stem verts stay green via COLOR. -func _build_iris_mesh() -> ArrayMesh: - var st: SurfaceTool = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - var stem_col: Color = Color(0.3, 0.5, 0.24) - var stem_h: float = 0.32 - # Stem: two crossed thin triangles. - for k in 2: - var b: Basis = Basis(Vector3.UP, PI * 0.5 * float(k)) - st.set_color(stem_col); st.set_normal(Vector3.UP) - st.add_vertex(b * Vector3(-0.015, 0.0, 0.0)) - st.set_color(stem_col); st.set_normal(Vector3.UP) - st.add_vertex(b * Vector3(0.015, 0.0, 0.0)) - st.set_color(stem_col); st.set_normal(Vector3.UP) - st.add_vertex(b * Vector3(0.0, stem_h, 0.0)) - # Petals: three diamonds fanning from the stem tip. COLOR white = tinted. - for k in 3: - var b: Basis = Basis(Vector3.UP, TAU * float(k) / 3.0) - var tip: Vector3 = Vector3(0.0, stem_h, 0.0) - var out: Vector3 = b * Vector3(0.12, 0.06, 0.0) - var side: Vector3 = b * Vector3(0.05, 0.0, 0.05) - st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip) - st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip + out + side) - st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip + out * 1.6) - st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip) - st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip + out * 1.6) - st.set_color(Color.WHITE); st.set_normal(Vector3.UP); st.add_vertex(tip + out - side) - var mat: StandardMaterial3D = StandardMaterial3D.new() - mat.vertex_color_use_as_albedo = true - mat.vertex_color_is_srgb = true - mat.roughness = 0.9 - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - _fade_bloom(mat) - st.set_material(mat) - return st.commit() - - ## Blooms dissolve with distance — unfaded white petals read as scattered ## litter across the far sward instead of flowers. func _fade_bloom(mat: StandardMaterial3D) -> void: