Compare commits
150 Commits
29474e5751
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e5fb173008 | |||
| f5e6c59f2a | |||
| 7f155b8de2 | |||
| d553213563 | |||
| 5a9f39ea48 | |||
| 583d32456a | |||
| 6b10b6ea6c | |||
| 8d963e8030 | |||
| e169885d90 | |||
| f763aa0c31 | |||
| 10f89baef9 | |||
| aaff2c8554 | |||
| a106be673f | |||
| 1ae62fe0db | |||
| 8f3aa75fa9 | |||
| 63cbc8c16e | |||
| 7471d2e37f | |||
| 6291f21a00 | |||
| 34bf064904 | |||
| 31759ef848 | |||
| 4494856a44 | |||
| c5f127e0f1 | |||
| 6589d7c4ed | |||
| 2227d703f6 | |||
| fe8e24cf39 | |||
| 0bbf94289a | |||
| 1621a38b29 | |||
| 866158cc0d | |||
| 1c4d19492d | |||
| 537b0e1d18 | |||
| 057b83679b | |||
| c6b8a651aa | |||
| e4db022c4b | |||
| 17ef142689 | |||
| 7a74a8de94 | |||
| 333d901931 | |||
| b4b72e472e | |||
| b663703da7 | |||
| a9357fb787 | |||
| 62b8113ffa | |||
| bd49e118d5 | |||
| eba706b906 | |||
| 5149e817f6 | |||
| a668cf98eb | |||
| bb5d176262 | |||
| a7e153884f | |||
| 78e18107b2 | |||
| 89d4c65a7a | |||
| 9de8ee40b3 | |||
| 198ea27d1a | |||
| bb8097929a | |||
| c3e02c7f7d | |||
| e8a61bf76f | |||
| acc5a7b2a2 | |||
| 96c270601a | |||
| a495543290 | |||
| 8e1238c316 | |||
| 6c496d3ed8 | |||
| a9d9eedb7e | |||
| c106dbbd37 | |||
| 8a71671df7 | |||
| 2a372bff2f | |||
| a90cc16463 | |||
| 37ec1bb1be | |||
| 9e8fc55a44 | |||
| ef335a93c3 | |||
| 021c2480d8 | |||
| c171299cb4 | |||
| 0e8aa5efbd | |||
| ccff2fdbf2 | |||
| 997fef1803 | |||
| 2b54dad1fa | |||
| dc4b14d3e5 | |||
| fae39d9c79 | |||
| 894bde959e | |||
| 602edbf257 | |||
| ac8da4e6da | |||
| c1edcfe015 | |||
| 2bf4ae43d1 | |||
| 9aa06d59d7 | |||
| df5ed2b525 | |||
| 1dfa9462ba | |||
| 5646bdd42a | |||
| 6c542e3669 | |||
| c6d16430de | |||
| 0cf1b27773 | |||
| ce9db1d9c8 | |||
| d15a8fdcab | |||
| 8f137aba20 | |||
| 58dd7be7cf | |||
| 4780ab4802 | |||
| 86a4ebc105 | |||
| b15bd2bb1e | |||
| a3506f7f02 | |||
| bbaa59fad4 | |||
| c09b6e89a0 | |||
| f84e55ab4b | |||
| c2d67be76a | |||
| a160d3e967 | |||
| 7ce5e4584c | |||
| 11c4d6dcd7 | |||
| 973af41a6e | |||
| 0542516312 | |||
| e3c35614e7 | |||
| 9141cd1e9f | |||
| f21d7be4d9 | |||
| bd293c1701 | |||
| 40c3a7fc4b | |||
| 16527e9d60 | |||
| db5ee247e7 | |||
| aeb9ee4a9d | |||
| 1304f0aee7 | |||
| 1364850d39 | |||
| 3ee2c7c3a8 | |||
| 2eead38b54 | |||
| f07139256b | |||
| a77bf03ee4 | |||
| c6d513c966 | |||
| 263254346d | |||
| 56278a4831 | |||
| 8e4c0bf957 | |||
| 0249cbcf09 | |||
| d9caf1dff6 | |||
| c933d12504 | |||
| 58880554c2 | |||
| fb541d05cb | |||
| 6ef71aaa31 | |||
| 4698732285 | |||
| 578b3e86c9 | |||
| 5f1ebdf840 | |||
| bca04e3a2e | |||
| 35c4586792 | |||
| 9a02c9b038 | |||
| c4aa74da5a | |||
| 400445f312 | |||
| cc365c8b73 | |||
| 09922e5fd3 | |||
| 322aae4a33 | |||
| 7bbb66827b | |||
| 4a32e7df8a | |||
| 0c12e13ac1 | |||
| 6530625f4d | |||
| 0082765ee7 | |||
| 7954c579d3 | |||
| 73fcf2d096 | |||
| 9091707535 | |||
| 3981047ec6 | |||
| 888b2b32f5 | |||
| 233b1088cf | |||
| 2d7ed469bf |
168
.claude/skills/world-building/SKILL.md
Normal file
168
.claude/skills/world-building/SKILL.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
---
|
||||||
|
name: world-building
|
||||||
|
description: >
|
||||||
|
Author gritty, dark, disposition-gated world content for Code of Conquest (the
|
||||||
|
Margreave). Use this WHENEVER the user wants to write, expand, or add lore,
|
||||||
|
storylines, plot threads, secrets, factions, regions, towns, dungeons, NPCs and
|
||||||
|
their knowledge/dialogue, quests, items, locations, or starting origins — even
|
||||||
|
if they don't say "world-building" or name the content/ folder. Triggers on
|
||||||
|
"write an NPC", "add a town/region/faction", "new quest", "a secret the party
|
||||||
|
can uncover", "expand a region", "flesh out a town", "someone the
|
||||||
|
player meets on the road", "cursed item", "give me a storyline", and the like.
|
||||||
|
The skill reconciles new content against existing canon (never contradicting or
|
||||||
|
silently changing it), writes it in the exact source format the content build
|
||||||
|
tool consumes, keeps NPCs true to the world's hard, uncaring tone (warm only
|
||||||
|
when disposition is earned), and verifies the build stays green. Do NOT use it
|
||||||
|
for engine/UI code, prompts (/api/prompts), or combat rules.
|
||||||
|
---
|
||||||
|
|
||||||
|
# World-building — the Margreave
|
||||||
|
|
||||||
|
Author new content for **Code of Conquest**, a gritty single-player CRPG. Read
|
||||||
|
`CLAUDE.md` (repo root) once per session for the charter — this skill assumes it.
|
||||||
|
|
||||||
|
## The one rule that governs everything you write
|
||||||
|
|
||||||
|
> **Code owns state. AI owns text.** (charter §2)
|
||||||
|
|
||||||
|
You are authoring *static content*, not runtime state. Facts, personas, places,
|
||||||
|
and knowledge chains are text the engine references by stable **id**. You never
|
||||||
|
write initial disposition numbers into world content, never gameplay logic. A
|
||||||
|
starting *value* (where the player begins, a seeded disposition) belongs to an
|
||||||
|
**origin**, never to a town or NPC.
|
||||||
|
|
||||||
|
## The two things that make this hard
|
||||||
|
|
||||||
|
1. **New content must not contradict old content.** The build tool checks
|
||||||
|
structure (ids resolve, secrecy routes correctly) but it CANNOT tell that you
|
||||||
|
just described the north road as untraveled when canon says elves walk it. A
|
||||||
|
contradiction ships silently and the DM narrates a broken world. So:
|
||||||
|
**read the existing canon before you write a word**, and treat every existing
|
||||||
|
file as read-only unless the user explicitly approves an edit (see below).
|
||||||
|
|
||||||
|
2. **The tone is specific and easy to get wrong.** Gritty, not grim. The world
|
||||||
|
is hard and does not care about the player — but an NPC who has come to trust
|
||||||
|
them becomes genuinely warm. Getting either pole wrong (grimdark misery, or
|
||||||
|
friendly-NPC-from-hello) breaks the world. See `references/tone.md`.
|
||||||
|
|
||||||
|
## Workflow — follow in order
|
||||||
|
|
||||||
|
Make a todo per step.
|
||||||
|
|
||||||
|
### 1. Understand what's being asked, and which layer it lives in
|
||||||
|
|
||||||
|
| Content kind | Format | Lives in |
|
||||||
|
|---|---|---|
|
||||||
|
| People, places, regions, factions, world rules, knowledge (rumor/fact/secret), NPC dialogue layers | **Markdown bible** — ` ```yaml ` blocks | `content/lore/*.md` (source) |
|
||||||
|
| Quests / story skeletons | Hand-authored JSON | `content/world/quests/` |
|
||||||
|
| Items (incl. cursed/blessed) | Hand-authored JSON | `content/world/items/` |
|
||||||
|
| Locations (map nodes) | Hand-authored JSON | `content/world/locations/` |
|
||||||
|
| Origins (starting seeds) | Hand-authored JSON | `content/origins/` |
|
||||||
|
| Degraded-DM fallback prose | Markdown/text | `content/fallback/` |
|
||||||
|
|
||||||
|
The **lore bible is the heart** — it produces the disposition-gated dialogue that
|
||||||
|
makes NPCs feel alive. `content/world/` and `content/server/` under `canon/`,
|
||||||
|
`topics/`, and `npcs/` are **BUILT from the bible — never hand-edit them.** The
|
||||||
|
JSON under `quests/`, `items/`, `locations/`, and `origins/` is hand-authored and
|
||||||
|
loosely schema'd (POC seeds); edit those directly.
|
||||||
|
|
||||||
|
If a storyline you're writing implies a quest, item, or location that doesn't
|
||||||
|
exist yet, say so and author the stub, or flag it — don't leave a dangling id.
|
||||||
|
|
||||||
|
### 2. Load and reconcile against existing canon — before writing
|
||||||
|
|
||||||
|
Run the canon index to see every id, type, secrecy, and one-line gist already
|
||||||
|
authored:
|
||||||
|
|
||||||
|
```
|
||||||
|
.venv/bin/python .claude/skills/world-building/scripts/canon_index.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the entries your new content touches (people it references, the region it
|
||||||
|
sits in, secrets it brushes against). New content must slot into this without
|
||||||
|
contradiction. Reuse existing ids in `related:` rather than inventing parallel
|
||||||
|
ones. If your idea only works by *changing* something already canon — a fact's
|
||||||
|
body, a disposition, who knows what — **STOP.** Do not edit. Present the exact
|
||||||
|
before/after change and the reason, and get explicit approval first. New files
|
||||||
|
and new entries need no approval; edits to existing canon always do.
|
||||||
|
|
||||||
|
New content must also slot against the world's overarching cosmology — the good
|
||||||
|
god (the Warden), his angels (the Sent), and the seven demon kings who work
|
||||||
|
against him through influence, possession, and blood sacrifice. Read
|
||||||
|
`references/cosmology.md` before authoring; it is the backdrop nothing may
|
||||||
|
contradict, and it carries the authoring rules that keep the war in tone
|
||||||
|
(distant, worked through people, never epic, blessing/curse read through Luck).
|
||||||
|
|
||||||
|
### 3. Draft in the correct format
|
||||||
|
|
||||||
|
- **Bible entries:** see `references/schema.md` for the full field-by-field
|
||||||
|
contract, the id/namespace/type rules, the secrecy scale, and the knowledge-gate
|
||||||
|
model. Match the **structure** of `content/lore/specimen.md` (the synthetic
|
||||||
|
disposition-gate specimen — a guarded-then-warm NPC with one reveal earned at
|
||||||
|
`warm` and a `never`-gated core no disposition unlocks) and the **voice** of the
|
||||||
|
canon bodies in `content/lore/cosmology.md`.
|
||||||
|
- **Hand-authored JSON:** mirror the shape of the existing file in that folder
|
||||||
|
(`references/schema.md` documents each). Keep ids stable and lowercase-snake.
|
||||||
|
|
||||||
|
Write NPCs to the tone and disposition-warmth model in `references/tone.md`. A
|
||||||
|
hard NPC starts guarded (`cold`/`neutral`, sometimes reachable-`hostile`),
|
||||||
|
withholds more than a stranger would expect, and both *reveals more* and *softens
|
||||||
|
in manner* as the player climbs their ladder. That warmth is earned, never
|
||||||
|
default — it's what the five-rung gate system exists to deliver.
|
||||||
|
|
||||||
|
### 4. Build and verify — not done until green
|
||||||
|
|
||||||
|
The build regenerates `content/world/` + `content/server/` from the bible, and the
|
||||||
|
`--check` gate is what proves it stayed valid. **There is no CI — nothing runs
|
||||||
|
these for you.** You are the gate; run them, from repo root, with the repo venv
|
||||||
|
active (`source .venv/bin/activate`). The `content_build` package lives under
|
||||||
|
`tools/`, so `PYTHONPATH=tools` is required:
|
||||||
|
|
||||||
|
```
|
||||||
|
PYTHONPATH=tools python -m content_build # regenerate world/ + server/ from lore/
|
||||||
|
PYTHONPATH=tools python -m content_build --check # must exit 0: fresh, valid, no orphans, no secrecy leaks
|
||||||
|
PYTHONPATH=tools python -m pytest tools/content_build/tests -q # schema + secrecy + cli coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
**If the build changed anything under `content/world/`, also run the client
|
||||||
|
suite** — `bash client/run_tests.sh` (must be fully green):
|
||||||
|
|
||||||
|
```
|
||||||
|
bash client/run_tests.sh # Godot/GUT; content/world/ is a CLIENT INPUT, not just build output
|
||||||
|
```
|
||||||
|
|
||||||
|
That last one is not optional and not paranoia. `client/scripts/content/content_db.gd`
|
||||||
|
loads `content/world/`, and `client/tests/unit/test_content_db.gd` asserts on it — so
|
||||||
|
**deleting or renaming generated content is a client change.** A content purge once
|
||||||
|
left the client erroring on a vanished directory and turned a secrecy assertion into a
|
||||||
|
test that passed while checking nothing. Nothing else will catch that for you.
|
||||||
|
|
||||||
|
(If `python` isn't found, the venv isn't active — use `.venv/bin/python`.)
|
||||||
|
If any step fails, read the `BUILD FAILED` / `STALE:` message — it names the
|
||||||
|
source file and line — fix the bible, rebuild, recheck. Never hand-edit the
|
||||||
|
generated JSON to make `--check` pass; fix the source. (Hand-authored JSON under
|
||||||
|
quests/items/locations/origins isn't built — the build tool ignores it. If you
|
||||||
|
touched those, just confirm they're valid JSON.)
|
||||||
|
|
||||||
|
### 5. Report
|
||||||
|
|
||||||
|
Summarize: new ids added (by layer), any existing id you reused in `related`,
|
||||||
|
anything you flagged for approval, and confirmation the build + check + tests are
|
||||||
|
green. Update [`content/roadmap.md`](../../../content/roadmap.md) if you added a new
|
||||||
|
bible file or closed out a thread — its Spine list, or the Bill of Materials line
|
||||||
|
you just satisfied.
|
||||||
|
|
||||||
|
**Respect its rule: never author anything that is not in the Bill of Materials.**
|
||||||
|
An idea that arrives during authoring goes to that doc's Backlog, not into the
|
||||||
|
world.
|
||||||
|
|
||||||
|
## Reference files
|
||||||
|
|
||||||
|
- `references/cosmology.md` — the overarching theme (the Warden and the Seven)
|
||||||
|
every piece of content reconciles against, plus the rules that keep it in tone.
|
||||||
|
Read before authoring any lore, NPC, quest, or item.
|
||||||
|
- `references/schema.md` — the complete bible + JSON schema, all validation rules,
|
||||||
|
the secrecy scale, the knowledge-gate model, worked examples. Read before
|
||||||
|
authoring bible entries.
|
||||||
|
- `references/tone.md` — the Margreave's tone contract and the NPC
|
||||||
|
disposition-warmth model. Read before writing any NPC or narrative body.
|
||||||
48
.claude/skills/world-building/evals/evals.json
Normal file
48
.claude/skills/world-building/evals/evals.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"skill_name": "world-building",
|
||||||
|
"evals": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "new-npc-reconciled",
|
||||||
|
"prompt": "Add a new NPC on the road: a caravan guard who's cagey with strangers but opens up once you've earned it. Give them something personal they only share when they trust you. Put it in a new lore file.",
|
||||||
|
"expected_output": "New npc.* dialogue-layer entry (+ its person/place/knowledge entries) authored in a NEW content/lore file; start_disposition cold or neutral; at least one fact gated at warm/trusted; every related resolves; no existing lore file edited; PYTHONPATH=tools content_build --check exits 0; tone dry and guarded-then-warm; nothing contradicts the cosmology backdrop.",
|
||||||
|
"assertions": [
|
||||||
|
"Created at least one new npc.* entry in content/lore with start_disposition of cold or neutral",
|
||||||
|
"The NPC's knows list gates a personal fact at warm or trusted (earned reveal)",
|
||||||
|
"Every related id resolves to a real entry",
|
||||||
|
"No pre-existing content/lore/*.md file was modified (or any edit was surfaced for approval, not silent)",
|
||||||
|
"python -m content_build --check exits 0 after the change",
|
||||||
|
"Persona/body prose is dry and in-tone; no narrator winking or 'hilarious' register",
|
||||||
|
"Nothing authored contradicts the cosmology canon (the Warden / the Seven)"
|
||||||
|
],
|
||||||
|
"files": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "contradiction-approval-gate",
|
||||||
|
"prompt": "Write an NPC priest who tells the player the Warden's true name and proves the god is real and good.",
|
||||||
|
"expected_output": "Skill recognizes this contradicts locked cosmology (the Warden is nameless BY DESIGN; his existence and goodness are 'distant & contested', never confirmed by the narrator), STOPS instead of authoring it, and either explains the conflict or proposes an in-tone alternative (an NPC who CLAIMS a name or believes, with the game confirming nothing) and asks before writing.",
|
||||||
|
"assertions": [
|
||||||
|
"Did NOT author content that states the Warden's true name or confirms the god is real/good",
|
||||||
|
"Surfaced the contradiction explicitly, naming the cosmology rule (nameless by design; distant & contested)",
|
||||||
|
"Proposed an in-tone alternative and/or asked for approval before writing"
|
||||||
|
],
|
||||||
|
"files": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "new-storyline-file",
|
||||||
|
"prompt": "Start a new storyline about a roadside cult bleeding travelers to feed one of the Seven: who they are, and a rumor the player might hear. Make it its own file.",
|
||||||
|
"expected_output": "A new content/lore/*.md file with canon entities and at least one knowledge entry; related wired to a real cosmology id (faction.the-seven or a specific lord); knowledge with correct ascending secrecy; build + --check green; content/roadmap.md updated. The demon tie is handled in-tone: a gated secret (hidden engine), not stated in the open.",
|
||||||
|
"assertions": [
|
||||||
|
"Authored a new content/lore/*.md file (its own file)",
|
||||||
|
"Wired at least one new entry's related to a real cosmology id (faction.the-seven or a lord)",
|
||||||
|
"Includes at least one knowledge entry (rumor/fact/secret) with correct ascending secrecy",
|
||||||
|
"python -m content_build --check exits 0 after the change",
|
||||||
|
"content/roadmap.md updated to reflect the new content",
|
||||||
|
"The demonic connection is a gated secret, not open surface text"
|
||||||
|
],
|
||||||
|
"files": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
57
.claude/skills/world-building/references/cosmology.md
Normal file
57
.claude/skills/world-building/references/cosmology.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Cosmology — the Warden and the Seven
|
||||||
|
|
||||||
|
The overarching theme of the Margreave. Every new piece of content sits inside
|
||||||
|
this hidden war and must not contradict it. The canon lives in
|
||||||
|
`content/lore/cosmology.md` and `content/lore/the-seven.md`; this file is the
|
||||||
|
author-facing summary + the rules that keep it in tone. Read it before writing
|
||||||
|
lore, NPCs, quests, or items.
|
||||||
|
|
||||||
|
## The frame
|
||||||
|
|
||||||
|
- **The Warden** (`person.the-warden`) — a genuinely good, *distant* god. Made
|
||||||
|
the races, loves them, wards the world against the Seven. Answers worship with
|
||||||
|
rare blessing and does not otherwise intervene. **Nameless by design** — no one
|
||||||
|
living speaks his name; never invent one. Whether he is good, absent, or dead
|
||||||
|
is the world's oldest argument, never settled by the narrator.
|
||||||
|
- **The Sent** (`faction.the-sent`) — his angels. Indirect: a Sent one *asks* a
|
||||||
|
mortal to do a deed in his name; blessing follows the deed. Rare — most people
|
||||||
|
never meet one. This is the cosmic-flavored quest-giver vein.
|
||||||
|
- **The Seven** (`faction.the-seven`) — seven demon kings + armies, the Warden's
|
||||||
|
enemy. Want the races dead or enslaved. Run on **blood** (`rule.blood-price`):
|
||||||
|
sacrifice buys their power. The roster (`content/lore/the-seven.md`): Kareth
|
||||||
|
(slaughter), Vael (betrayal), Morren (plague/mercy-kill), Ghaul (bondage),
|
||||||
|
Ishri (appetite), Nuun (madness — chief possessor), Draeth (tyranny).
|
||||||
|
- **Possession** (`rule.possession`) — how the Seven reach mortals: mostly
|
||||||
|
*influence* (a hand on the scale of what a person already wants), rarely
|
||||||
|
outright seizing a body (Nuun's specialty). A possessed mortal is a victim; the
|
||||||
|
thing inside is the enemy.
|
||||||
|
- **The two economies** — worship→blessing (`rule.the-pact`) vs
|
||||||
|
blood→power (`rule.blood-price`). Both express through Luck's *flavor*, never
|
||||||
|
numbers (see below).
|
||||||
|
|
||||||
|
## The FTH-axis callings (from the races/classes spec)
|
||||||
|
|
||||||
|
The two divine callings draw power from opposite sides; the rest are unaligned.
|
||||||
|
- **Bonesetter** (Cleric, FTH) — channels the Warden; healing *is* his blessing.
|
||||||
|
- **Bloodsworn** (Warlock, FTH) — pacted to one of the Seven; the "Pact Mark"
|
||||||
|
talent spends a thread of a lord's blood-price power.
|
||||||
|
- **Hedge-Mage** (Wizard, MAG) — outsider; arcane is *studied*, serves neither.
|
||||||
|
- Martials (Sellsword/Trapper/Barbarian) — unaligned; the war only echoes.
|
||||||
|
Callings are code-owned state — reference them in prose, never author a "class"
|
||||||
|
entity. (A Bloodsworn's patron choice and any Luck effect are M4/M5, not content.)
|
||||||
|
|
||||||
|
## Authoring rules — keep it in tone
|
||||||
|
|
||||||
|
1. **Distant.** The cosmology is the hidden engine under local grime, not the
|
||||||
|
surface text. A corrupt mayor can just be a corrupt mayor.
|
||||||
|
2. **Through people.** Demons work by influence; possession is rare and quiet.
|
||||||
|
No manifesting monsters in the square, no preaching narrator, god unconfirmed.
|
||||||
|
3. **Not epic.** Keep the gritty, indifferent register (see `tone.md`). Cosmic
|
||||||
|
stakes stay implied, argued-over, never announced.
|
||||||
|
4. **Blessing/curse ↔ Luck.** Express which side has hold of someone through
|
||||||
|
Luck's *flavor* ("fortune spits on you") — never a number, never something the
|
||||||
|
player can calculate. A cursed item (+STR / −LCK) is a small bargain with the
|
||||||
|
Seven.
|
||||||
|
5. **Victims are gated secrets.** WHO is possessed, WHICH road is bled, WHICH
|
||||||
|
shrine is a front — author as per-town `rumor`/`fact`/`secret` with disposition
|
||||||
|
gates, not stated in the open. The frame gives you hooks; you place the wounds.
|
||||||
260
.claude/skills/world-building/references/schema.md
Normal file
260
.claude/skills/world-building/references/schema.md
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
# Content schema — the exact contract
|
||||||
|
|
||||||
|
Everything the build tool (`tools/content_build/`) enforces, plus the loose JSON
|
||||||
|
formats it doesn't build. When in doubt, the validator in `resolve.py` is the
|
||||||
|
source of truth; this file is its plain-language mirror.
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [The bible file (`content/lore/*.md`)](#the-bible-file)
|
||||||
|
- [Common fields (every bible entry)](#common-fields)
|
||||||
|
- [Entry kinds](#entry-kinds)
|
||||||
|
- [The secrecy scale](#the-secrecy-scale)
|
||||||
|
- [The knowledge-gate model](#the-knowledge-gate-model)
|
||||||
|
- [Client vs server routing](#client-vs-server-routing)
|
||||||
|
- [Worked example](#worked-example)
|
||||||
|
- [Hand-authored JSON (not built)](#hand-authored-json)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The bible file
|
||||||
|
|
||||||
|
A bible file is Markdown. Prose between blocks is for human authors — the build
|
||||||
|
tool ignores it. Content lives in fenced blocks opened by a line that is exactly
|
||||||
|
` ```yaml ` and closed by ` ``` `. Each block is one YAML **mapping** = one entry.
|
||||||
|
|
||||||
|
Organize a file with `##` headings and `>` notes like `content/lore/specimen.md`.
|
||||||
|
Every `*.md` in `content/lore/` is parsed; a new region/storyline can be its own
|
||||||
|
file (e.g. `content/lore/the-tallow-reach.md`).
|
||||||
|
|
||||||
|
## Common fields
|
||||||
|
|
||||||
|
Every entry requires these five (missing any → build fails):
|
||||||
|
|
||||||
|
| Field | Rule |
|
||||||
|
|---|---|
|
||||||
|
| `id` | `namespace.slug`, lowercase-kebab. Namespace is one of the legal set below. **Globally unique** — duplicate id fails the build. |
|
||||||
|
| `type` | Must equal the id's namespace, EXCEPT `npc.*` whose type is always `person`. |
|
||||||
|
| `status` | `candidate` or `canon`. Only `canon` entries emit to world/server; `candidate` is validated but not shipped — use it for work-in-progress. |
|
||||||
|
| `secrecy` | Integer (not bool). See the scale below. |
|
||||||
|
| `related` | List of ids (may be empty `[]`). **Every id must resolve** to another entry, or the build fails. This is the web that keeps the world coherent — wire new entries into it. |
|
||||||
|
|
||||||
|
`body: >` — the prose. A YAML folded scalar. This is what the DM/NPC pipeline
|
||||||
|
reads. For canon entities it ships to the client; for knowledge it's server-only.
|
||||||
|
|
||||||
|
## Entry kinds
|
||||||
|
|
||||||
|
Two families, distinguished by **id namespace** (not by any `kind` field):
|
||||||
|
|
||||||
|
### Canon entities — the world's furniture
|
||||||
|
|
||||||
|
Namespaces `town`, `place`, `region`, `faction`, `person`, `rule`.
|
||||||
|
- `type` == namespace.
|
||||||
|
- **Must be `secrecy: 0`** (they're public; their bodies ship to the client).
|
||||||
|
- `body` is public description.
|
||||||
|
- The special entry `rule.disposition-ladder` carries a `rungs:` list and is
|
||||||
|
**required exactly once** across all bibles — it defines the gate vocabulary.
|
||||||
|
Don't duplicate or remove it; it lives in `mechanics.md`.
|
||||||
|
|
||||||
|
### Knowledge — the atoms NPCs reveal
|
||||||
|
|
||||||
|
Namespaces `rumor`, `fact`, `secret` (these three id-prefixes; `type` matches).
|
||||||
|
- `secrecy` 1–4 (author-facing sensitivity — see scale).
|
||||||
|
- `body` is the thing known. **Routes to the server**, never the client.
|
||||||
|
- One atomic idea per entry, so different NPCs can hold the same fact at
|
||||||
|
different gates. Chain them by `related:` (a secret relates to the facts that
|
||||||
|
circle it).
|
||||||
|
|
||||||
|
### NPC dialogue layer — `npc.*`
|
||||||
|
|
||||||
|
The interactive layer of a person. `id: npc.<slug>`, `type: person`. This is a
|
||||||
|
SEPARATE entry from the `person.<slug>` world-record; the namespace prefix is the
|
||||||
|
only thing distinguishing them (a person may have both — see the mayor). Extra
|
||||||
|
required/optional fields:
|
||||||
|
|
||||||
|
| Field | Rule |
|
||||||
|
|---|---|
|
||||||
|
| `start_disposition` | **Required.** One of the ladder rungs (`hostile`/`cold`/`neutral`/`warm`/`trusted`). Where this NPC meets a stranger. |
|
||||||
|
| `knows` | **Required, non-empty.** List of `{fact: <knowledge-id>, gate: <rung-or-never>}`. Each `fact` must resolve to a `rumor`/`fact`/`secret`. Each `gate` is a rung or `never`. |
|
||||||
|
| `body` | Here it's the NPC's **persona** — voice, manner, what they want, why they withhold. Server-only. |
|
||||||
|
| `disposition_notes` | Optional. Author guidance on how they move up/down the ladder and what each pole means. Server-only. |
|
||||||
|
|
||||||
|
A non-npc entry must NOT carry `knows` or `start_disposition`. An `npc.*` entry
|
||||||
|
MUST carry both.
|
||||||
|
|
||||||
|
## The secrecy scale
|
||||||
|
|
||||||
|
`secrecy` is **author-facing and static** — a mis-authoring guard, not a runtime
|
||||||
|
lever. It answers "how sensitive is this text, where may it live?"
|
||||||
|
|
||||||
|
| secrecy | Meaning | Routing |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Public. Canon entities only. | Body ships client-side. |
|
||||||
|
| 1 | Observable rumor; freely-ish known. | Body server-only. |
|
||||||
|
| 2 | Known locally, not discussed. | Body server-only. |
|
||||||
|
| 3 | Protected; a real tell. | **Body must never reach the client** (build hard-fails if it does). |
|
||||||
|
| 4 | The core secret. | Server-only. |
|
||||||
|
|
||||||
|
Keep the secrecy of a knowledge chain *ascending* from freely-observed to the
|
||||||
|
`never`-gated core — a chain climbs by secrecy, e.g. 1→2→2→3→4 from an
|
||||||
|
observable rumor to the core secret.
|
||||||
|
|
||||||
|
## The knowledge-gate model
|
||||||
|
|
||||||
|
**Two independent axes — never conflate them:**
|
||||||
|
|
||||||
|
- **secrecy** (above) lives on the *fact* — static, author-facing.
|
||||||
|
- **gate** lives on the *knowledge link* (`knows[].gate`) — runtime,
|
||||||
|
character-facing. It's the disposition rung at which THIS NPC will reveal THIS
|
||||||
|
fact.
|
||||||
|
|
||||||
|
Rungs, low → high: `hostile < cold < neutral < warm < trusted`. Plus the special
|
||||||
|
gate **`never`**: the NPC *knows* the fact (the Improviser can reason with it) but
|
||||||
|
no disposition ever unlocks it in dialogue. `never` is how a locked-door NPC (the
|
||||||
|
mayor) holds the secret without leaking it.
|
||||||
|
|
||||||
|
Design a chain so climbing an NPC's disposition unlocks successively deeper facts.
|
||||||
|
The reachable ceiling is a design choice: an NPC can *witness* enough to imply a
|
||||||
|
secret without holding the secret entry itself: its top rung names what it
|
||||||
|
witnessed, not the whole truth, and the player infers the rest. Understanding
|
||||||
|
earned beats understanding handed over.
|
||||||
|
|
||||||
|
## Client vs server routing
|
||||||
|
|
||||||
|
The build splits each canon entry (`emit.py`) — you don't do this, but author with
|
||||||
|
it in mind:
|
||||||
|
|
||||||
|
- **Canon entity** → `content/world/canon/<slug>.json` (id, type, related, public
|
||||||
|
body). Nothing server-side.
|
||||||
|
- **Knowledge** → client `content/world/topics/<slug>.json` (id, type, related —
|
||||||
|
**no body**); server `content/server/topics/<slug>.json` (id, body, secrecy).
|
||||||
|
- **npc.\*** → client `content/world/npcs/<slug>.json` (id, type,
|
||||||
|
start_disposition, related, knows+gates — the gate logic the client needs);
|
||||||
|
server `content/server/npcs/<slug>.json` (id, persona, disposition_notes — the
|
||||||
|
voice, hidden).
|
||||||
|
|
||||||
|
The guarantee: a player who reads the client bundle sees NPC *gates* but never NPC
|
||||||
|
*personas* or *secret bodies*. That's why secrecy≥3 bodies client-side is a hard
|
||||||
|
build failure.
|
||||||
|
|
||||||
|
## Worked example
|
||||||
|
|
||||||
|
A guarded fisherman who warms up and names the coin — and who holds one thing no
|
||||||
|
disposition ever buys.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: person.specimen-teague
|
||||||
|
type: person
|
||||||
|
status: canon
|
||||||
|
secrecy: 0
|
||||||
|
related: [place.specimen-wharf]
|
||||||
|
body: >
|
||||||
|
Teague, oldest hand on the Greywater wharf. Mends nets he no longer sails with.
|
||||||
|
Watches everything off the water and says almost none of it.
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: fact.specimen-foreign-coin
|
||||||
|
type: fact
|
||||||
|
status: canon
|
||||||
|
secrecy: 2
|
||||||
|
related: [place.specimen-wharf, person.specimen-teague]
|
||||||
|
body: >
|
||||||
|
The dock hands are paid in pass-country coin, not town mint. Someone outside the
|
||||||
|
charter is buying the wharf's silence, a little at a time.
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: secret.specimen-drowned-clerk
|
||||||
|
type: secret
|
||||||
|
status: canon
|
||||||
|
secrecy: 4
|
||||||
|
related: [place.specimen-wharf, person.specimen-teague, fact.specimen-foreign-coin]
|
||||||
|
body: >
|
||||||
|
The charter clerk who came asking after the coin went into the river in the
|
||||||
|
spring. Teague held his legs. He has not been out on the water since and tells
|
||||||
|
anyone who asks that it is his knees.
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: npc.specimen-teague
|
||||||
|
type: person
|
||||||
|
status: canon
|
||||||
|
start_disposition: cold
|
||||||
|
related:
|
||||||
|
[person.specimen-teague, fact.specimen-foreign-coin,
|
||||||
|
secret.specimen-drowned-clerk]
|
||||||
|
body: >
|
||||||
|
Same man as person.specimen-teague; his interactive layer. Curt with strangers, not
|
||||||
|
from malice but from a lifetime of watching talkers end up face-down in the
|
||||||
|
river. Warms slowly, and when he does the dryness turns to something almost
|
||||||
|
fond.
|
||||||
|
|
||||||
|
knows:
|
||||||
|
- {fact: fact.specimen-foreign-coin, gate: warm}
|
||||||
|
- {fact: secret.specimen-drowned-clerk, gate: never}
|
||||||
|
|
||||||
|
disposition_notes: >
|
||||||
|
Starts cold. Buying his catch, not his story, is what moves him. At warm he
|
||||||
|
names the coin; he never speculates aloud about who mints it. The clerk is the
|
||||||
|
one thing no rung buys — saying it aloud would hang him, and he knows it. He
|
||||||
|
reasons around it, steers off it, and takes it to the ground.
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `person.specimen-teague` (secrecy 0, public) and `npc.specimen-teague` (the layer,
|
||||||
|
persona server-only) are two entries. The chain ascends 2 → 4: the coin is gated at
|
||||||
|
`warm` and the player earns it; the clerk is gated `never` and no disposition unlocks
|
||||||
|
it. **That is what `never` is for.** Teague holds it — the Improviser may reason with
|
||||||
|
it, and the player may infer it from what he *will* say — but he cannot be talked into
|
||||||
|
saying it, because saying it would hang him. A `never` gate needs a reason like that:
|
||||||
|
if any rung *should* buy the secret, gate it at `trusted` instead.
|
||||||
|
|
||||||
|
## Hand-authored JSON
|
||||||
|
|
||||||
|
Not built from the bible — edit these files directly. Loosely schema'd POC seeds;
|
||||||
|
mirror the existing file in the folder. Keep ids stable, lowercase-snake.
|
||||||
|
|
||||||
|
**Quest** — `content/world/quests/<id>.json`
|
||||||
|
```json
|
||||||
|
{ "id": "find_the_ledger", "name": "The Missing Ledger",
|
||||||
|
"objective": "Find who took Fenn's ledger" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Item** — `content/world/items/<id>.json`. Cursed/blessed items move Luck (§7):
|
||||||
|
the STR/LCK split is the point. Numeric effects live in game state; keep the JSON
|
||||||
|
to identity/slot and let narrative-worthy items surface as canon-log facts.
|
||||||
|
```json
|
||||||
|
{ "id": "worn_shortsword", "name": "a worn shortsword", "slot": "weapon" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location** — `content/world/locations/<id>.json`. Map nodes; an origin's
|
||||||
|
`start_location_id` resolves here.
|
||||||
|
```json
|
||||||
|
{ "id": "greywater_docks", "name": "the Greywater docks",
|
||||||
|
"description": "A rot-black wharf where the river meets the sea trade." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Origin** — `content/origins/<id>.json`. Thin starting seed — where the player
|
||||||
|
begins and the situation. This is the ONLY place initial *state* (seeded
|
||||||
|
dispositions, granted items) belongs. Every referenced id
|
||||||
|
(`start_location_id`, `inventory_grants[].item_id`, `start_quest_id`,
|
||||||
|
`disposition_overrides` keys) must resolve to real content, or new-game
|
||||||
|
construction fails loudly.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "deserter",
|
||||||
|
"display_name": "The Deserter",
|
||||||
|
"description": "You walked away from a company that doesn't allow walking away.",
|
||||||
|
"start_location_id": "greywater_docks",
|
||||||
|
"situation": ["Arrived by barge before dawn, hood up"],
|
||||||
|
"opening_facts": ["the player deserted the Iron Kettle mercenary company"],
|
||||||
|
"disposition_overrides": { "brannoc_thane": 40, "cadwyn_vell": 15 },
|
||||||
|
"inventory_grants": [{ "item_id": "worn_shortsword", "qty": 1 }],
|
||||||
|
"start_quest_id": "find_the_ledger",
|
||||||
|
"build_constraints": { "allowed_callings": ["sellsword","reaver","cutpurse","trapper","hedge_mage","bonesetter","bloodsworn"], "luck_modifier": 0 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fallback** — `content/fallback/`. Degraded-DM prose (charter §13), written as
|
||||||
|
in-voice content, not error text. Every AI surface needs one before it ships.
|
||||||
121
.claude/skills/world-building/references/tone.md
Normal file
121
.claude/skills/world-building/references/tone.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Tone — the Margreave
|
||||||
|
|
||||||
|
The world's voice. Distilled from charter §3 (tone), §7 (Luck), §9 (companions).
|
||||||
|
Read before writing any `body`, persona, or narrative prose. Getting this right is
|
||||||
|
most of the job — the schema is easy; the voice is the product.
|
||||||
|
|
||||||
|
## The core stance
|
||||||
|
|
||||||
|
**Gritty, not grim.** The reference is Batman in the DC comics — not Warhammer,
|
||||||
|
not Care Bears. The world has real problems and does not care about the player.
|
||||||
|
People get hangovers, vomit, curse, get infected wounds. But it is not a misery
|
||||||
|
engine. Grimdark is as wrong as cozy. The world is *indifferent*, and indifference
|
||||||
|
is harder and more interesting than cruelty.
|
||||||
|
|
||||||
|
**The world is played straight.** Comedy emerges from situation, never from the
|
||||||
|
narrator. Nothing is ever "hilariously" anything. The narrator is dry and does not
|
||||||
|
wink. When you're tempted to make prose funny, make the *situation* absurd and
|
||||||
|
describe it flatly instead.
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
- **Profanity** is permitted and should feel *earned*, not decorative.
|
||||||
|
- **Violence has weight.** Don't gloss it and don't wallow.
|
||||||
|
- **Sex exists** and is discussed the way adults discuss it — bluntly, and mostly
|
||||||
|
as a source of trouble.
|
||||||
|
- **Bodies are real.** Fatigue, hunger, cold, drink, injury. The world touches the
|
||||||
|
characters physically.
|
||||||
|
|
||||||
|
## The narrator voice
|
||||||
|
|
||||||
|
Dry. Economical. Observational. States what is, lets the reader feel the weight.
|
||||||
|
It never editorializes, never reassures, never jokes. A good body reads like
|
||||||
|
someone who has seen a lot and is not impressed, telling you the truth plainly.
|
||||||
|
|
||||||
|
Look at the cosmology bodies: "Most of the Margreave believes the Seven are real.
|
||||||
|
Most of the Margreave is right." The weight is in the plain statement, not in
|
||||||
|
adjectives.
|
||||||
|
|
||||||
|
## NPCs: hard by default, warm when earned
|
||||||
|
|
||||||
|
This is the load-bearing pattern for the user's world. **The world doesn't care
|
||||||
|
about you — but a person who comes to trust you does.** That arc is what the
|
||||||
|
disposition ladder exists to deliver, and it's what makes the coldness bearable.
|
||||||
|
|
||||||
|
### Default posture: guarded
|
||||||
|
|
||||||
|
A Margreave NPC meets a stranger with wariness, not hostility — they have their
|
||||||
|
own troubles and no reason to spend them on you. Mechanically:
|
||||||
|
|
||||||
|
- `start_disposition` is usually `cold` or `neutral`, rarely `warm`.
|
||||||
|
- A frightened or burned NPC withholds **more** than a stranger would expect, not
|
||||||
|
less (fear reads as prickliness). `hostile` is a
|
||||||
|
reachable floor: push them, threaten, side against them, and they close entirely.
|
||||||
|
- What they reveal is gated. Low rungs get you surface; the personal and the
|
||||||
|
dangerous sit higher up.
|
||||||
|
|
||||||
|
### The warm turn: real, specific, earned
|
||||||
|
|
||||||
|
When the player climbs the ladder, two things change together:
|
||||||
|
|
||||||
|
1. **They reveal more** — deeper `knows` gates unlock.
|
||||||
|
2. **Their manner softens** — write the persona and `disposition_notes` so that at
|
||||||
|
`warm`/`trusted` the dryness turns to something human: fondness, relief at being
|
||||||
|
heard, blunt loyalty — the specimen NPC's "dryness turns to something almost
|
||||||
|
fond."
|
||||||
|
|
||||||
|
The warmth is never sycophantic and never a personality transplant — a hard person
|
||||||
|
stays recognizably themselves, just no longer guarded against *you*. That's more
|
||||||
|
affecting than a switch from mean to nice.
|
||||||
|
|
||||||
|
**Don't** write an NPC who is friendly from hello (breaks the world) or one who
|
||||||
|
stays cold no matter what (makes the ladder pointless and the world hopeless). The
|
||||||
|
range between those poles is the whole design.
|
||||||
|
|
||||||
|
## The companions set the tone (charter §9)
|
||||||
|
|
||||||
|
If you write companion content, hold these exactly — they carry the register:
|
||||||
|
|
||||||
|
- **Cadwyn Vell (Bard, NPC-only):** genuinely talented, reflexively lies about
|
||||||
|
small things and truthfully about large ones, does not know he is the problem and
|
||||||
|
never becomes self-aware. Florid performing, clipped when scared. The source of
|
||||||
|
chaos.
|
||||||
|
- **Brannoc Thane (Sellsword):** dry, warm, economical, twenty years past his
|
||||||
|
prime and at peace with it. Says devastating things in the tone of a man
|
||||||
|
discussing weather. Holds the humiliation log — the callback engine. Not a grump;
|
||||||
|
*fond* of you, which is what makes it land.
|
||||||
|
- The pair dislike each other mildly and permanently, and neither will leave the
|
||||||
|
other. Cadwyn generates, Brannoc annotates. Don't add a third to the loop.
|
||||||
|
|
||||||
|
## The cosmic backdrop (the Warden and the Seven)
|
||||||
|
|
||||||
|
The world sits inside a hidden war — a distant good god and seven demon kings
|
||||||
|
(see `references/cosmology.md`). It colors tone but must never break the world's
|
||||||
|
indifference:
|
||||||
|
|
||||||
|
- The cosmology is the **hidden engine**, not the surface text. Day-to-day the
|
||||||
|
world is still taxes, mud, and bad men; the war is what's *underneath* them.
|
||||||
|
- Demons work **through people** — influence, rarely possession. Write the
|
||||||
|
wreckage and the person going wrong, not a monster in the square. The narrator
|
||||||
|
never confirms the god, never preaches, never goes epic.
|
||||||
|
- Which side has hold of someone reads through **Luck's flavor, never a number**
|
||||||
|
(a blessing is a run of fortune; a curse is the trap door opening under you).
|
||||||
|
- The good/evil is real but **argued-over** — most folk dispute whether any of it
|
||||||
|
is true. Certainty is the enemy of this tone as much as of §7's Luck.
|
||||||
|
|
||||||
|
## Luck's fingerprint (charter §7)
|
||||||
|
|
||||||
|
If content touches Luck: it is **visible in prose, never in numbers.** Never state
|
||||||
|
a Luck value; render a descriptor ("Fortune spits on you"). Bad luck costs
|
||||||
|
**dignity, not progress** — embarrassment, inconvenience, property damage, social
|
||||||
|
catastrophe, minor injury. Never quest failure, permanent loss, or death. A cursed
|
||||||
|
item that grants power at a Luck cost (+STR / −LCK) is the most interesting kind of
|
||||||
|
item — lean into that.
|
||||||
|
|
||||||
|
## Quick self-check before you ship a body
|
||||||
|
|
||||||
|
- Did the narrator stay dry and out of the joke?
|
||||||
|
- Is the hardness *indifference*, not sadism?
|
||||||
|
- Does the NPC start guarded, with a real warm turn available up the ladder?
|
||||||
|
- Would profanity/violence/sex here feel earned, not decorative?
|
||||||
|
- Does anything contradict an existing canon body? (If yes → reconcile, don't ship.)
|
||||||
112
.claude/skills/world-building/scripts/canon_index.py
Normal file
112
.claude/skills/world-building/scripts/canon_index.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Print an index of every authored entry across the lore bible + hand-authored
|
||||||
|
JSON, so new content can be reconciled against existing canon BEFORE writing.
|
||||||
|
|
||||||
|
Reads the same ```yaml blocks the content build tool parses (no dependency on the
|
||||||
|
build package — pure stdlib + PyYAML, which the repo already needs). Run from the
|
||||||
|
repo root:
|
||||||
|
|
||||||
|
python .claude/skills/world-building/scripts/canon_index.py
|
||||||
|
|
||||||
|
Output groups bible entries by kind (canon entity / knowledge / npc layer) with
|
||||||
|
id, status, secrecy, and a one-line gist, then lists hand-authored quest/item/
|
||||||
|
location/origin ids. Use it to spot id collisions and facts your new content must
|
||||||
|
not contradict."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("PyYAML not installed; run: pip install pyyaml")
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[4]
|
||||||
|
LORE = REPO / "content" / "lore"
|
||||||
|
WORLD = REPO / "content" / "world"
|
||||||
|
ORIGINS = REPO / "content" / "origins"
|
||||||
|
|
||||||
|
CANON_TYPES = {"town", "place", "region", "faction", "person", "rule"}
|
||||||
|
KNOWLEDGE_TYPES = {"rumor", "fact", "secret"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bible(path: Path):
|
||||||
|
"""Yield yaml-block mappings, mirroring tools/content_build/parse.py."""
|
||||||
|
lines = path.read_text().splitlines()
|
||||||
|
i, n = 0, len(lines)
|
||||||
|
while i < n:
|
||||||
|
if lines[i].strip() == "```yaml":
|
||||||
|
i += 1
|
||||||
|
block = []
|
||||||
|
while i < n and lines[i].strip() != "```":
|
||||||
|
block.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
data = yaml.safe_load("\n".join(block))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
yield data, path.name
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
def gist(text) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
one = " ".join(str(text).split())
|
||||||
|
return one[:100] + ("…" if len(one) > 100 else "")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not LORE.is_dir():
|
||||||
|
return print(f"no lore dir at {LORE}") or 1
|
||||||
|
|
||||||
|
entities, knowledge, npcs = [], [], []
|
||||||
|
for md in sorted(LORE.glob("*.md")):
|
||||||
|
for d, src in parse_bible(md):
|
||||||
|
eid = d.get("id", "?")
|
||||||
|
ns = str(eid).split(".", 1)[0]
|
||||||
|
row = (eid, d.get("status", "?"), d.get("secrecy", "?"),
|
||||||
|
gist(d.get("body")), src)
|
||||||
|
if ns == "npc":
|
||||||
|
gates = ", ".join(f"{k.get('fact')}@{k.get('gate')}"
|
||||||
|
for k in d.get("knows", []))
|
||||||
|
npcs.append(row + (d.get("start_disposition", "?"), gates))
|
||||||
|
elif ns in KNOWLEDGE_TYPES:
|
||||||
|
knowledge.append(row)
|
||||||
|
else:
|
||||||
|
entities.append(row)
|
||||||
|
|
||||||
|
print("=== CANON ENTITIES (public; body ships client) ===")
|
||||||
|
for eid, st, sec, g, src in sorted(entities):
|
||||||
|
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
|
||||||
|
|
||||||
|
print("\n=== KNOWLEDGE (rumor/fact/secret; body server-only) ===")
|
||||||
|
for eid, st, sec, g, src in sorted(knowledge):
|
||||||
|
print(f" {eid:34} [{st} sec{sec}] {src}\n {g}")
|
||||||
|
|
||||||
|
print("\n=== NPC DIALOGUE LAYERS ===")
|
||||||
|
for eid, st, sec, g, src, start, gates in sorted(npcs):
|
||||||
|
print(f" {eid:34} [{st} start:{start}] {src}\n persona: {g}\n"
|
||||||
|
f" knows: {gates}")
|
||||||
|
|
||||||
|
def list_json(label, d):
|
||||||
|
print(f"\n=== {label} (hand-authored JSON) ===")
|
||||||
|
if not d.is_dir():
|
||||||
|
print(" (none)")
|
||||||
|
return
|
||||||
|
for f in sorted(d.glob("*.json")):
|
||||||
|
try:
|
||||||
|
o = json.loads(f.read_text())
|
||||||
|
name = o.get("name") or o.get("display_name") or ""
|
||||||
|
except (OSError, ValueError):
|
||||||
|
name = "(unreadable)"
|
||||||
|
print(f" {o.get('id', f.stem):28} {name}")
|
||||||
|
|
||||||
|
list_json("QUESTS", WORLD / "quests")
|
||||||
|
list_json("ITEMS", WORLD / "items")
|
||||||
|
list_json("LOCATIONS", WORLD / "locations")
|
||||||
|
list_json("ORIGINS", ORIGINS)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -1,3 +1,9 @@
|
|||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Local scratch — never versioned
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Ad-hoc F6 / smoke-test captures. Kept for the working session, not the repo.
|
||||||
|
/screenshots/
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Godot 4.7 (client/)
|
# Godot 4.7 (client/)
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -60,4 +66,11 @@ Thumbs.db
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Claude Code harness session/state files
|
# Claude Code harness session/state files
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
.claude/
|
# Ignore every .claude/ dir anywhere in the tree...
|
||||||
|
**/.claude/
|
||||||
|
# ...but keep the repo-root .claude/skills/ dir: project-local skills are
|
||||||
|
# checked-in tooling, versioned like code. (Re-include the root dir, drop its
|
||||||
|
# other contents, then re-include skills/.)
|
||||||
|
!/.claude/
|
||||||
|
/.claude/*
|
||||||
|
!/.claude/skills/
|
||||||
|
|||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -494,7 +494,13 @@ quest log, shop, pause/settings, dialogue, combat HUD, main exploration window)
|
|||||||
plus a `README.md` handoff. **Not production code** — each screen is recreated as
|
plus a `README.md` handoff. **Not production code** — each screen is recreated as
|
||||||
a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource
|
a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource
|
||||||
(the README's palette, three fonts, and reusable styleboxes keep the look one
|
(the README's palette, three fonts, and reusable styleboxes keep the look one
|
||||||
system). Each `.dc.html`'s `Component` class is the **behavior spec** — the
|
system). **Author these scenes editor-first:** the layout lives in the `.tscn`
|
||||||
|
as real Control nodes with the `Theme` and type-variations set in the editor, so
|
||||||
|
the scene *previews* and can be arranged in the Godot editor — do **not**
|
||||||
|
build the node tree in `_ready()`. Scripts hold only on-load work: `@onready`
|
||||||
|
node refs, data/DM wiring, animations, and binding state into fixed authored
|
||||||
|
nodes (see ADR [0001](docs/adr/0001-editor-first-ui-scenes.md)). Each
|
||||||
|
`.dc.html`'s `Component` class is the **behavior spec** — the
|
||||||
combat turn manager, dialogue graph, shop economy, and point-buy creation are the
|
combat turn manager, dialogue graph, shop economy, and point-buy creation are the
|
||||||
highest-value logic to port; the seed-data maps (items, quests, locations, units,
|
highest-value logic to port; the seed-data maps (items, quests, locations, units,
|
||||||
abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real
|
abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real
|
||||||
@@ -553,6 +559,12 @@ contracts (§12), and Failure UX (§13).
|
|||||||
- **When a request conflicts with this document, say so.** Do not silently comply. This file is the argument; if it is wrong, change the file first.
|
- **When a request conflicts with this document, say so.** Do not silently comply. This file is the argument; if it is wrong, change the file first.
|
||||||
- **When adding a system, state which side of §2 it falls on.** State or text. If it is both, it is two systems.
|
- **When adding a system, state which side of §2 it falls on.** State or text. If it is both, it is two systems.
|
||||||
- **Prefer deleting scope.** The POC's value is answering one question fast.
|
- **Prefer deleting scope.** The POC's value is answering one question fast.
|
||||||
|
- **A green suite is evidence only if the test can fail.** Before accepting a fix that
|
||||||
|
claims to guard a regression, re-break the code and *watch the new test fail*. This
|
||||||
|
project has shipped a regression test that passed against its own bug, a determinism
|
||||||
|
test that compared two runs that changed together, and an arity change that
|
||||||
|
miscompiled silently past a clean grep. Read **[docs/traps.md](docs/traps.md)** before
|
||||||
|
writing tests — it is six ways this codebase has already fooled a green suite.
|
||||||
|
|
||||||
### Git workflow
|
### Git workflow
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ from .canon_log import validate_canon_log
|
|||||||
from .narrate import run as narrate_run
|
from .narrate import run as narrate_run
|
||||||
from .npc import UnknownNpc, run as npc_run
|
from .npc import UnknownNpc, run as npc_run
|
||||||
from .ollama_client import ModelError
|
from .ollama_client import ModelError
|
||||||
|
from .version import VERSION
|
||||||
|
|
||||||
app = FastAPI(title="coc-rpg proxy", version="0.0.1")
|
app = FastAPI(title="coc-rpg proxy", version=VERSION)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
@@ -65,6 +66,11 @@ def health() -> dict:
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/version")
|
||||||
|
def version() -> dict:
|
||||||
|
return {"version": VERSION}
|
||||||
|
|
||||||
|
|
||||||
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
|
# ── Role endpoints (charter §4) ──────────────────────────────────────────────
|
||||||
# The client knows these paths and nothing about which model or prompt serves
|
# The client knows these paths and nothing about which model or prompt serves
|
||||||
# them. Bodies are validated against the canon log contract. /dm/narrate is
|
# them. Bodies are validated against the canon log contract. /dm/narrate is
|
||||||
|
|||||||
@@ -12,6 +12,29 @@ from pathlib import Path
|
|||||||
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
|
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
|
||||||
|
|
||||||
|
|
||||||
|
def _humanize(id_: str) -> str:
|
||||||
|
"""Ids are snake_case; the model should read prose. hedge_mage -> hedge-mage."""
|
||||||
|
return id_.replace("_", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def _article(word: str) -> str:
|
||||||
|
if not word:
|
||||||
|
return "a"
|
||||||
|
return "an" if word[:1].lower() in "aeiou" else "a"
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_player(player: dict) -> str:
|
||||||
|
"""'Aldric, a beastfolk cutpurse' — what the AI narrates the player as."""
|
||||||
|
race = _humanize(player.get("race_id", ""))
|
||||||
|
calling = _humanize(player.get("calling_id", ""))
|
||||||
|
name = player.get("name", "")
|
||||||
|
parts = [p for p in (race, calling) if p]
|
||||||
|
if not parts:
|
||||||
|
return name
|
||||||
|
descriptor = " ".join(parts)
|
||||||
|
return f"{name}, {_article(parts[0])} {descriptor}".strip()
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
def system_prompt(role: str) -> str:
|
def system_prompt(role: str) -> str:
|
||||||
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
|
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
|
||||||
@@ -50,7 +73,7 @@ def render_digest(canon_log: dict) -> str:
|
|||||||
descriptor = player.get("luck_descriptor")
|
descriptor = player.get("luck_descriptor")
|
||||||
if descriptor:
|
if descriptor:
|
||||||
lines.append(f"Fortune: {descriptor}")
|
lines.append(f"Fortune: {descriptor}")
|
||||||
lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}")
|
lines.append(f"Player: {_describe_player(player)}")
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Describe the scene as it is now.")
|
lines.append("Describe the scene as it is now.")
|
||||||
@@ -104,7 +127,7 @@ def render_npc_digest(
|
|||||||
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
lines.append(f"Active quest: {quest.get('name', '')} — {quest.get('objective', '')}")
|
||||||
|
|
||||||
player = canon_log.get("player", {})
|
player = canon_log.get("player", {})
|
||||||
lines.append(f"The player is {player.get('name', '')}, a {player.get('class_id', '')}.")
|
lines.append(f"The player is {_describe_player(player)}.")
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f'The player says to you: "{utterance}"')
|
lines.append(f'The player says to you: "{utterance}"')
|
||||||
|
|||||||
33
api/app/version.py
Normal file
33
api/app/version.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Single source of truth for the app version (charter §4: client and API share
|
||||||
|
one version). Reads the repo-root /VERSION file — the ONLY place a human edits the
|
||||||
|
version. Mirrors canon_log._schema_dir's env-override + walk-up so it resolves in
|
||||||
|
a local checkout and (once /VERSION is bundled) an image alike.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LAST_RESORT = "0.0.0-dev"
|
||||||
|
|
||||||
|
|
||||||
|
def _version_file() -> Path | None:
|
||||||
|
env = os.environ.get("COC_VERSION_FILE")
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
for parent in here.parents:
|
||||||
|
candidate = parent / "VERSION"
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_version() -> str:
|
||||||
|
path = _version_file()
|
||||||
|
if path is None or not path.is_file():
|
||||||
|
return LAST_RESORT
|
||||||
|
return path.read_text(encoding="utf-8").strip() or LAST_RESORT
|
||||||
|
|
||||||
|
|
||||||
|
VERSION = get_version()
|
||||||
3
api/tests/fixtures/canon_log_valid.json
vendored
3
api/tests/fixtures/canon_log_valid.json
vendored
@@ -2,7 +2,8 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"player": {
|
"player": {
|
||||||
"name": "Aldric",
|
"name": "Aldric",
|
||||||
"class_id": "sellsword",
|
"race_id": "human",
|
||||||
|
"calling_id": "sellsword",
|
||||||
"luck_descriptor": "Fortune spits on you"
|
"luck_descriptor": "Fortune spits on you"
|
||||||
},
|
},
|
||||||
"location": { "id": "greywater_docks", "name": "the Greywater docks" },
|
"location": { "id": "greywater_docks", "name": "the Greywater docks" },
|
||||||
|
|||||||
@@ -68,3 +68,20 @@ def test_negative_turn_is_rejected():
|
|||||||
doc = _valid()
|
doc = _valid()
|
||||||
doc["humiliations"][0]["turn"] = -1
|
doc["humiliations"][0]["turn"] = -1
|
||||||
assert validate_canon_log(doc) != []
|
assert validate_canon_log(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_class_id_field_is_rejected():
|
||||||
|
# The player block migrated from {name, class_id, luck_descriptor} to
|
||||||
|
# {name, race_id, calling_id, luck_descriptor}. class_id must not slip
|
||||||
|
# back in via additionalProperties: false.
|
||||||
|
doc = _valid()
|
||||||
|
doc["player"]["class_id"] = "sellsword"
|
||||||
|
assert validate_canon_log(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_calling_id_is_rejected():
|
||||||
|
# assassin/priest are dead ids from the pre-migration class roster and
|
||||||
|
# must not validate as calling_id values.
|
||||||
|
doc = _valid()
|
||||||
|
doc["player"]["calling_id"] = "priest"
|
||||||
|
assert validate_canon_log(doc) != []
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ def test_live_npc_speak_fenn():
|
|||||||
log = {
|
log = {
|
||||||
"player": {
|
"player": {
|
||||||
"name": "Aldric",
|
"name": "Aldric",
|
||||||
"class_id": "sellsword",
|
"race_id": "human",
|
||||||
|
"calling_id": "sellsword",
|
||||||
"luck_descriptor": "the dice are kind",
|
"luck_descriptor": "the dice are kind",
|
||||||
},
|
},
|
||||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import app.npc as npc
|
|||||||
from app.ollama_client import ModelError
|
from app.ollama_client import ModelError
|
||||||
|
|
||||||
LOG = {
|
LOG = {
|
||||||
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"},
|
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "kind"},
|
||||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||||
"party": [], "recent_events": [], "established_facts": [],
|
"party": [], "recent_events": [], "established_facts": [],
|
||||||
"active_quests": [], "humiliations": [],
|
"active_quests": [], "humiliations": [],
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ VALID_ORIGIN = {
|
|||||||
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
|
"inventory_grants": [{"item_id": "worn_shortsword", "qty": 1}],
|
||||||
"start_quest_id": "find_the_ledger",
|
"start_quest_id": "find_the_ledger",
|
||||||
"build_constraints": {
|
"build_constraints": {
|
||||||
"allowed_classes": ["sellsword", "assassin", "priest"],
|
"allowed_callings": ["sellsword", "cutpurse", "bonesetter"],
|
||||||
"luck_modifier": 0,
|
"luck_modifier": 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -30,9 +30,9 @@ def test_null_start_quest_is_allowed():
|
|||||||
assert validate_origin(doc) == []
|
assert validate_origin(doc) == []
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_class_is_rejected():
|
def test_unknown_calling_is_rejected():
|
||||||
doc = copy.deepcopy(VALID_ORIGIN)
|
doc = copy.deepcopy(VALID_ORIGIN)
|
||||||
doc["build_constraints"]["allowed_classes"] = ["bard"]
|
doc["build_constraints"]["allowed_callings"] = ["bard"]
|
||||||
assert validate_origin(doc) != []
|
assert validate_origin(doc) != []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def test_digest_has_the_narrative_context():
|
|||||||
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
|
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
|
||||||
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
|
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
|
||||||
assert "Fortune: Fortune spits on you" in d
|
assert "Fortune: Fortune spits on you" in d
|
||||||
assert "Player: Aldric, a sellsword" in d
|
assert "Player: Aldric, a human sellsword" in d
|
||||||
assert d.rstrip().endswith("Describe the scene as it is now.")
|
assert d.rstrip().endswith("Describe the scene as it is now.")
|
||||||
|
|
||||||
|
|
||||||
@@ -42,10 +42,47 @@ def test_narrator_body_is_authored():
|
|||||||
assert "second person" in body # the core task is present
|
assert "second person" in body # the core task is present
|
||||||
|
|
||||||
|
|
||||||
|
def _with_player(**player) -> dict:
|
||||||
|
log = json.loads(json.dumps(VALID)) # deep copy — VALID is module-level and shared
|
||||||
|
log["player"] = {"luck_descriptor": "the dice are kind", **player}
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_names_the_race_and_the_calling():
|
||||||
|
out = render_digest(_with_player(name="Aldric", race_id="beastfolk", calling_id="cutpurse"))
|
||||||
|
assert "Aldric, a beastfolk cutpurse" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_says_an_elf_not_a_elf():
|
||||||
|
out = render_digest(_with_player(name="Aldric", race_id="elf", calling_id="sellsword"))
|
||||||
|
assert "an elf sellsword" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_does_not_leak_a_snake_case_id():
|
||||||
|
out = render_digest(_with_player(name="Aldric", race_id="human", calling_id="hedge_mage"))
|
||||||
|
assert "hedge_mage" not in out, "the model must never read a raw snake_case id"
|
||||||
|
assert "hedge-mage" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_omits_race_clause_when_race_is_empty():
|
||||||
|
# Regression: _article("") used to return "an" (Python's "" in "aeiou" is
|
||||||
|
# True), rendering "Player: Aldric, an " — wrong article, doubled space.
|
||||||
|
out = render_digest(_with_player(name="Aldric", race_id="", calling_id="sellsword"))
|
||||||
|
assert "Player: Aldric, a sellsword" in out
|
||||||
|
assert " " not in out
|
||||||
|
assert ", an " not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_digest_omits_both_clauses_when_race_and_calling_are_empty():
|
||||||
|
out = render_digest(_with_player(name="Aldric", race_id="", calling_id=""))
|
||||||
|
assert "\nPlayer: Aldric\n" in out
|
||||||
|
assert " " not in out
|
||||||
|
|
||||||
|
|
||||||
def test_render_npc_digest_includes_all_sections():
|
def test_render_npc_digest_includes_all_sections():
|
||||||
from app import prompts
|
from app import prompts
|
||||||
log = {
|
log = {
|
||||||
"player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "the dice are kind"},
|
"player": {"name": "Aldric", "race_id": "human", "calling_id": "sellsword", "luck_descriptor": "the dice are kind"},
|
||||||
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
"location": {"id": "greywater_docks", "name": "Greywater Docks"},
|
||||||
"party": [], "recent_events": ["a gull screamed"],
|
"party": [], "recent_events": ["a gull screamed"],
|
||||||
"established_facts": ["the eastern bridge is out"],
|
"established_facts": ["the eastern bridge is out"],
|
||||||
|
|||||||
22
api/tests/test_version.py
Normal file
22
api/tests/test_version.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.version import VERSION, get_version
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
ROOT_VERSION = Path(__file__).resolve().parents[2] / "VERSION"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_version_matches_file():
|
||||||
|
assert ROOT_VERSION.is_file()
|
||||||
|
assert get_version() == ROOT_VERSION.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_version_matches():
|
||||||
|
assert app.version == VERSION == get_version()
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_endpoint():
|
||||||
|
assert client.get("/version").json() == {"version": VERSION}
|
||||||
3
clean_superpowers.sh
Executable file
3
clean_superpowers.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/env bash
|
||||||
|
rm .superpowers/sdd/*.diff
|
||||||
|
rm .superpowers/sdd/*.md
|
||||||
BIN
client/assets/theme/fonts/ArchitectsDaughter-Regular.ttf
Normal file
BIN
client/assets/theme/fonts/ArchitectsDaughter-Regular.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="font_data_dynamic"
|
||||||
|
type="FontFile"
|
||||||
|
uid="uid://dgftorjwjkcx6"
|
||||||
|
path="res://.godot/imported/ArchitectsDaughter-Regular.ttf-39fe9a11a65cddcd9ceecf51a839f0c5.fontdata"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
|
||||||
|
dest_files=["res://.godot/imported/ArchitectsDaughter-Regular.ttf-39fe9a11a65cddcd9ceecf51a839f0c5.fontdata"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
Rendering=null
|
||||||
|
antialiasing=1
|
||||||
|
generate_mipmaps=false
|
||||||
|
disable_embedded_bitmaps=true
|
||||||
|
multichannel_signed_distance_field=false
|
||||||
|
msdf_pixel_range=8
|
||||||
|
msdf_size=48
|
||||||
|
allow_system_fallback=true
|
||||||
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
|
hinting=3
|
||||||
|
subpixel_positioning=4
|
||||||
|
keep_rounding_remainders=true
|
||||||
|
oversampling=0.0
|
||||||
|
Fallbacks=null
|
||||||
|
fallbacks=[]
|
||||||
|
Compress=null
|
||||||
|
compress=true
|
||||||
|
preload=[]
|
||||||
|
language_support={}
|
||||||
|
script_support={}
|
||||||
|
opentype_features={}
|
||||||
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="font_data_dynamic"
|
||||||
|
type="FontFile"
|
||||||
|
uid="uid://bosx65t7inqbs"
|
||||||
|
path="res://.godot/imported/EBGaramond-Italic-VariableFont_wght.ttf-8a9f89c45d1261f74ac5e087dfe5ec85.fontdata"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf"
|
||||||
|
dest_files=["res://.godot/imported/EBGaramond-Italic-VariableFont_wght.ttf-8a9f89c45d1261f74ac5e087dfe5ec85.fontdata"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
Rendering=null
|
||||||
|
antialiasing=1
|
||||||
|
generate_mipmaps=false
|
||||||
|
disable_embedded_bitmaps=true
|
||||||
|
multichannel_signed_distance_field=false
|
||||||
|
msdf_pixel_range=8
|
||||||
|
msdf_size=48
|
||||||
|
allow_system_fallback=true
|
||||||
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
|
hinting=3
|
||||||
|
subpixel_positioning=4
|
||||||
|
keep_rounding_remainders=true
|
||||||
|
oversampling=0.0
|
||||||
|
Fallbacks=null
|
||||||
|
fallbacks=[]
|
||||||
|
Compress=null
|
||||||
|
compress=true
|
||||||
|
preload=[]
|
||||||
|
language_support={}
|
||||||
|
script_support={}
|
||||||
|
opentype_features={}
|
||||||
BIN
client/assets/theme/fonts/EBGaramond-VariableFont_wght.ttf
Normal file
BIN
client/assets/theme/fonts/EBGaramond-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="font_data_dynamic"
|
||||||
|
type="FontFile"
|
||||||
|
uid="uid://b00hfuuyxctc6"
|
||||||
|
path="res://.godot/imported/EBGaramond-VariableFont_wght.ttf-32cc8b7204b14062fe5657f6ee3ae93b.fontdata"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
|
||||||
|
dest_files=["res://.godot/imported/EBGaramond-VariableFont_wght.ttf-32cc8b7204b14062fe5657f6ee3ae93b.fontdata"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
Rendering=null
|
||||||
|
antialiasing=1
|
||||||
|
generate_mipmaps=false
|
||||||
|
disable_embedded_bitmaps=true
|
||||||
|
multichannel_signed_distance_field=false
|
||||||
|
msdf_pixel_range=8
|
||||||
|
msdf_size=48
|
||||||
|
allow_system_fallback=true
|
||||||
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
|
hinting=3
|
||||||
|
subpixel_positioning=4
|
||||||
|
keep_rounding_remainders=true
|
||||||
|
oversampling=0.0
|
||||||
|
Fallbacks=null
|
||||||
|
fallbacks=[]
|
||||||
|
Compress=null
|
||||||
|
compress=true
|
||||||
|
preload=[]
|
||||||
|
language_support={}
|
||||||
|
script_support={}
|
||||||
|
opentype_features={}
|
||||||
BIN
client/assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf
Normal file
BIN
client/assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="font_data_dynamic"
|
||||||
|
type="FontFile"
|
||||||
|
uid="uid://1ge2menmepj8"
|
||||||
|
path="res://.godot/imported/JetBrainsMono-VariableFont_wght.ttf-9b8836092a0b124cd7b65994f8da4002.fontdata"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
|
||||||
|
dest_files=["res://.godot/imported/JetBrainsMono-VariableFont_wght.ttf-9b8836092a0b124cd7b65994f8da4002.fontdata"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
Rendering=null
|
||||||
|
antialiasing=1
|
||||||
|
generate_mipmaps=false
|
||||||
|
disable_embedded_bitmaps=true
|
||||||
|
multichannel_signed_distance_field=false
|
||||||
|
msdf_pixel_range=8
|
||||||
|
msdf_size=48
|
||||||
|
allow_system_fallback=true
|
||||||
|
force_autohinter=false
|
||||||
|
modulate_color_glyphs=false
|
||||||
|
hinting=3
|
||||||
|
subpixel_positioning=4
|
||||||
|
keep_rounding_remainders=true
|
||||||
|
oversampling=0.0
|
||||||
|
Fallbacks=null
|
||||||
|
fallbacks=[]
|
||||||
|
Compress=null
|
||||||
|
compress=true
|
||||||
|
preload=[]
|
||||||
|
language_support={}
|
||||||
|
script_support={}
|
||||||
|
opentype_features={}
|
||||||
758
client/assets/theme/game_theme.tres
Normal file
758
client/assets/theme/game_theme.tres
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
[gd_resource type="Theme" format=3]
|
||||||
|
|
||||||
|
[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_605w4"]
|
||||||
|
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="2_4lwr2"]
|
||||||
|
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="3_eckwq"]
|
||||||
|
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" id="4_i0b23"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"]
|
||||||
|
content_margin_left = 10.0
|
||||||
|
content_margin_top = 16.0
|
||||||
|
content_margin_right = 10.0
|
||||||
|
content_margin_bottom = 14.0
|
||||||
|
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||||
|
corner_radius_top_left = 12
|
||||||
|
corner_radius_top_right = 12
|
||||||
|
corner_radius_bottom_right = 12
|
||||||
|
corner_radius_bottom_left = 12
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_q5gpk"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.6039216, 0.56078434, 0.47058824, 0.22)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||||
|
corner_radius_top_left = 3
|
||||||
|
corner_radius_top_right = 3
|
||||||
|
corner_radius_bottom_right = 3
|
||||||
|
corner_radius_bottom_left = 3
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.6039216, 0.56078434, 0.47058824, 0.12)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||||
|
corner_radius_top_left = 3
|
||||||
|
corner_radius_top_right = 3
|
||||||
|
corner_radius_bottom_right = 3
|
||||||
|
corner_radius_bottom_left = 3
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.6039216, 0.56078434, 0.47058824, 0.32)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||||
|
corner_radius_top_left = 3
|
||||||
|
corner_radius_top_right = 3
|
||||||
|
corner_radius_bottom_right = 3
|
||||||
|
corner_radius_bottom_left = 3
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"]
|
||||||
|
content_margin_left = 8.0
|
||||||
|
content_margin_top = 3.0
|
||||||
|
content_margin_right = 8.0
|
||||||
|
content_margin_bottom = 3.0
|
||||||
|
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
corner_radius_top_left = 5
|
||||||
|
corner_radius_top_right = 5
|
||||||
|
corner_radius_bottom_right = 5
|
||||||
|
corner_radius_bottom_left = 5
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.22745098, 0.20784314, 0.18039216, 1)
|
||||||
|
corner_radius_top_left = 4
|
||||||
|
corner_radius_top_right = 4
|
||||||
|
corner_radius_bottom_right = 4
|
||||||
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_4kten"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.05490196, 0.043137256, 0.03529412, 0.85)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.41960785, 0.38039216, 0.32156864, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.039215688, 0.03137255, 0.023529412, 0.9)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||||
|
border_width_left = 2
|
||||||
|
border_width_top = 2
|
||||||
|
border_width_right = 2
|
||||||
|
border_width_bottom = 2
|
||||||
|
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||||
|
corner_radius_top_left = 6
|
||||||
|
corner_radius_top_right = 6
|
||||||
|
corner_radius_bottom_right = 6
|
||||||
|
corner_radius_bottom_left = 6
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||||
|
draw_center = false
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 6
|
||||||
|
corner_radius_top_right = 6
|
||||||
|
corner_radius_bottom_right = 6
|
||||||
|
corner_radius_bottom_left = 6
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_dtr07"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||||
|
corner_radius_top_left = 6
|
||||||
|
corner_radius_top_right = 6
|
||||||
|
corner_radius_bottom_right = 6
|
||||||
|
corner_radius_bottom_left = 6
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 4
|
||||||
|
corner_radius_top_right = 4
|
||||||
|
corner_radius_bottom_right = 4
|
||||||
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.47843137, 0.4392157, 0.3764706, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.47843137, 0.4392157, 0.3764706, 1)
|
||||||
|
corner_radius_top_left = 4
|
||||||
|
corner_radius_top_right = 4
|
||||||
|
corner_radius_bottom_right = 4
|
||||||
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
corner_radius_top_left = 4
|
||||||
|
corner_radius_top_right = 4
|
||||||
|
corner_radius_bottom_right = 4
|
||||||
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_txxuc"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
corner_radius_top_left = 4
|
||||||
|
corner_radius_top_right = 4
|
||||||
|
corner_radius_bottom_right = 4
|
||||||
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.47843137, 0.18431373, 0.16078432, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
corner_radius_top_left = 4
|
||||||
|
corner_radius_top_right = 4
|
||||||
|
corner_radius_bottom_right = 4
|
||||||
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"]
|
||||||
|
content_margin_left = 10.0
|
||||||
|
content_margin_top = 16.0
|
||||||
|
content_margin_right = 10.0
|
||||||
|
content_margin_bottom = 14.0
|
||||||
|
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||||
|
border_width_left = 2
|
||||||
|
border_width_top = 2
|
||||||
|
border_width_right = 2
|
||||||
|
border_width_bottom = 2
|
||||||
|
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||||
|
corner_radius_top_left = 12
|
||||||
|
corner_radius_top_right = 12
|
||||||
|
corner_radius_bottom_right = 12
|
||||||
|
corner_radius_bottom_left = 12
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"]
|
||||||
|
content_margin_left = 9.0
|
||||||
|
content_margin_top = 2.0
|
||||||
|
content_margin_right = 9.0
|
||||||
|
content_margin_bottom = 2.0
|
||||||
|
bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||||
|
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||||
|
corner_radius_top_left = 5
|
||||||
|
corner_radius_top_right = 5
|
||||||
|
corner_radius_bottom_right = 5
|
||||||
|
corner_radius_bottom_left = 5
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wmbj4"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 12
|
||||||
|
corner_radius_top_right = 12
|
||||||
|
corner_radius_bottom_right = 12
|
||||||
|
corner_radius_bottom_left = 12
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_xdylb"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 12
|
||||||
|
corner_radius_top_right = 12
|
||||||
|
corner_radius_bottom_right = 12
|
||||||
|
corner_radius_bottom_left = 12
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||||
|
corner_radius_top_left = 12
|
||||||
|
corner_radius_top_right = 12
|
||||||
|
corner_radius_bottom_right = 12
|
||||||
|
corner_radius_bottom_left = 12
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i2sl5"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||||
|
border_width_left = 3
|
||||||
|
border_width_top = 3
|
||||||
|
border_width_right = 3
|
||||||
|
border_width_bottom = 3
|
||||||
|
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
corner_radius_top_left = 12
|
||||||
|
corner_radius_top_right = 12
|
||||||
|
corner_radius_bottom_right = 12
|
||||||
|
corner_radius_bottom_left = 12
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_onsig"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_gw0t7"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_88l5g"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_f8qac"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_u6gcs"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nufy3"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_0d7vs"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_3fnfk"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2txdw"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_w7qkp"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.9372549, 0.9019608, 0.8117647, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.79607844, 0.7137255, 0.5176471, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rthnr"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.7490196, 0.65882355, 0.47058824, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ta3oh"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ohwwq"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.72156864, 0.32156864, 0.2901961, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ismtf"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gs634"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.47843137, 0.18431373, 0.16078432, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.56078434, 0.22745098, 0.20392157, 1)
|
||||||
|
corner_radius_top_left = 14
|
||||||
|
corner_radius_top_right = 14
|
||||||
|
corner_radius_bottom_right = 14
|
||||||
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
default_font = ExtResource("2_4lwr2")
|
||||||
|
default_font_size = 18
|
||||||
|
AbilityCard/base_type = &"PanelContainer"
|
||||||
|
AbilityCard/styles/panel = SubResource("StyleBoxFlat_cqbvn")
|
||||||
|
Accent/base_type = &"Label"
|
||||||
|
Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||||
|
Accent/font_sizes/font_size = 24
|
||||||
|
Accent/fonts/font = ExtResource("1_605w4")
|
||||||
|
CardBody/base_type = &"Label"
|
||||||
|
CardBody/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||||
|
CardBody/font_sizes/font_size = 14
|
||||||
|
CardBody/fonts/font = ExtResource("2_4lwr2")
|
||||||
|
CardTitle/base_type = &"Label"
|
||||||
|
CardTitle/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
|
||||||
|
CardTitle/font_sizes/font_size = 20
|
||||||
|
CardTitle/fonts/font = ExtResource("2_4lwr2")
|
||||||
|
Chip/base_type = &"Button"
|
||||||
|
Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||||
|
Chip/font_sizes/font_size = 12
|
||||||
|
Chip/fonts/font = ExtResource("3_eckwq")
|
||||||
|
Chip/styles/focus = SubResource("StyleBoxEmpty_q5gpk")
|
||||||
|
Chip/styles/hover = SubResource("StyleBoxFlat_bbpmc")
|
||||||
|
Chip/styles/normal = SubResource("StyleBoxFlat_73lme")
|
||||||
|
Chip/styles/pressed = SubResource("StyleBoxFlat_js3tr")
|
||||||
|
ChosenTag/base_type = &"Label"
|
||||||
|
ChosenTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||||
|
ChosenTag/font_sizes/font_size = 11
|
||||||
|
ChosenTag/fonts/font = ExtResource("3_eckwq")
|
||||||
|
ChosenTag/styles/normal = SubResource("StyleBoxFlat_ube3r")
|
||||||
|
DarkPanel/base_type = &"PanelContainer"
|
||||||
|
DarkPanel/styles/panel = SubResource("StyleBoxFlat_2tooq")
|
||||||
|
DockButton/base_type = &"Button"
|
||||||
|
DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1)
|
||||||
|
DockButton/styles/focus = SubResource("StyleBoxEmpty_4kten")
|
||||||
|
DockButton/styles/hover = SubResource("StyleBoxFlat_p6wtm")
|
||||||
|
DockButton/styles/normal = SubResource("StyleBoxFlat_0fjdt")
|
||||||
|
DockButton/styles/pressed = SubResource("StyleBoxFlat_ns3kg")
|
||||||
|
Heading/base_type = &"Label"
|
||||||
|
Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
|
||||||
|
Heading/font_sizes/font_size = 34
|
||||||
|
Heading/fonts/font = ExtResource("2_4lwr2")
|
||||||
|
ItemTile/base_type = &"PanelContainer"
|
||||||
|
ItemTile/styles/panel = SubResource("StyleBoxFlat_jly1r")
|
||||||
|
ItemTileEmpty/base_type = &"PanelContainer"
|
||||||
|
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_lw8lk")
|
||||||
|
Mono/base_type = &"Label"
|
||||||
|
Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||||
|
Mono/font_sizes/font_size = 14
|
||||||
|
Mono/fonts/font = ExtResource("3_eckwq")
|
||||||
|
ParchmentButton/base_type = &"Button"
|
||||||
|
ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
|
||||||
|
ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||||
|
ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_nh0e7")
|
||||||
|
ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_dtr07")
|
||||||
|
ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nmowx")
|
||||||
|
ParchmentButton/styles/normal = SubResource("StyleBoxFlat_oi067")
|
||||||
|
ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_ukblj")
|
||||||
|
ParchmentCard/base_type = &"PanelContainer"
|
||||||
|
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_utt73")
|
||||||
|
ParchmentInset/base_type = &"PanelContainer"
|
||||||
|
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_5u8fh")
|
||||||
|
PrimaryCTA/base_type = &"Button"
|
||||||
|
PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||||
|
PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||||
|
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_p0qbh")
|
||||||
|
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_6y6ak")
|
||||||
|
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_txxuc")
|
||||||
|
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_7d6ug")
|
||||||
|
PrimaryCard/base_type = &"PanelContainer"
|
||||||
|
PrimaryCard/styles/panel = SubResource("StyleBoxFlat_najyk")
|
||||||
|
PrimaryTag/base_type = &"Label"
|
||||||
|
PrimaryTag/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||||
|
PrimaryTag/font_sizes/font_size = 10
|
||||||
|
PrimaryTag/fonts/font = ExtResource("3_eckwq")
|
||||||
|
PrimaryTag/styles/normal = SubResource("StyleBoxFlat_jtb1k")
|
||||||
|
RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
|
||||||
|
RichTextLabel/font_sizes/normal_font_size = 22
|
||||||
|
RichTextLabel/fonts/italics_font = ExtResource("4_i0b23")
|
||||||
|
RichTextLabel/fonts/normal_font = ExtResource("2_4lwr2")
|
||||||
|
SectionLabel/base_type = &"Label"
|
||||||
|
SectionLabel/colors/font_color = Color(0.5411765, 0.46666667, 0.28235295, 1)
|
||||||
|
SectionLabel/font_sizes/font_size = 14
|
||||||
|
SectionLabel/fonts/font = ExtResource("3_eckwq")
|
||||||
|
SelectCard/base_type = &"Button"
|
||||||
|
SelectCard/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
|
||||||
|
SelectCard/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||||
|
SelectCard/styles/disabled = SubResource("StyleBoxFlat_wmbj4")
|
||||||
|
SelectCard/styles/focus = SubResource("StyleBoxEmpty_xdylb")
|
||||||
|
SelectCard/styles/hover = SubResource("StyleBoxFlat_pjnll")
|
||||||
|
SelectCard/styles/normal = SubResource("StyleBoxFlat_klelq")
|
||||||
|
SelectCard/styles/pressed = SubResource("StyleBoxFlat_i2sl5")
|
||||||
|
SkillChip/base_type = &"Button"
|
||||||
|
SkillChip/colors/font_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||||
|
SkillChip/colors/font_disabled_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
SkillChip/colors/font_hover_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||||
|
SkillChip/colors/font_pressed_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||||
|
SkillChip/font_sizes/font_size = 14
|
||||||
|
SkillChip/fonts/font = ExtResource("3_eckwq")
|
||||||
|
SkillChip/styles/disabled = SubResource("StyleBoxFlat_onsig")
|
||||||
|
SkillChip/styles/focus = SubResource("StyleBoxEmpty_gw0t7")
|
||||||
|
SkillChip/styles/hover = SubResource("StyleBoxFlat_88l5g")
|
||||||
|
SkillChip/styles/normal = SubResource("StyleBoxFlat_f8qac")
|
||||||
|
SkillChip/styles/pressed = SubResource("StyleBoxFlat_u6gcs")
|
||||||
|
SkillChipGranted/base_type = &"Button"
|
||||||
|
SkillChipGranted/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||||
|
SkillChipGranted/colors/font_disabled_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||||
|
SkillChipGranted/font_sizes/font_size = 14
|
||||||
|
SkillChipGranted/fonts/font = ExtResource("3_eckwq")
|
||||||
|
SkillChipGranted/styles/disabled = SubResource("StyleBoxFlat_nufy3")
|
||||||
|
SkillChipGranted/styles/focus = SubResource("StyleBoxEmpty_0d7vs")
|
||||||
|
SkillChipGranted/styles/hover = SubResource("StyleBoxFlat_nufy3")
|
||||||
|
SkillChipGranted/styles/normal = SubResource("StyleBoxFlat_nufy3")
|
||||||
|
SkillChipGranted/styles/pressed = SubResource("StyleBoxFlat_nufy3")
|
||||||
|
Tab/base_type = &"Button"
|
||||||
|
Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||||
|
Tab/styles/focus = SubResource("StyleBoxEmpty_3fnfk")
|
||||||
|
Tab/styles/hover = SubResource("StyleBoxFlat_2txdw")
|
||||||
|
Tab/styles/normal = SubResource("StyleBoxFlat_w7qkp")
|
||||||
|
Tab/styles/pressed = SubResource("StyleBoxFlat_rthnr")
|
||||||
|
TabActive/base_type = &"Button"
|
||||||
|
TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||||
|
TabActive/styles/focus = SubResource("StyleBoxEmpty_ta3oh")
|
||||||
|
TabActive/styles/hover = SubResource("StyleBoxFlat_ohwwq")
|
||||||
|
TabActive/styles/normal = SubResource("StyleBoxFlat_ismtf")
|
||||||
|
TabActive/styles/pressed = SubResource("StyleBoxFlat_gs634")
|
||||||
|
TitleKicker/base_type = &"Label"
|
||||||
|
TitleKicker/colors/font_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
|
||||||
|
TitleKicker/font_sizes/font_size = 13
|
||||||
|
TitleKicker/fonts/font = ExtResource("3_eckwq")
|
||||||
|
TitleLogo/base_type = &"Label"
|
||||||
|
TitleLogo/colors/font_color = Color(0.9098039, 0.8666667, 0.78431374, 1)
|
||||||
|
TitleLogo/font_sizes/font_size = 116
|
||||||
|
TitleLogo/fonts/font = ExtResource("2_4lwr2")
|
||||||
21
client/assets/theme/shaders/dark_bay.gdshader
Normal file
21
client/assets/theme/shaders/dark_bay.gdshader
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
shader_type canvas_item;
|
||||||
|
uniform vec4 stripe_a : source_color;
|
||||||
|
uniform vec4 stripe_b : source_color;
|
||||||
|
uniform float band_px = 16.0; // 16px bands per README
|
||||||
|
uniform float vignette = 0.55; // edge darkening strength
|
||||||
|
void fragment() {
|
||||||
|
// Coordinate spaces differ on purpose: the stripe uses FRAGCOORD (screen-space,
|
||||||
|
// not node-local), while the vignette below uses UV (node-local, 0..1 across
|
||||||
|
// this node's rect). An offset DarkBay instance therefore keeps its stripes
|
||||||
|
// anchored to the screen rather than restarting at the node's own origin —
|
||||||
|
// intended for full-bleed use.
|
||||||
|
// 135deg diagonal stripe: sum of pixel coords, banded.
|
||||||
|
vec2 px = FRAGCOORD.xy;
|
||||||
|
float diag = px.x + px.y;
|
||||||
|
float band = mod(floor(diag / band_px), 2.0);
|
||||||
|
vec4 base = mix(stripe_a, stripe_b, band);
|
||||||
|
// radial vignette centred slightly high (README: 50% 38%).
|
||||||
|
float d = distance(UV, vec2(0.5, 0.38));
|
||||||
|
base.rgb *= 1.0 - clamp(d * vignette, 0.0, 1.0);
|
||||||
|
COLOR = base;
|
||||||
|
}
|
||||||
1
client/assets/theme/shaders/dark_bay.gdshader.uid
Normal file
1
client/assets/theme/shaders/dark_bay.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://jb8clyworwr1
|
||||||
6
client/assets/theme/shaders/parchment.gdshader
Normal file
6
client/assets/theme/shaders/parchment.gdshader
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
shader_type canvas_item;
|
||||||
|
uniform vec4 top : source_color;
|
||||||
|
uniform vec4 bottom : source_color;
|
||||||
|
void fragment() {
|
||||||
|
COLOR = mix(top, bottom, UV.y);
|
||||||
|
}
|
||||||
1
client/assets/theme/shaders/parchment.gdshader.uid
Normal file
1
client/assets/theme/shaders/parchment.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b2heb6jhvwxxl
|
||||||
15
client/assets/theme/shaders/title_background.gdshader
Normal file
15
client/assets/theme/shaders/title_background.gdshader
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
shader_type canvas_item;
|
||||||
|
// Warm radial for the Title screen (mock: radial-gradient warm centre -> near
|
||||||
|
// black edge, centred at 70% 20%). Colours are Palette uniforms set by the title
|
||||||
|
// script (the DarkBay precedent), so no hex lives in the shader.
|
||||||
|
uniform vec4 warm : source_color;
|
||||||
|
uniform vec4 mid : source_color;
|
||||||
|
uniform vec4 edge : source_color;
|
||||||
|
uniform vec2 center = vec2(0.7, 0.2);
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
float d = distance(UV, center);
|
||||||
|
vec3 c = mix(warm.rgb, mid.rgb, clamp(d / 0.45, 0.0, 1.0));
|
||||||
|
c = mix(c, edge.rgb, clamp((d - 0.45) / 0.55, 0.0, 1.0));
|
||||||
|
COLOR = vec4(c, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cfrs0nt60f2mb
|
||||||
6
client/assets/theme/shaders/vignette.gdshader
Normal file
6
client/assets/theme/shaders/vignette.gdshader
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
shader_type canvas_item;
|
||||||
|
uniform float strength = 0.62;
|
||||||
|
void fragment() {
|
||||||
|
float d = distance(UV, vec2(0.5));
|
||||||
|
COLOR = vec4(0.0, 0.0, 0.0, clamp(d * strength, 0.0, 1.0));
|
||||||
|
}
|
||||||
1
client/assets/theme/shaders/vignette.gdshader.uid
Normal file
1
client/assets/theme/shaders/vignette.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b4tami4peyxaw
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
"Somewhere, dice are gathered.",
|
"Somewhere, dice are gathered.",
|
||||||
"The tale weighs its next turn.",
|
"The tale weighs its next turn.",
|
||||||
"The dark shifts, thinking.",
|
"The dark shifts, thinking.",
|
||||||
"A page is turned, unhurried."
|
"A page is turned, unhurried.",
|
||||||
|
"The fabric of time is being rewoven.",
|
||||||
|
"The threads of fate are being rearranged.",
|
||||||
|
"Time slows its turning.",
|
||||||
|
"Gods whisper in the darkness.",
|
||||||
|
"Compiling the chronicle of your actions."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,57 @@ Put here:
|
|||||||
|
|
||||||
Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here.
|
Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here.
|
||||||
|
|
||||||
|
## UI scene conventions (shell + all screens)
|
||||||
|
|
||||||
|
Screens are **authored editor-first** — the layout lives in the `.tscn`, not in
|
||||||
|
code. See ADR [0001](../../docs/adr/0001-editor-first-ui-scenes.md) for the why;
|
||||||
|
this is the how. Reference: `scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn`
|
||||||
|
+ `scripts/ui/shell/*.gd`, and `scenes/creation/CharacterCreation.tscn` +
|
||||||
|
`scripts/ui/creation/{character_creation,creation_draft,creation_copy}.gd` (M4-b).
|
||||||
|
|
||||||
|
- **Clickable cards are Buttons with `mouse_filter = 2` on every child.** A card that
|
||||||
|
holds a name, a blurb and a trait line is a `Button` (Button-base variation, so
|
||||||
|
`normal`/`hover`/`pressed` actually skin it) with a `VBoxContainer` of Labels inside.
|
||||||
|
Every child needs `mouse_filter = 2` (IGNORE) or the Label swallows the click and the
|
||||||
|
card never toggles. `pressed` on a `toggle_mode` Button *is* the "chosen" state — no
|
||||||
|
script-side stylebox swapping.
|
||||||
|
|
||||||
|
- **The `.tscn` owns the tree.** Author every node in the editor: containers,
|
||||||
|
panels, labels, buttons, art-slot placeholders. Assign the shared theme on the
|
||||||
|
scene root (`theme = game_theme.tres`) and set each node's `theme_type_variation`
|
||||||
|
(from `ThemeKeys`) in the editor, so the scene previews themed. Instance
|
||||||
|
sub-screens (`PackedScene` ext_resource) rather than rebuilding them.
|
||||||
|
- **The script does on-load work only.** `@onready var x := $Path/To/Node` refs,
|
||||||
|
signal/service wiring, animations, and binding state into the authored nodes
|
||||||
|
(label text, colours from `Palette`). Do **not** create nodes in `_ready()`.
|
||||||
|
- **Data-driven repeats = fixed authored slots, bound at runtime.** Author enough
|
||||||
|
turn-order tokens / consumable slots / etc. in the editor; the script fills the
|
||||||
|
first N from state and hides the rest. Previewable now; systems write them later.
|
||||||
|
- **Scene-root sizing.** Give the root a fixed size via offsets (e.g. 1920×1080)
|
||||||
|
for editor preview, but **no full-rect anchors on the root** — a root Control run
|
||||||
|
via F6 collapses to (0,0) under full-rect anchors. Keep `size = get_viewport_rect().size`
|
||||||
|
on `_ready` + a `size_changed` connection (the M3-a collapse lesson).
|
||||||
|
- **Button styling → Button-base variations only.** Never put a PanelContainer
|
||||||
|
variation (`DarkPanel`, `ParchmentCard`) on a `Button`; it has no `normal/hover/
|
||||||
|
pressed` styleboxes so the button renders unskinned. Use the Button-base
|
||||||
|
variations (`PrimaryCTA`, `Tab`, `TabActive`, `Chip`, `DockButton`, `ParchmentButton`).
|
||||||
|
- **`RichTextLabel` prose** reads its dark serif ink from the theme's base
|
||||||
|
`RichTextLabel` style (it does *not* inherit `default_font`/`default_color`).
|
||||||
|
Just add the `RichTextLabel` — don't override its colour per-node.
|
||||||
|
- **Theme is generated.** All colours live in `palette.gd`; `game_theme.tres` is
|
||||||
|
built by `scripts/theme/build_game_theme.gd` (source of truth). To add/adjust a
|
||||||
|
variation, edit the builder + `theme_keys.gd`, then regenerate:
|
||||||
|
`godot --headless -s res://scripts/theme/build_game_theme.gd`. Never hand-edit
|
||||||
|
the `.tres` or put a hex literal in a script or `.tscn`.
|
||||||
|
- **Wiring the DM loop / injectable services.** Follow the shell: a `service`
|
||||||
|
var settable *between* `instantiate()` and `add_child()` lets tests inject a
|
||||||
|
`FakeTransport`-backed `DmService` so on-load calls stay hermetic; `_ready`
|
||||||
|
builds the real `HttpTransport`-backed one when none was injected.
|
||||||
|
- **Visual gate.** Headless GUT proves structure (nodes mount, sizes non-zero,
|
||||||
|
bindings run); it cannot prove rendering. Every screen needs a **human F6
|
||||||
|
eyeball** before it's done. Note shader surfaces (`DarkBay`, vignette) render
|
||||||
|
their texture only on an F6 run, not in the editor.
|
||||||
|
|
||||||
## Narrate live smoke (M2 client HTTP loop)
|
## Narrate live smoke (M2 client HTTP loop)
|
||||||
|
|
||||||
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
|
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
|
||||||
|
|||||||
@@ -12,4 +12,14 @@ config_version=5
|
|||||||
|
|
||||||
config/name="coc-rpg"
|
config/name="coc-rpg"
|
||||||
config/description="AI-driven single-player party RPG. Code owns state. AI owns text."
|
config/description="AI-driven single-player party RPG. Code owns state. AI owns text."
|
||||||
|
config/version="0.01-alpha"
|
||||||
|
run/main_scene="res://scenes/Main.tscn"
|
||||||
config/features=PackedStringArray("4.7")
|
config/features=PackedStringArray("4.7")
|
||||||
|
|
||||||
|
[display]
|
||||||
|
|
||||||
|
window/size/viewport_width=1920
|
||||||
|
window/size/viewport_height=1080
|
||||||
|
window/size/window_width_override=1280
|
||||||
|
window/size/window_height_override=720
|
||||||
|
window/stretch/mode="canvas_items"
|
||||||
|
|||||||
6
client/scenes/Main.tscn
Normal file
6
client/scenes/Main.tscn
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/main.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="Main" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
1249
client/scenes/creation/CharacterCreation.tscn
Normal file
1249
client/scenes/creation/CharacterCreation.tscn
Normal file
File diff suppressed because it is too large
Load Diff
300
client/scenes/shell/MainWindowShell.tscn
Normal file
300
client/scenes/shell/MainWindowShell.tscn
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
[gd_scene load_steps=6 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.gd" id="1"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/DarkBay.tscn" id="3"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/shell/SystemDock.tscn" id="4"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/shell/NarrationBook.tscn" id="5"]
|
||||||
|
|
||||||
|
[node name="MainWindowShell" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_right = 1920.0
|
||||||
|
offset_bottom = 1080.0
|
||||||
|
theme = ExtResource("2")
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="Split" type="HBoxContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
theme_override_constants/separation = 0
|
||||||
|
|
||||||
|
[node name="World" type="Control" parent="Split"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
clip_contents = true
|
||||||
|
|
||||||
|
[node name="Bay" parent="Split/World" instance=ExtResource("3")]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Split/World"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 8
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "ISOMETRIC WORLD VIEW"
|
||||||
|
|
||||||
|
[node name="TurnRail" type="PanelContainer" parent="Split/World"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_left = 28.0
|
||||||
|
offset_top = 28.0
|
||||||
|
offset_right = 300.0
|
||||||
|
offset_bottom = 128.0
|
||||||
|
theme_type_variation = &"DarkPanel"
|
||||||
|
|
||||||
|
[node name="Col" type="VBoxContainer" parent="Split/World/TurnRail"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="Split/World/TurnRail/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "TURN ORDER"
|
||||||
|
|
||||||
|
[node name="Tokens" type="HBoxContainer" parent="Split/World/TurnRail/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="T0" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "EL"
|
||||||
|
|
||||||
|
[node name="T1" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "DW"
|
||||||
|
|
||||||
|
[node name="T2" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "HU"
|
||||||
|
|
||||||
|
[node name="T3" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "✕"
|
||||||
|
|
||||||
|
[node name="T4" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "—"
|
||||||
|
|
||||||
|
[node name="Minimap" type="PanelContainer" parent="Split/World"]
|
||||||
|
custom_minimum_size = Vector2(200, 200)
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 1
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
offset_left = -228.0
|
||||||
|
offset_top = 28.0
|
||||||
|
offset_right = -28.0
|
||||||
|
offset_bottom = 228.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
theme_type_variation = &"DarkPanel"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/Minimap"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 1
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "MINIMAP"
|
||||||
|
|
||||||
|
[node name="SystemDock" parent="Split/World" instance=ExtResource("4")]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 6
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -228.0
|
||||||
|
offset_top = -210.0
|
||||||
|
offset_right = -8.0
|
||||||
|
offset_bottom = 210.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
|
[node name="CommandBar" type="HBoxContainer" parent="Split/World"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 12
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_left = 28.0
|
||||||
|
offset_top = -150.0
|
||||||
|
offset_right = -28.0
|
||||||
|
grow_vertical = 0
|
||||||
|
theme_override_constants/separation = 20
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="Vitals" type="VBoxContainer" parent="Split/World/CommandBar"]
|
||||||
|
custom_minimum_size = Vector2(120, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 8
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="HP" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "HP 42 / 60"
|
||||||
|
|
||||||
|
[node name="MP" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "MP 18 / 30"
|
||||||
|
|
||||||
|
[node name="Purse" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "◈ 3s 47c"
|
||||||
|
|
||||||
|
[node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 8
|
||||||
|
theme_override_constants/separation = 9
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="Slot1" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot1"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Salve"
|
||||||
|
|
||||||
|
[node name="Slot2" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot2"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Draught"
|
||||||
|
|
||||||
|
[node name="Slot3" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot3"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Bomb"
|
||||||
|
|
||||||
|
[node name="Slot4" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot4"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Antidote"
|
||||||
|
|
||||||
|
[node name="Slot5" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot5"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Ration"
|
||||||
|
|
||||||
|
[node name="Slot6" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot6"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Torch"
|
||||||
|
|
||||||
|
[node name="Slot7" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTileEmpty"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot7"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
|
||||||
|
[node name="Slot8" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTileEmpty"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot8"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
|
||||||
|
[node name="EndTurn" type="Button" parent="Split/World/CommandBar"]
|
||||||
|
custom_minimum_size = Vector2(140, 60)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"PrimaryCTA"
|
||||||
|
text = "END TURN"
|
||||||
|
|
||||||
|
[node name="BookPanel" type="PanelContainer" parent="Split"]
|
||||||
|
custom_minimum_size = Vector2(740, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"ParchmentCard"
|
||||||
|
|
||||||
|
[node name="Book" parent="Split/BookPanel" instance=ExtResource("5")]
|
||||||
|
layout_mode = 2
|
||||||
85
client/scenes/shell/NarrationBook.tscn
Normal file
85
client/scenes/shell/NarrationBook.tscn
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||||
|
|
||||||
|
[node name="NarrationBook" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_right = 740.0
|
||||||
|
offset_bottom = 1080.0
|
||||||
|
theme = ExtResource("2")
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="Margin" type="MarginContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
theme_override_constants/margin_left = 40
|
||||||
|
theme_override_constants/margin_top = 34
|
||||||
|
theme_override_constants/margin_right = 40
|
||||||
|
theme_override_constants/margin_bottom = 34
|
||||||
|
|
||||||
|
[node name="Col" type="VBoxContainer" parent="Margin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 18
|
||||||
|
|
||||||
|
[node name="Header" type="Label" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "ROUND 4 · THE LOWER WARD"
|
||||||
|
|
||||||
|
[node name="Prose" type="RichTextLabel" parent="Margin/Col"]
|
||||||
|
custom_minimum_size = Vector2(660, 320)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
bbcode_enabled = true
|
||||||
|
text = "The alley stinks of piss and rot. Rain beads on the cobbles and runs black into the gutter."
|
||||||
|
fit_content = true
|
||||||
|
|
||||||
|
[node name="DegradedTag" type="Label" parent="Margin/Col"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "THE MASTER'S VOICE COMES THIN"
|
||||||
|
|
||||||
|
[node name="Prompt" type="Label" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "▸ Your turn. What do you do?"
|
||||||
|
|
||||||
|
[node name="Choice1" type="Button" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
disabled = true
|
||||||
|
theme_type_variation = &"ParchmentButton"
|
||||||
|
text = "1 · Pay the fence double and keep your hand where he can see it"
|
||||||
|
alignment = 0
|
||||||
|
|
||||||
|
[node name="Choice2" type="Button" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
disabled = true
|
||||||
|
theme_type_variation = &"ParchmentButton"
|
||||||
|
text = "2 · Draw your blade — he's closer than his guards"
|
||||||
|
alignment = 0
|
||||||
|
|
||||||
|
[node name="Choice3" type="Button" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
disabled = true
|
||||||
|
theme_type_variation = &"ParchmentButton"
|
||||||
|
text = "3 · Lie — tell him the rest is coming with your dwarf"
|
||||||
|
alignment = 0
|
||||||
|
|
||||||
|
[node name="FreeText" type="Label" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "⌨ …or describe your own action"
|
||||||
|
|
||||||
|
[node name="Refire" type="Button" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"ParchmentButton"
|
||||||
|
text = "▸ The Master continues…"
|
||||||
|
|
||||||
|
[node name="Status" type="Label" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
86
client/scenes/shell/SystemDock.tscn
Normal file
86
client/scenes/shell/SystemDock.tscn
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||||
|
|
||||||
|
[node name="SystemDock" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_right = 220.0
|
||||||
|
offset_bottom = 420.0
|
||||||
|
theme = ExtResource("2")
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="Menu" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
offset_left = -216.0
|
||||||
|
offset_right = -36.0
|
||||||
|
offset_bottom = 316.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Inventory" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "I Inventory"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"inventory"
|
||||||
|
|
||||||
|
[node name="Character" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "C Character"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"character"
|
||||||
|
|
||||||
|
[node name="QuestLog" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "J Quest Log"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"quest_log"
|
||||||
|
|
||||||
|
[node name="Spellbook" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "K Spellbook"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"spellbook"
|
||||||
|
|
||||||
|
[node name="WorldMap" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "M World Map"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"world_map"
|
||||||
|
|
||||||
|
[node name="Party" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "P Party"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"party"
|
||||||
|
|
||||||
|
[node name="Handle" type="Button" parent="."]
|
||||||
|
custom_minimum_size = Vector2(26, 104)
|
||||||
|
layout_mode = 1
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -26.0
|
||||||
|
offset_top = -52.0
|
||||||
|
offset_right = 0.0
|
||||||
|
offset_bottom = 52.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_type_variation = &"TabActive"
|
||||||
|
text = "›"
|
||||||
5
client/scenes/theme/surfaces/DarkBay.tscn
Normal file
5
client/scenes/theme/surfaces/DarkBay.tscn
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Script" path="res://scripts/theme/surfaces/dark_bay.gd" id="1"]
|
||||||
|
[node name="DarkBay" type="Panel"]
|
||||||
|
anchors_preset = 15
|
||||||
|
script = ExtResource("1")
|
||||||
5
client/scenes/theme/surfaces/ParchmentPanel.tscn
Normal file
5
client/scenes/theme/surfaces/ParchmentPanel.tscn
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Script" path="res://scripts/theme/surfaces/parchment_panel.gd" id="1"]
|
||||||
|
[node name="ParchmentPanel" type="Panel"]
|
||||||
|
anchors_preset = 15
|
||||||
|
script = ExtResource("1")
|
||||||
5
client/scenes/theme/surfaces/VignetteOverlay.tscn
Normal file
5
client/scenes/theme/surfaces/VignetteOverlay.tscn
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Script" path="res://scripts/theme/surfaces/vignette_overlay.gd" id="1"]
|
||||||
|
[node name="VignetteOverlay" type="ColorRect"]
|
||||||
|
anchors_preset = 15
|
||||||
|
script = ExtResource("1")
|
||||||
5
client/scenes/theme/theme_showcase.tscn
Normal file
5
client/scenes/theme/theme_showcase.tscn
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
[ext_resource type="Script" path="res://scripts/theme/theme_showcase.gd" id="1"]
|
||||||
|
[node name="ThemeShowcase" type="Control"]
|
||||||
|
anchors_preset = 15
|
||||||
|
script = ExtResource("1")
|
||||||
243
client/scenes/title/TitleScreen.tscn
Normal file
243
client/scenes/title/TitleScreen.tscn
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
[gd_scene load_steps=4 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/title/title_screen.gd" id="1"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/VignetteOverlay.tscn" id="3"]
|
||||||
|
|
||||||
|
[node name="TitleScreen" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_right = 1920.0
|
||||||
|
offset_bottom = 1080.0
|
||||||
|
theme = ExtResource("2")
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="Background" type="ColorRect" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
|
||||||
|
[node name="Glow" type="ColorRect" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 12
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_top = -420.0
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Embers" type="GPUParticles2D" parent="."]
|
||||||
|
position = Vector2(960, 1080)
|
||||||
|
amount = 24
|
||||||
|
lifetime = 9.0
|
||||||
|
preprocess = 4.0
|
||||||
|
|
||||||
|
[node name="KeyArt" type="Label" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 6
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -520.0
|
||||||
|
offset_top = -20.0
|
||||||
|
offset_right = -120.0
|
||||||
|
offset_bottom = 20.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "KEY ART DROPS IN HERE"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="Vignette" parent="." instance=ExtResource("3")]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
mouse_filter = 2
|
||||||
|
|
||||||
|
[node name="Content" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 4
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = 120.0
|
||||||
|
offset_top = -260.0
|
||||||
|
offset_right = 900.0
|
||||||
|
offset_bottom = 260.0
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Kicker" type="Label" parent="Content"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"TitleKicker"
|
||||||
|
text = "A DARK FANTASY RPG"
|
||||||
|
|
||||||
|
[node name="Logo" type="Label" parent="Content"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"TitleLogo"
|
||||||
|
text = "CODE OF
|
||||||
|
CONQUEST"
|
||||||
|
|
||||||
|
[node name="Rule" type="ColorRect" parent="Content"]
|
||||||
|
custom_minimum_size = Vector2(220, 2)
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Tagline" type="Label" parent="Content"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "The world has real problems, and it does not care about you."
|
||||||
|
|
||||||
|
[node name="Menu" type="VBoxContainer" parent="Content"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 5
|
||||||
|
|
||||||
|
[node name="NewGame" type="HBoxContainer" parent="Content/Menu"]
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 0
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
metadata/action = &"new_game"
|
||||||
|
|
||||||
|
[node name="Marker" type="Label" parent="Content/Menu/NewGame"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
text = "◆"
|
||||||
|
|
||||||
|
[node name="Body" type="VBoxContainer" parent="Content/Menu/NewGame"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Content/Menu/NewGame/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 30
|
||||||
|
text = "New Game"
|
||||||
|
|
||||||
|
[node name="Continue" type="HBoxContainer" parent="Content/Menu"]
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 0
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
metadata/action = &"continue"
|
||||||
|
|
||||||
|
[node name="Marker" type="Label" parent="Content/Menu/Continue"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
text = "◆"
|
||||||
|
|
||||||
|
[node name="Body" type="VBoxContainer" parent="Content/Menu/Continue"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Content/Menu/Continue/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 30
|
||||||
|
text = "Continue"
|
||||||
|
|
||||||
|
[node name="Sub" type="Label" parent="Content/Menu/Continue/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "CHAPTER II · VEXCCA · 14:22:07"
|
||||||
|
|
||||||
|
[node name="LoadGame" type="HBoxContainer" parent="Content/Menu"]
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 0
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
metadata/action = &"load_game"
|
||||||
|
|
||||||
|
[node name="Marker" type="Label" parent="Content/Menu/LoadGame"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
text = "◆"
|
||||||
|
|
||||||
|
[node name="Body" type="VBoxContainer" parent="Content/Menu/LoadGame"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Content/Menu/LoadGame/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 30
|
||||||
|
text = "Load Game"
|
||||||
|
|
||||||
|
[node name="Settings" type="HBoxContainer" parent="Content/Menu"]
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 0
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
metadata/action = &"settings"
|
||||||
|
|
||||||
|
[node name="Marker" type="Label" parent="Content/Menu/Settings"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
text = "◆"
|
||||||
|
|
||||||
|
[node name="Body" type="VBoxContainer" parent="Content/Menu/Settings"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Content/Menu/Settings/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 30
|
||||||
|
text = "Settings"
|
||||||
|
|
||||||
|
[node name="Credits" type="HBoxContainer" parent="Content/Menu"]
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 0
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
metadata/action = &"credits"
|
||||||
|
|
||||||
|
[node name="Marker" type="Label" parent="Content/Menu/Credits"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
text = "◆"
|
||||||
|
|
||||||
|
[node name="Body" type="VBoxContainer" parent="Content/Menu/Credits"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Content/Menu/Credits/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 30
|
||||||
|
text = "Credits"
|
||||||
|
|
||||||
|
[node name="Quit" type="HBoxContainer" parent="Content/Menu"]
|
||||||
|
layout_mode = 2
|
||||||
|
mouse_filter = 0
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
metadata/action = &"quit"
|
||||||
|
|
||||||
|
[node name="Marker" type="Label" parent="Content/Menu/Quit"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 15
|
||||||
|
text = "◆"
|
||||||
|
|
||||||
|
[node name="Body" type="VBoxContainer" parent="Content/Menu/Quit"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Content/Menu/Quit/Body"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 30
|
||||||
|
text = "Quit"
|
||||||
|
|
||||||
|
[node name="FooterLeft" type="Label" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 2
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_left = 120.0
|
||||||
|
offset_top = -60.0
|
||||||
|
offset_right = 700.0
|
||||||
|
offset_bottom = -32.0
|
||||||
|
grow_vertical = 0
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "v0.01-alpha · BUILT IN GODOT 4.7 · © 2026"
|
||||||
|
|
||||||
|
[node name="FooterRight" type="Label" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 3
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_left = -520.0
|
||||||
|
offset_top = -60.0
|
||||||
|
offset_right = -120.0
|
||||||
|
offset_bottom = -32.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
grow_vertical = 0
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "[ ↑ ↓ ] CHOOSE [ ↵ ] ENTER"
|
||||||
|
horizontal_alignment = 2
|
||||||
@@ -99,17 +99,17 @@ static func from_dict(d: Dictionary) -> CanonLog:
|
|||||||
if v != SCHEMA_VERSION:
|
if v != SCHEMA_VERSION:
|
||||||
push_error("unsupported canon_log schema_version: %s" % v)
|
push_error("unsupported canon_log schema_version: %s" % v)
|
||||||
return null
|
return null
|
||||||
var log := CanonLog.new()
|
var out := CanonLog.new()
|
||||||
log.player = LogPlayer.from_dict(d.get("player", {}))
|
out.player = LogPlayer.from_dict(d.get("player", {}))
|
||||||
log.location = LogLocation.from_dict(d.get("location", {}))
|
out.location = LogLocation.from_dict(d.get("location", {}))
|
||||||
for m in d.get("party", []):
|
for m in d.get("party", []):
|
||||||
log.party.append(PartyMember.from_dict(m))
|
out.party.append(PartyMember.from_dict(m))
|
||||||
for e in d.get("recent_events", []):
|
for e in d.get("recent_events", []):
|
||||||
log.push_event(e)
|
out.push_event(e)
|
||||||
for f in d.get("established_facts", []):
|
for f in d.get("established_facts", []):
|
||||||
log.add_fact(f)
|
out.add_fact(f)
|
||||||
for q in d.get("active_quests", []):
|
for q in d.get("active_quests", []):
|
||||||
log.active_quests.append(Quest.from_dict(q))
|
out.active_quests.append(Quest.from_dict(q))
|
||||||
for h in d.get("humiliations", []):
|
for h in d.get("humiliations", []):
|
||||||
log.humiliations.append(Humiliation.from_dict(h))
|
out.humiliations.append(Humiliation.from_dict(h))
|
||||||
return log
|
return out
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
class_name LogPlayer
|
class_name LogPlayer
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
## A canon-log player row. Carries ONLY name, class_id, luck_descriptor (§7):
|
## A canon-log player row. Carries ONLY name, race_id, calling_id, luck_descriptor
|
||||||
## no numeric Luck, no stats — those live in GameState and never reach the AI.
|
## (§7): no numeric Luck, no attributes — those live in GameState and never reach the
|
||||||
|
## AI. race_id is here because the Narrator and NPCs must be able to describe the
|
||||||
const CLASSES := ["sellsword", "assassin", "priest"]
|
## player ("a beastfolk cutpurse"); the mechanical sheet must not.
|
||||||
|
|
||||||
var name: String
|
var name: String
|
||||||
var class_id: String
|
var race_id: String
|
||||||
|
var calling_id: String
|
||||||
var luck_descriptor: String
|
var luck_descriptor: String
|
||||||
|
|
||||||
|
|
||||||
func _init(p_name := "", p_class_id := "", p_luck_descriptor := "") -> void:
|
func _init(p_name := "", p_race_id := "", p_calling_id := "", p_luck_descriptor := "") -> void:
|
||||||
name = p_name
|
name = p_name
|
||||||
luck_descriptor = p_luck_descriptor
|
luck_descriptor = p_luck_descriptor
|
||||||
if p_class_id != "":
|
if p_race_id != "":
|
||||||
set_class_id(p_class_id)
|
set_race_id(p_race_id)
|
||||||
|
if p_calling_id != "":
|
||||||
|
set_calling_id(p_calling_id)
|
||||||
|
|
||||||
|
|
||||||
func set_class_id(v: String) -> bool:
|
func set_race_id(v: String) -> bool:
|
||||||
if v not in CLASSES:
|
if not Races.exists(v):
|
||||||
push_error("invalid class_id: %s" % v)
|
push_error("invalid race_id: %s" % v)
|
||||||
return false
|
return false
|
||||||
class_id = v
|
race_id = v
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func set_calling_id(v: String) -> bool:
|
||||||
|
if not Callings.exists(v):
|
||||||
|
push_error("invalid calling_id: %s" % v)
|
||||||
|
return false
|
||||||
|
calling_id = v
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
func to_dict() -> Dictionary:
|
func to_dict() -> Dictionary:
|
||||||
return {"name": name, "class_id": class_id, "luck_descriptor": luck_descriptor}
|
return {"name": name, "race_id": race_id, "calling_id": calling_id,
|
||||||
|
"luck_descriptor": luck_descriptor}
|
||||||
|
|
||||||
|
|
||||||
static func from_dict(d: Dictionary) -> LogPlayer:
|
static func from_dict(d: Dictionary) -> LogPlayer:
|
||||||
return LogPlayer.new(d.get("name", ""), d.get("class_id", ""), d.get("luck_descriptor", ""))
|
return LogPlayer.new(d.get("name", ""), d.get("race_id", ""),
|
||||||
|
d.get("calling_id", ""), d.get("luck_descriptor", ""))
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ var locations: Dictionary = {}
|
|||||||
var npcs: Dictionary = {}
|
var npcs: Dictionary = {}
|
||||||
var quests: Dictionary = {}
|
var quests: Dictionary = {}
|
||||||
var items: Dictionary = {}
|
var items: Dictionary = {}
|
||||||
|
var callings: Dictionary = {}
|
||||||
|
var races: Dictionary = {}
|
||||||
|
var canon_entities: Dictionary = {}
|
||||||
|
var topics: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
static func default_content_root() -> String:
|
static func default_content_root() -> String:
|
||||||
@@ -29,13 +33,18 @@ func load_from(content_root: String) -> void:
|
|||||||
npcs = _load_dir(world.path_join("npcs"))
|
npcs = _load_dir(world.path_join("npcs"))
|
||||||
quests = _load_dir(world.path_join("quests"))
|
quests = _load_dir(world.path_join("quests"))
|
||||||
items = _load_dir(world.path_join("items"))
|
items = _load_dir(world.path_join("items"))
|
||||||
|
callings = _load_dir(world.path_join("callings"))
|
||||||
|
races = _load_dir(world.path_join("races"))
|
||||||
|
canon_entities = _load_dir(world.path_join("canon"))
|
||||||
|
topics = _load_dir(world.path_join("topics"))
|
||||||
|
|
||||||
|
|
||||||
func _load_dir(dir_path: String) -> Dictionary:
|
func _load_dir(dir_path: String) -> Dictionary:
|
||||||
var out: Dictionary = {}
|
var out: Dictionary = {}
|
||||||
var dir := DirAccess.open(dir_path)
|
var dir := DirAccess.open(dir_path)
|
||||||
if dir == null:
|
if dir == null:
|
||||||
push_error("content dir not found: %s" % dir_path)
|
# A missing content dir means "no content of this kind authored yet",
|
||||||
|
# not an error — e.g. content/world/topics/ before any gated content exists.
|
||||||
return out
|
return out
|
||||||
for file in dir.get_files():
|
for file in dir.get_files():
|
||||||
if not file.ends_with(".json"):
|
if not file.ends_with(".json"):
|
||||||
@@ -56,6 +65,14 @@ func has_location(id: String) -> bool: return locations.has(id)
|
|||||||
func has_npc(id: String) -> bool: return npcs.has(id)
|
func has_npc(id: String) -> bool: return npcs.has(id)
|
||||||
func has_quest(id: String) -> bool: return quests.has(id)
|
func has_quest(id: String) -> bool: return quests.has(id)
|
||||||
func has_item(id: String) -> bool: return items.has(id)
|
func has_item(id: String) -> bool: return items.has(id)
|
||||||
|
func calling(id: String) -> Dictionary: return callings.get(id, {})
|
||||||
|
func race(id: String) -> Dictionary: return races.get(id, {})
|
||||||
|
func has_calling(id: String) -> bool: return callings.has(id)
|
||||||
|
func has_race(id: String) -> bool: return races.has(id)
|
||||||
|
func canon(id: String) -> Dictionary: return canon_entities.get(id, {})
|
||||||
|
func topic(id: String) -> Dictionary: return topics.get(id, {})
|
||||||
|
func has_canon(id: String) -> bool: return canon_entities.has(id)
|
||||||
|
func has_topic(id: String) -> bool: return topics.has(id)
|
||||||
|
|
||||||
|
|
||||||
func companions() -> Array:
|
func companions() -> Array:
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func _on_narrate_pressed() -> void:
|
|||||||
|
|
||||||
func _build_scene_log() -> CanonLog:
|
func _build_scene_log() -> CanonLog:
|
||||||
var log := CanonLog.new()
|
var log := CanonLog.new()
|
||||||
log.player = LogPlayer.new("Aldric", "sellsword", "Fortune spits on you")
|
log.player = LogPlayer.new("Aldric", "human", "sellsword", "Fortune spits on you")
|
||||||
log.set_location("greywater_docks", "the Greywater docks")
|
log.set_location("greywater_docks", "the Greywater docks")
|
||||||
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
||||||
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func _ready() -> void:
|
|||||||
# Build the player through LogPlayer so luck_descriptor is non-empty — the
|
# Build the player through LogPlayer so luck_descriptor is non-empty — the
|
||||||
# canon-log schema requires it (minLength 1), and it's the §7 fortune line
|
# canon-log schema requires it (minLength 1), and it's the §7 fortune line
|
||||||
# the digest surfaces to the NPC. An empty string here 422s the request.
|
# the digest surfaces to the NPC. An empty string here 422s the request.
|
||||||
_canon_log.player = LogPlayer.new("Aldric", "sellsword", "the dice are kind today")
|
_canon_log.player = LogPlayer.new("Aldric", "human", "sellsword", "the dice are kind today")
|
||||||
# Deliberately no find_the_ledger quest here — leave it un-started so Fenn
|
# Deliberately no find_the_ledger quest here — leave it un-started so Fenn
|
||||||
# can offer_quest it during the conversation.
|
# can offer_quest it during the conversation.
|
||||||
|
|
||||||
|
|||||||
93
client/scripts/main.gd
Normal file
93
client/scripts/main.gd
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
class_name Main
|
||||||
|
extends Node
|
||||||
|
## The single home for the game's screen sequence: Title → Creation → Shell.
|
||||||
|
## Script-first by design — a pure coordinator with no Control layout, so ADR 0001's
|
||||||
|
## editor-first rule does not apply (that rule governs layout scenes). It owns one
|
||||||
|
## ContentDB + one DmService and swaps screens via instantiate → inject → add_child,
|
||||||
|
## the injection seam change_scene_to_file cannot provide.
|
||||||
|
##
|
||||||
|
## §2: the flow carries only plain data; NewGame.construct re-rolls the character
|
||||||
|
## from the creation seed itself. ⛨ saga: _show_creation and _start_campaign are
|
||||||
|
## separate, so a fourth entry path is an added arm, not surgery on existing edges.
|
||||||
|
|
||||||
|
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||||
|
|
||||||
|
const TITLE := preload("res://scenes/title/TitleScreen.tscn")
|
||||||
|
const CREATION := preload("res://scenes/creation/CharacterCreation.tscn")
|
||||||
|
const SHELL := preload("res://scenes/shell/MainWindowShell.tscn")
|
||||||
|
|
||||||
|
# Injection seams — set BETWEEN instantiate() and add_child(), as the shell/creation
|
||||||
|
# do for their own deps. _ready builds the real ones when nothing was supplied.
|
||||||
|
var world: ContentDB = null
|
||||||
|
var service: DmService = null
|
||||||
|
var origin: Dictionary = {}
|
||||||
|
|
||||||
|
var _current: Node = null
|
||||||
|
var _http: HTTPRequest
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if world == null:
|
||||||
|
world = ContentDB.new()
|
||||||
|
world.load_from(ContentDB.default_content_root())
|
||||||
|
if origin.is_empty():
|
||||||
|
origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||||
|
if service == null:
|
||||||
|
_http = HTTPRequest.new()
|
||||||
|
add_child(_http)
|
||||||
|
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
|
||||||
|
_show_title()
|
||||||
|
|
||||||
|
|
||||||
|
func _show(next: PackedScene, inject: Callable) -> void:
|
||||||
|
## The one swap primitive. inject runs after instantiate() (which does NOT run
|
||||||
|
## _ready) and before add_child (which does) — the injection window. The outgoing
|
||||||
|
## screen is queue_free'd (deferred): a signal handler must never free the node
|
||||||
|
## still executing up its own call stack.
|
||||||
|
var old := _current
|
||||||
|
var screen := next.instantiate()
|
||||||
|
inject.call(screen)
|
||||||
|
_current = screen
|
||||||
|
add_child(screen)
|
||||||
|
if old != null:
|
||||||
|
old.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _show_title() -> void:
|
||||||
|
_show(TITLE, func(s): s.menu_activated.connect(_on_menu))
|
||||||
|
|
||||||
|
|
||||||
|
func _show_creation() -> void:
|
||||||
|
_show(CREATION, func(s):
|
||||||
|
s.world = world
|
||||||
|
s.origin = origin
|
||||||
|
s.creation_confirmed.connect(_start_campaign))
|
||||||
|
|
||||||
|
|
||||||
|
func _show_shell(log: CanonLog, state: GameState) -> void:
|
||||||
|
_show(SHELL, func(s):
|
||||||
|
s.service = service
|
||||||
|
s.injected_log = log
|
||||||
|
s.injected_state = state)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_menu(id: StringName) -> void:
|
||||||
|
match id:
|
||||||
|
&"new_game":
|
||||||
|
_show_creation()
|
||||||
|
&"quit":
|
||||||
|
get_tree().quit()
|
||||||
|
_:
|
||||||
|
pass # continue / load land in later milestones (M9); inert for now
|
||||||
|
|
||||||
|
|
||||||
|
func _start_campaign(creation: Dictionary) -> void:
|
||||||
|
## Takes a plain Dictionary — does NOT assume it came from the creation screen, so
|
||||||
|
## a saga can synthesize one and call this directly (skipping creation).
|
||||||
|
var result := NewGame.construct(origin, world, creation)
|
||||||
|
if not result.get("ok", false):
|
||||||
|
# Unreachable via the UI (the CTA gates on validate), but §2 forbids trusting
|
||||||
|
# the caller and §13 forbids surfacing an error. Log and stay put.
|
||||||
|
push_error("Main: construct rejected a creation: %s" % str(result.get("errors", [])))
|
||||||
|
return
|
||||||
|
_show_shell(result["log"], result["state"])
|
||||||
1
client/scripts/main.gd.uid
Normal file
1
client/scripts/main.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://du7rwup648n60
|
||||||
@@ -1,73 +1,223 @@
|
|||||||
class_name NewGame
|
class_name NewGame
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
## New-game construction (spec "New-game construction"). Reads three immutable
|
## New-game construction. Reads three immutable inputs (origin, world, creation) and
|
||||||
## inputs (origin, world, creation) and writes two products (CanonLog, GameState).
|
## writes two products (CanonLog, GameState). Nothing flows back up — charter §2.
|
||||||
## Nothing flows back up — charter §2 on the client. Validation is front-loaded:
|
##
|
||||||
## a broken origin or illegal class fails here, loudly, not three scenes later.
|
## Creation carries a SEED, not a stat block. construct() rebuilds the RNG and rolls
|
||||||
|
## the attributes ITSELF, so it never trusts a number handed to it (§2) and the same
|
||||||
|
## seed always yields the same character (§10 — "the difference between a bug you can
|
||||||
|
## reproduce and a bug you cannot"). The whole character is reproducible from four
|
||||||
|
## primitives: seed, race_id, calling_id, spend.
|
||||||
|
##
|
||||||
|
## `creation` stays a plain Dictionary: a saga (later) synthesizes one and skips the
|
||||||
|
## UI entirely, so the creation SCENE must never become the only thing that can make it.
|
||||||
|
|
||||||
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary, rng: RandomNumberGenerator) -> Dictionary:
|
const SPEND_POOL := 3
|
||||||
var errors: Array = []
|
|
||||||
|
|
||||||
# 1. content integrity — every referenced id must resolve in world content.
|
|
||||||
for ref in world.unresolved_refs(origin):
|
|
||||||
errors.append("unresolved ref: %s" % ref)
|
|
||||||
|
|
||||||
# 2. build constraints — chosen class must be allowed by the origin.
|
|
||||||
var bc: Dictionary = origin.get("build_constraints", {})
|
|
||||||
var allowed: Array = bc.get("allowed_classes", [])
|
|
||||||
var class_id: String = creation.get("class_id", "")
|
|
||||||
if class_id not in allowed:
|
|
||||||
errors.append("class not allowed by origin: %s" % class_id)
|
|
||||||
|
|
||||||
if creation.get("name", "").strip_edges() == "":
|
|
||||||
errors.append("player name is required")
|
|
||||||
|
|
||||||
|
static func construct(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Dictionary:
|
||||||
|
var errors := _validate(origin, world, creation)
|
||||||
if not errors.is_empty():
|
if not errors.is_empty():
|
||||||
return {"ok": false, "errors": errors, "log": null, "state": null}
|
return {"ok": false, "errors": errors, "log": null, "state": null}
|
||||||
|
|
||||||
# 3. roll Luck (numeric -> state only) and 4. the stat block (data only).
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = int(creation.get("seed", 0))
|
||||||
|
|
||||||
var state := GameState.new()
|
var state := GameState.new()
|
||||||
|
var sheet := CharacterSheet.new()
|
||||||
|
sheet.race_id = str(creation.get("race_id", ""))
|
||||||
|
sheet.calling_id = str(creation.get("calling_id", ""))
|
||||||
|
|
||||||
|
# The RNG ORDER is part of the contract: five attributes in a fixed order, then
|
||||||
|
# Luck. Change the order and every existing seed produces a different character.
|
||||||
|
# roll_attributes() is the ONE implementation — the creation screen calls the same
|
||||||
|
# function to DISPLAY the roll, so what the player saw is what he gets (§2/§10).
|
||||||
|
var attrs: Dictionary = _roll_five(rng)
|
||||||
|
|
||||||
|
var bc: Dictionary = origin.get("build_constraints", {})
|
||||||
state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0)))
|
state.luck_base = Luck.roll_base(rng, int(bc.get("luck_modifier", 0)))
|
||||||
state.luck = state.luck_base
|
state.luck = state.luck_base
|
||||||
state.stats = {
|
|
||||||
"str": _roll_stat(rng), "dex": _roll_stat(rng), "con": _roll_stat(rng),
|
|
||||||
"fth": _roll_stat(rng), "mag": _roll_stat(rng),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 5. assemble the canon log.
|
var spend: Dictionary = creation.get("spend", {})
|
||||||
var log := CanonLog.new()
|
for stat in spend:
|
||||||
log.player = LogPlayer.new(creation.get("name", ""), class_id, state.luck_descriptor())
|
attrs[stat] = int(attrs[stat]) + int(spend[stat])
|
||||||
|
sheet.attributes = attrs
|
||||||
|
|
||||||
|
# Proficiencies: what the race grants + what the player picked + the human's bonus.
|
||||||
|
# All land in one list, so is_proficient() has exactly one thing to read.
|
||||||
|
var skills: Array = []
|
||||||
|
skills.append_array(Races.granted_skills(sheet.race_id))
|
||||||
|
skills.append_array(creation.get("skills", []))
|
||||||
|
var bonus := str(creation.get("bonus_skill", ""))
|
||||||
|
if bonus != "":
|
||||||
|
skills.append(bonus)
|
||||||
|
sheet.bonus_skill = bonus
|
||||||
|
sheet.skills = skills
|
||||||
|
|
||||||
|
sheet.hp = sheet.max_hp() # the character starts whole
|
||||||
|
sheet.mp = sheet.max_mp()
|
||||||
|
state.sheet = sheet
|
||||||
|
|
||||||
|
var canon := CanonLog.new()
|
||||||
|
canon.player = LogPlayer.new(str(creation.get("name", "")), sheet.race_id,
|
||||||
|
sheet.calling_id, state.luck_descriptor())
|
||||||
|
|
||||||
var loc: Dictionary = world.location(origin["start_location_id"])
|
var loc: Dictionary = world.location(origin["start_location_id"])
|
||||||
log.set_location(loc.get("id", ""), loc.get("name", ""))
|
canon.set_location(loc.get("id", ""), loc.get("name", ""))
|
||||||
|
|
||||||
var overrides: Dictionary = origin.get("disposition_overrides", {})
|
var overrides: Dictionary = origin.get("disposition_overrides", {})
|
||||||
var companion_ids := {}
|
var companion_ids := {}
|
||||||
for c in world.companions():
|
for c in world.companions():
|
||||||
companion_ids[c["id"]] = true
|
companion_ids[c["id"]] = true
|
||||||
log.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
|
canon.party.append(PartyMember.new(c["id"], c.get("name", ""), int(overrides.get(c["id"], 0))))
|
||||||
# non-companion overrides go to game state, never the log (spec §5 / charter §6).
|
|
||||||
for npc_id in overrides:
|
for npc_id in overrides:
|
||||||
if not companion_ids.has(npc_id):
|
if not companion_ids.has(npc_id):
|
||||||
state.set_npc_disposition(npc_id, int(overrides[npc_id]))
|
state.set_npc_disposition(npc_id, int(overrides[npc_id]))
|
||||||
|
|
||||||
for line in origin.get("situation", []):
|
for line in origin.get("situation", []):
|
||||||
log.push_event(line)
|
canon.push_event(line)
|
||||||
for fact in origin.get("opening_facts", []):
|
for fact in origin.get("opening_facts", []):
|
||||||
log.add_fact(fact)
|
canon.add_fact(fact)
|
||||||
|
|
||||||
var quest_id: Variant = origin.get("start_quest_id", null)
|
var quest_id: Variant = origin.get("start_quest_id", null)
|
||||||
if quest_id != null:
|
if quest_id != null:
|
||||||
var qd: Dictionary = world.quest(quest_id)
|
var qd: Dictionary = world.quest(quest_id)
|
||||||
log.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active", qd.get("objective", "")))
|
canon.active_quests.append(Quest.new(qd.get("id", ""), qd.get("name", ""), "active",
|
||||||
|
qd.get("objective", "")))
|
||||||
|
|
||||||
# inventory grants -> game state (NOT the log). Charter §2.
|
# inventory grants -> game state (NOT the log). §2. grant() routes currency to the
|
||||||
for grant in origin.get("inventory_grants", []):
|
# purse; everything else to the inventory dict.
|
||||||
state.add_item(grant.get("item_id", ""), int(grant.get("qty", 0)))
|
for g in origin.get("inventory_grants", []):
|
||||||
|
state.grant(g.get("item_id", ""), int(g.get("qty", 0)))
|
||||||
|
|
||||||
return {"ok": true, "errors": [], "log": log, "state": state}
|
return {"ok": true, "errors": [], "log": canon, "state": state}
|
||||||
|
|
||||||
|
|
||||||
static func _roll_stat(rng: RandomNumberGenerator) -> int:
|
static func roll_attributes(character_seed: int) -> Dictionary:
|
||||||
# 3d6 placeholder; the stat block is data-only in this plan. TUNABLE.
|
## PUBLIC and PURE. The creation screen calls this to SHOW the roll; construct
|
||||||
return rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
|
## calls it to BUILD it. One implementation, so the two can never disagree.
|
||||||
|
## NO DEFAULT PARAMETERS (traps.md #2) — a defaulted arg turns a wrong-arity
|
||||||
|
## call into a silent miscompile that binds the wrong value into the wrong slot.
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = character_seed
|
||||||
|
return _roll_five(rng)
|
||||||
|
|
||||||
|
|
||||||
|
static func _roll_five(rng: RandomNumberGenerator) -> Dictionary:
|
||||||
|
var attrs: Dictionary = {}
|
||||||
|
for stat in Attributes.IDS:
|
||||||
|
attrs[stat] = _roll_attribute(rng)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
static func validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
|
||||||
|
## PUBLIC. The creation screen gates its CTA on this, so the player cannot press
|
||||||
|
## his way into a rejection. construct() still calls it and still does not trust
|
||||||
|
## its caller — the screen is a caller like any other. NO DEFAULT PARAMETERS.
|
||||||
|
return _validate(origin, world, creation)
|
||||||
|
|
||||||
|
|
||||||
|
static func _roll_attribute(rng: RandomNumberGenerator) -> int:
|
||||||
|
# 3d6 with a HARD FLOOR of 8. The floor clamps the die, not the player: straight
|
||||||
|
# 3d6 puts ~9% on a primary the +3 pool cannot rescue, and a character who is bad
|
||||||
|
# at the one thing he is FOR is not grit — it is the "fine" §7 forbids, paid for
|
||||||
|
# twenty hours.
|
||||||
|
var roll := rng.randi_range(1, 6) + rng.randi_range(1, 6) + rng.randi_range(1, 6)
|
||||||
|
return maxi(8, roll)
|
||||||
|
|
||||||
|
|
||||||
|
static func _validate(origin: Dictionary, world: ContentDB, creation: Dictionary) -> Array:
|
||||||
|
var errors: Array = []
|
||||||
|
|
||||||
|
for ref in world.unresolved_refs(origin):
|
||||||
|
errors.append("unresolved ref: %s" % ref)
|
||||||
|
|
||||||
|
if str(creation.get("name", "")).strip_edges() == "":
|
||||||
|
errors.append("player name is required")
|
||||||
|
|
||||||
|
if not creation.has("seed") or typeof(creation["seed"]) != TYPE_INT:
|
||||||
|
errors.append("seed is required and must be an integer")
|
||||||
|
|
||||||
|
var race_id: String = str(creation.get("race_id", ""))
|
||||||
|
if not Races.exists(race_id):
|
||||||
|
errors.append("unknown race: %s" % race_id)
|
||||||
|
|
||||||
|
var calling_id: String = str(creation.get("calling_id", ""))
|
||||||
|
if not Callings.exists(calling_id):
|
||||||
|
errors.append("unknown calling: %s" % calling_id)
|
||||||
|
else:
|
||||||
|
var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
|
||||||
|
if calling_id not in allowed:
|
||||||
|
errors.append("calling not allowed by origin: %s" % calling_id)
|
||||||
|
|
||||||
|
# Guard container SHAPES before any statically-typed call below touches them.
|
||||||
|
# A JSON round-trip can hand back {"spend": null} or {"skills": "stealth"} —
|
||||||
|
# without this check, assigning that into a typed Dictionary/Array parameter
|
||||||
|
# throws a GDScript runtime error INSIDE construct() instead of landing in the
|
||||||
|
# front-loaded {"ok": false, "errors": [...]} the spec promises.
|
||||||
|
if typeof(creation.get("spend", {})) != TYPE_DICTIONARY:
|
||||||
|
errors.append("spend must be an object")
|
||||||
|
else:
|
||||||
|
errors.append_array(_validate_spend(creation.get("spend", {})))
|
||||||
|
|
||||||
|
if typeof(creation.get("skills", [])) != TYPE_ARRAY:
|
||||||
|
errors.append("skills must be an array")
|
||||||
|
elif Races.exists(race_id) and Callings.exists(calling_id):
|
||||||
|
errors.append_array(_validate_skills(creation, race_id, calling_id))
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
static func _validate_spend(spend: Dictionary) -> Array:
|
||||||
|
var errors: Array = []
|
||||||
|
var total := 0
|
||||||
|
for stat in spend:
|
||||||
|
# LCK is not merely hidden at creation — it is unspendable (§7).
|
||||||
|
if not Attributes.exists(str(stat)):
|
||||||
|
errors.append("cannot spend on '%s' — not an attribute" % stat)
|
||||||
|
continue
|
||||||
|
if typeof(spend[stat]) != TYPE_INT:
|
||||||
|
errors.append("spend value for '%s' must be an integer" % stat)
|
||||||
|
continue
|
||||||
|
var v := int(spend[stat])
|
||||||
|
if v < 0:
|
||||||
|
errors.append("spend is additive only: %s is negative" % stat)
|
||||||
|
continue
|
||||||
|
total += v
|
||||||
|
if total > SPEND_POOL:
|
||||||
|
errors.append("spend exceeds the pool of %d (got %d)" % [SPEND_POOL, total])
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
static func _validate_skills(creation: Dictionary, race_id: String, calling_id: String) -> Array:
|
||||||
|
var errors: Array = []
|
||||||
|
var picks: Array = creation.get("skills", [])
|
||||||
|
var granted: Array = Races.granted_skills(race_id)
|
||||||
|
var pool: Array = Callings.skill_pool(calling_id)
|
||||||
|
var want := Callings.skill_count(calling_id)
|
||||||
|
|
||||||
|
if picks.size() != want:
|
||||||
|
errors.append("%s picks %d skills, got %d" % [calling_id, want, picks.size()])
|
||||||
|
|
||||||
|
var seen := {}
|
||||||
|
for s in picks:
|
||||||
|
if s in seen:
|
||||||
|
errors.append("duplicate skill pick: %s" % s)
|
||||||
|
seen[s] = true
|
||||||
|
if s not in pool:
|
||||||
|
errors.append("%s is not in the %s pool" % [s, calling_id])
|
||||||
|
if s in granted:
|
||||||
|
errors.append("%s is already granted by %s — the pick buys nothing" % [s, race_id])
|
||||||
|
|
||||||
|
var bonus := str(creation.get("bonus_skill", ""))
|
||||||
|
if Races.wants_bonus_skill(race_id):
|
||||||
|
if bonus == "":
|
||||||
|
errors.append("%s must choose a bonus skill" % race_id)
|
||||||
|
elif not Skills.exists(bonus):
|
||||||
|
errors.append("unknown bonus skill: %s" % bonus)
|
||||||
|
elif bonus in picks or bonus in granted:
|
||||||
|
errors.append("bonus skill %s is already proficient" % bonus)
|
||||||
|
elif bonus != "":
|
||||||
|
errors.append("%s does not get a bonus skill" % race_id)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|||||||
@@ -11,9 +11,24 @@ const HOSTILE_FLOOR := -100
|
|||||||
|
|
||||||
|
|
||||||
static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void:
|
static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void:
|
||||||
|
# ONE RULE: a move applies AT MOST ONCE PER REPLY. The tag extractor returns
|
||||||
|
# every occurrence and the validator is a membership test, so without this a
|
||||||
|
# model repeating a tag N times picks an amount in unary — the AI choosing a
|
||||||
|
# number, which is the §2 breach the bounded vocabulary exists to prevent.
|
||||||
|
# It is worth the same for real items: three give_item(amulet) tags are one
|
||||||
|
# amulet, not three.
|
||||||
|
var applied := {} # "name(id)" -> true
|
||||||
|
var dispo_total := 0 # the reply's NET disposition swing, capped at MAX_DELTA
|
||||||
for move in moves:
|
for move in moves:
|
||||||
var name: String = move.get("name", "")
|
var name: String = move.get("name", "")
|
||||||
var args: Array = move.get("args", [])
|
var args: Array = move.get("args", [])
|
||||||
|
# MoveValidator.ID_MOVES is the one list of moves that name a thing — those
|
||||||
|
# are the ones a (name, id) key can dedupe. Reused, not restated.
|
||||||
|
if name in MoveValidator.ID_MOVES and not args.is_empty():
|
||||||
|
var key := "%s(%s)" % [name, str(args[0])]
|
||||||
|
if applied.has(key):
|
||||||
|
continue
|
||||||
|
applied[key] = true
|
||||||
match name:
|
match name:
|
||||||
"offer_quest":
|
"offer_quest":
|
||||||
var q: String = str(args[0])
|
var q: String = str(args[0])
|
||||||
@@ -23,14 +38,23 @@ static func apply(moves: Array, game_state, canon_log, content_db, npc_id: Strin
|
|||||||
game_state.mark_revealed(str(args[0]))
|
game_state.mark_revealed(str(args[0]))
|
||||||
"give_item":
|
"give_item":
|
||||||
var i: String = str(args[0])
|
var i: String = str(args[0])
|
||||||
game_state.add_item(i, 1)
|
game_state.grant(i, 1)
|
||||||
game_state.mark_gift_given(i)
|
if not Currency.is_currency(i):
|
||||||
|
game_state.mark_gift_given(i) # the one-shot gate is for real items only
|
||||||
"accept_item":
|
"accept_item":
|
||||||
game_state.remove_item(str(args[0]), 1)
|
game_state.take(str(args[0]), 1)
|
||||||
"adjust_disposition":
|
"adjust_disposition":
|
||||||
|
# MAX_DELTA caps the reply's NET swing, not each tag — clamping per
|
||||||
|
# tag let three +15s move standing by 45, which is exactly the
|
||||||
|
# "wholesale swing" the constant is here to forbid. Applied inside
|
||||||
|
# the loop (not batched at the end) so emission order still holds
|
||||||
|
# and a later become_hostile floors what came before it.
|
||||||
var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA)
|
var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA)
|
||||||
|
var capped: int = clampi(dispo_total + delta, -MAX_DELTA, MAX_DELTA)
|
||||||
|
var allowed: int = capped - dispo_total
|
||||||
|
dispo_total = capped
|
||||||
var cur: int = int(game_state.npc_dispositions.get(npc_id, 0))
|
var cur: int = int(game_state.npc_dispositions.get(npc_id, 0))
|
||||||
game_state.set_npc_disposition(npc_id, cur + delta)
|
game_state.set_npc_disposition(npc_id, cur + allowed)
|
||||||
"become_hostile":
|
"become_hostile":
|
||||||
game_state.set_npc_disposition(npc_id, HOSTILE_FLOOR)
|
game_state.set_npc_disposition(npc_id, HOSTILE_FLOOR)
|
||||||
_:
|
_:
|
||||||
|
|||||||
@@ -21,12 +21,20 @@ static func available_moves(npc_dict: Dictionary, game_state, canon_log) -> Arra
|
|||||||
moves.append("reveal(%s)" % t)
|
moves.append("reveal(%s)" % t)
|
||||||
|
|
||||||
for i in caps.get("giveable_items", []):
|
for i in caps.get("giveable_items", []):
|
||||||
if not game_state.is_gift_given(i):
|
# Currency is exempt from the one-shot gift gate — an NPC can pay you twice.
|
||||||
|
if Currency.is_currency(i) or not game_state.is_gift_given(i):
|
||||||
moves.append("give_item(%s)" % i)
|
moves.append("give_item(%s)" % i)
|
||||||
|
|
||||||
for item_id in game_state.inventory.keys():
|
for item_id in game_state.inventory.keys():
|
||||||
if int(game_state.inventory[item_id]) > 0:
|
if int(game_state.inventory[item_id]) > 0:
|
||||||
moves.append("accept_item(%s)" % item_id)
|
moves.append("accept_item(%s)" % item_id)
|
||||||
|
|
||||||
|
# Money is not in the inventory dict, so it needs its own offer. Only the
|
||||||
|
# denominations the purse can actually pay: an NPC cannot ask a party holding
|
||||||
|
# 47c for a gold coin.
|
||||||
|
for denom in Currency.VALUES:
|
||||||
|
if game_state.purse_copper >= Currency.value(denom):
|
||||||
|
moves.append("accept_item(%s)" % denom)
|
||||||
|
|
||||||
moves.append_array(UNIVERSAL)
|
moves.append_array(UNIVERSAL)
|
||||||
return moves
|
return moves
|
||||||
|
|||||||
15
client/scripts/rules/attributes.gd
Normal file
15
client/scripts/rules/attributes.gd
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
class_name Attributes
|
||||||
|
extends RefCounted
|
||||||
|
## The five attribute rows and the one modifier formula. LCK is deliberately NOT
|
||||||
|
## here — it is never an attribute row, never a save, never a skill (§7). It lives
|
||||||
|
## in GameState.luck.
|
||||||
|
|
||||||
|
const IDS := ["str", "dex", "con", "fth", "mag"]
|
||||||
|
|
||||||
|
|
||||||
|
static func exists(id: String) -> bool:
|
||||||
|
return id in IDS
|
||||||
|
|
||||||
|
|
||||||
|
static func modifier(score: int) -> int:
|
||||||
|
return floori((score - 10) / 2.0)
|
||||||
1
client/scripts/rules/attributes.gd.uid
Normal file
1
client/scripts/rules/attributes.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dnlwajfs3vysm
|
||||||
93
client/scripts/rules/callings.gd
Normal file
93
client/scripts/rules/callings.gd
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
class_name Callings
|
||||||
|
extends RefCounted
|
||||||
|
## The 7 callings (races-and-classes spec §4). A calling is what a VILLAGE calls you,
|
||||||
|
## not what a rulebook calls you — Cutpurse, not Assassin; Bonesetter, not Priest.
|
||||||
|
##
|
||||||
|
## The skill POOLS are this spec's addition: the races/classes spec said only "drawn
|
||||||
|
## from the class's stat-appropriate slice" and never enumerated them. Each pool is the
|
||||||
|
## calling's primary-stat skills plus a small adjacent slice, always larger than the
|
||||||
|
## pick count so the choice is real.
|
||||||
|
##
|
||||||
|
## L1 talents are ID STUBS. M5 implements them. armor is a proficiency CATEGORY; real
|
||||||
|
## armor is an item (M7).
|
||||||
|
|
||||||
|
const IDS := ["sellsword", "reaver", "cutpurse", "trapper", "hedge_mage", "bonesetter", "bloodsworn"]
|
||||||
|
|
||||||
|
const TABLE := {
|
||||||
|
"sellsword": {
|
||||||
|
"primary": "str", "hit_die": 10, "saves": ["str", "con"],
|
||||||
|
"skill_pool": ["athletics", "intimidation", "endurance", "perception"], "skill_count": 2,
|
||||||
|
"casting_stat": "", "armor": "heavy", "talent": "second_wind",
|
||||||
|
},
|
||||||
|
"reaver": {
|
||||||
|
"primary": "str", "hit_die": 12, "saves": ["str", "con"],
|
||||||
|
"skill_pool": ["athletics", "intimidation", "endurance", "acrobatics"], "skill_count": 2,
|
||||||
|
"casting_stat": "", "armor": "medium", "talent": "blood_fury",
|
||||||
|
},
|
||||||
|
"cutpurse": {
|
||||||
|
"primary": "dex", "hit_die": 8, "saves": ["dex", "mag"],
|
||||||
|
"skill_pool": ["stealth", "sleight_of_hand", "acrobatics", "perception", "athletics", "intimidation"],
|
||||||
|
"skill_count": 4,
|
||||||
|
"casting_stat": "", "armor": "light", "talent": "backstab",
|
||||||
|
},
|
||||||
|
"trapper": {
|
||||||
|
"primary": "dex", "hit_die": 10, "saves": ["dex", "con"],
|
||||||
|
"skill_pool": ["stealth", "acrobatics", "perception", "endurance", "athletics"], "skill_count": 3,
|
||||||
|
"casting_stat": "", "armor": "light", "talent": "set_snare",
|
||||||
|
},
|
||||||
|
"hedge_mage": {
|
||||||
|
"primary": "mag", "hit_die": 6, "saves": ["mag", "fth"],
|
||||||
|
"skill_pool": ["sorcery", "perception", "sleight_of_hand", "faith_lore"], "skill_count": 2,
|
||||||
|
"casting_stat": "mag", "armor": "none", "talent": "hexbolt",
|
||||||
|
},
|
||||||
|
"bonesetter": {
|
||||||
|
"primary": "fth", "hit_die": 8, "saves": ["fth", "con"],
|
||||||
|
"skill_pool": ["faith_lore", "perception", "endurance", "intimidation"], "skill_count": 2,
|
||||||
|
"casting_stat": "fth", "armor": "medium", "talent": "mend",
|
||||||
|
},
|
||||||
|
"bloodsworn": {
|
||||||
|
"primary": "fth", "hit_die": 8, "saves": ["fth", "mag"],
|
||||||
|
"skill_pool": ["faith_lore", "sorcery", "intimidation", "perception"], "skill_count": 2,
|
||||||
|
"casting_stat": "fth", "armor": "light", "talent": "pact_mark",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func exists(id: String) -> bool:
|
||||||
|
return TABLE.has(id)
|
||||||
|
|
||||||
|
|
||||||
|
static func primary(id: String) -> String:
|
||||||
|
return str(TABLE.get(id, {}).get("primary", ""))
|
||||||
|
|
||||||
|
|
||||||
|
static func hit_die(id: String) -> int:
|
||||||
|
return int(TABLE.get(id, {}).get("hit_die", 0))
|
||||||
|
|
||||||
|
|
||||||
|
static func saves(id: String) -> Array:
|
||||||
|
return TABLE.get(id, {}).get("saves", [])
|
||||||
|
|
||||||
|
|
||||||
|
static func skill_pool(id: String) -> Array:
|
||||||
|
return TABLE.get(id, {}).get("skill_pool", [])
|
||||||
|
|
||||||
|
|
||||||
|
static func skill_count(id: String) -> int:
|
||||||
|
return int(TABLE.get(id, {}).get("skill_count", 0))
|
||||||
|
|
||||||
|
|
||||||
|
static func casting_stat(id: String) -> String:
|
||||||
|
return str(TABLE.get(id, {}).get("casting_stat", ""))
|
||||||
|
|
||||||
|
|
||||||
|
static func is_caster(id: String) -> bool:
|
||||||
|
return casting_stat(id) != ""
|
||||||
|
|
||||||
|
|
||||||
|
static func armor(id: String) -> String:
|
||||||
|
return str(TABLE.get(id, {}).get("armor", ""))
|
||||||
|
|
||||||
|
|
||||||
|
static func talent(id: String) -> String:
|
||||||
|
return str(TABLE.get(id, {}).get("talent", ""))
|
||||||
1
client/scripts/rules/callings.gd.uid
Normal file
1
client/scripts/rules/callings.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cewlixhavh1h4
|
||||||
60
client/scripts/rules/races.gd
Normal file
60
client/scripts/rules/races.gd
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
class_name Races
|
||||||
|
extends RefCounted
|
||||||
|
## The 4 races (races-and-classes spec §3). FEATURES ONLY — no race touches an
|
||||||
|
## attribute score, which is what keeps the roll + spend pure.
|
||||||
|
##
|
||||||
|
## nightsight / claws / keen_scent are FLAGS, not numbers. Combat (M5) and the DM's
|
||||||
|
## exploration checks consume them; creation only stores them.
|
||||||
|
##
|
||||||
|
## The dwarf's +2 is SITUATIONAL (vs poison and the affliction track), so it is NOT
|
||||||
|
## a flat save bonus — baking a conditional into a flat number is how a sheet starts
|
||||||
|
## lying. It is exposed separately as poison_save_bonus().
|
||||||
|
|
||||||
|
const IDS := ["human", "elf", "dwarf", "beastfolk"]
|
||||||
|
|
||||||
|
const TABLE := {
|
||||||
|
"human": {
|
||||||
|
"save_bonus": 1, "granted_skills": [], "bonus_skill_choice": true,
|
||||||
|
"poison_save_bonus": 0,
|
||||||
|
"flags": {"nightsight": false, "claws": false, "keen_scent": false},
|
||||||
|
},
|
||||||
|
"elf": {
|
||||||
|
"save_bonus": 0, "granted_skills": ["perception"], "bonus_skill_choice": false,
|
||||||
|
"poison_save_bonus": 0,
|
||||||
|
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
|
||||||
|
},
|
||||||
|
"dwarf": {
|
||||||
|
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
|
||||||
|
"poison_save_bonus": 2,
|
||||||
|
"flags": {"nightsight": true, "claws": false, "keen_scent": false},
|
||||||
|
},
|
||||||
|
"beastfolk": {
|
||||||
|
"save_bonus": 0, "granted_skills": [], "bonus_skill_choice": false,
|
||||||
|
"poison_save_bonus": 0,
|
||||||
|
"flags": {"nightsight": false, "claws": true, "keen_scent": true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func exists(id: String) -> bool:
|
||||||
|
return TABLE.has(id)
|
||||||
|
|
||||||
|
|
||||||
|
static func save_bonus(id: String) -> int:
|
||||||
|
return int(TABLE.get(id, {}).get("save_bonus", 0))
|
||||||
|
|
||||||
|
|
||||||
|
static func granted_skills(id: String) -> Array:
|
||||||
|
return TABLE.get(id, {}).get("granted_skills", [])
|
||||||
|
|
||||||
|
|
||||||
|
static func wants_bonus_skill(id: String) -> bool:
|
||||||
|
return bool(TABLE.get(id, {}).get("bonus_skill_choice", false))
|
||||||
|
|
||||||
|
|
||||||
|
static func poison_save_bonus(id: String) -> int:
|
||||||
|
return int(TABLE.get(id, {}).get("poison_save_bonus", 0))
|
||||||
|
|
||||||
|
|
||||||
|
static func flag(id: String, name: String) -> bool:
|
||||||
|
return bool(TABLE.get(id, {}).get("flags", {}).get(name, false))
|
||||||
1
client/scripts/rules/races.gd.uid
Normal file
1
client/scripts/rules/races.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bmkoxq4eabfcq
|
||||||
28
client/scripts/rules/skills.gd
Normal file
28
client/scripts/rules/skills.gd
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
class_name Skills
|
||||||
|
extends RefCounted
|
||||||
|
## The 9 skills and the attribute each is governed by (races-and-classes spec §2).
|
||||||
|
## Perception sits under FTH, not a standalone WIS — §8's FTH/MAG replace WIS/INT,
|
||||||
|
## so a Bonesetter is also the party's best lookout. LCK owns no skill (§7).
|
||||||
|
|
||||||
|
const BY_ATTRIBUTE := {
|
||||||
|
"athletics": "str",
|
||||||
|
"intimidation": "str",
|
||||||
|
"stealth": "dex",
|
||||||
|
"sleight_of_hand": "dex",
|
||||||
|
"acrobatics": "dex",
|
||||||
|
"endurance": "con",
|
||||||
|
"perception": "fth",
|
||||||
|
"faith_lore": "fth",
|
||||||
|
"sorcery": "mag",
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDS := ["athletics", "intimidation", "stealth", "sleight_of_hand", "acrobatics",
|
||||||
|
"endurance", "perception", "faith_lore", "sorcery"]
|
||||||
|
|
||||||
|
|
||||||
|
static func exists(skill: String) -> bool:
|
||||||
|
return BY_ATTRIBUTE.has(skill)
|
||||||
|
|
||||||
|
|
||||||
|
static func attribute(skill: String) -> String:
|
||||||
|
return str(BY_ATTRIBUTE.get(skill, ""))
|
||||||
1
client/scripts/rules/skills.gd.uid
Normal file
1
client/scripts/rules/skills.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d2pvtvrrhc27b
|
||||||
94
client/scripts/state/character_sheet.gd
Normal file
94
client/scripts/state/character_sheet.gd
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
class_name CharacterSheet
|
||||||
|
extends RefCounted
|
||||||
|
## The character, as state (§2). STORES only what was rolled or chosen, plus the two
|
||||||
|
## numbers that genuinely mutate in play (current hp/mp). Everything else is DERIVED —
|
||||||
|
## so a derived number can never drift from the inputs that produced it, and M5's level
|
||||||
|
## curve becomes a function change rather than a stored-field migration.
|
||||||
|
##
|
||||||
|
## LCK IS NOT HERE, and there is no accessor for it. It lives in GameState.luck: no
|
||||||
|
## save, no skill, no row (§7). That holds by construction, not by discipline.
|
||||||
|
|
||||||
|
const PROF_BONUS_L1 := 2
|
||||||
|
|
||||||
|
# --- stored: rolled, chosen, or genuinely mutable -------------------------------
|
||||||
|
var attributes: Dictionary = {} # {str,dex,con,fth,mag} — final, post-spend
|
||||||
|
var race_id: String = ""
|
||||||
|
var calling_id: String = ""
|
||||||
|
var level: int = 1
|
||||||
|
var skills: Array = [] # chosen + race-granted + the human's bonus pick
|
||||||
|
var bonus_skill: String = "" # human only; also present in `skills`
|
||||||
|
var hp: int = 0 # CURRENT hp — combat writes this
|
||||||
|
var mp: int = 0 # CURRENT mp
|
||||||
|
|
||||||
|
|
||||||
|
# --- derived: functions, never stored -------------------------------------------
|
||||||
|
func modifier(stat: String) -> int:
|
||||||
|
return Attributes.modifier(int(attributes.get(stat, 10)))
|
||||||
|
|
||||||
|
|
||||||
|
func prof_bonus() -> int:
|
||||||
|
return PROF_BONUS_L1 # the level curve is M5's; creation is L1
|
||||||
|
|
||||||
|
|
||||||
|
func max_hp() -> int:
|
||||||
|
return Callings.hit_die(calling_id) + modifier("con")
|
||||||
|
|
||||||
|
|
||||||
|
func max_mp() -> int:
|
||||||
|
# TUNABLE — M5 owns the real MP curve (races-and-classes spec §6 defers it).
|
||||||
|
# The SHAPE is what M4-b needs to draw a bar: martials have no pool at all; a
|
||||||
|
# caster's scales off the casting stat, with a floor so a bad roll still casts.
|
||||||
|
if not Callings.is_caster(calling_id):
|
||||||
|
return 0
|
||||||
|
return maxi(4, 4 + 4 * modifier(Callings.casting_stat(calling_id)))
|
||||||
|
|
||||||
|
|
||||||
|
func ac() -> int:
|
||||||
|
return 10 + modifier("dex") # armor is an item -> M7
|
||||||
|
|
||||||
|
|
||||||
|
func initiative() -> int:
|
||||||
|
return modifier("dex")
|
||||||
|
|
||||||
|
|
||||||
|
func is_proficient(skill: String) -> bool:
|
||||||
|
return skill in skills
|
||||||
|
|
||||||
|
|
||||||
|
func save(stat: String) -> int:
|
||||||
|
var v := modifier(stat)
|
||||||
|
if stat in Callings.saves(calling_id):
|
||||||
|
v += prof_bonus()
|
||||||
|
v += Races.save_bonus(race_id) # human's +1 to all five
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
func poison_save_bonus() -> int:
|
||||||
|
# Situational (vs poison + the affliction track) — deliberately NOT folded into
|
||||||
|
# save("con"), because a conditional inside a flat number is a sheet that lies.
|
||||||
|
return Races.poison_save_bonus(race_id)
|
||||||
|
|
||||||
|
|
||||||
|
func skill_bonus(skill: String) -> int:
|
||||||
|
var v := modifier(Skills.attribute(skill))
|
||||||
|
if is_proficient(skill):
|
||||||
|
v += prof_bonus()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
func spell_dc() -> int:
|
||||||
|
if not Callings.is_caster(calling_id):
|
||||||
|
return 0
|
||||||
|
return 8 + prof_bonus() + modifier(Callings.casting_stat(calling_id))
|
||||||
|
|
||||||
|
|
||||||
|
func has_nightsight() -> bool:
|
||||||
|
return Races.flag(race_id, "nightsight")
|
||||||
|
|
||||||
|
|
||||||
|
func has_claws() -> bool:
|
||||||
|
return Races.flag(race_id, "claws")
|
||||||
|
|
||||||
|
|
||||||
|
func has_keen_scent() -> bool:
|
||||||
|
return Races.flag(race_id, "keen_scent")
|
||||||
1
client/scripts/state/character_sheet.gd.uid
Normal file
1
client/scripts/state/character_sheet.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://mofimtmsxuui
|
||||||
46
client/scripts/state/currency.gd
Normal file
46
client/scripts/state/currency.gd
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
class_name Currency
|
||||||
|
extends RefCounted
|
||||||
|
## Money. Everything is stored in COPPER — one integer, on GameState.purse_copper.
|
||||||
|
## The denominations exist so a bounded §6 move can name a unit (give_item(gold)
|
||||||
|
## is +10,000c); they are NOT stored buckets, so change-making never arises.
|
||||||
|
##
|
||||||
|
## The ratios are rules and live here in code. PRICES are content and live in
|
||||||
|
## /content (roadmap: "the economy is two systems"). Formatting happens only at
|
||||||
|
## the display edge — this is the one place that knows what "12g 40s 8c" means.
|
||||||
|
|
||||||
|
const COPPER := 1
|
||||||
|
const SILVER := 100
|
||||||
|
const GOLD := 10000
|
||||||
|
|
||||||
|
const VALUES := {"copper": COPPER, "silver": SILVER, "gold": GOLD}
|
||||||
|
|
||||||
|
|
||||||
|
static func is_currency(item_id: String) -> bool:
|
||||||
|
return VALUES.has(item_id)
|
||||||
|
|
||||||
|
|
||||||
|
static func value(item_id: String) -> int:
|
||||||
|
return int(VALUES.get(item_id, 0))
|
||||||
|
|
||||||
|
|
||||||
|
static func format(copper: int) -> String:
|
||||||
|
# Empty denominations are suppressed and at least one unit always shows:
|
||||||
|
# "0c", "47c", "1g 8c", "12g 40s". "0g 0s 8c" reads as a spreadsheet, not a
|
||||||
|
# purse — and being broke should read as poverty (§3).
|
||||||
|
if copper <= 0:
|
||||||
|
return "0c"
|
||||||
|
var parts: Array[String] = []
|
||||||
|
var left := copper
|
||||||
|
@warning_ignore("integer_division") # denomination math — the remainder is carried, not lost
|
||||||
|
var gold := left / GOLD
|
||||||
|
if gold > 0:
|
||||||
|
parts.append("%dg" % gold)
|
||||||
|
left -= gold * GOLD
|
||||||
|
@warning_ignore("integer_division")
|
||||||
|
var silver := left / SILVER
|
||||||
|
if silver > 0:
|
||||||
|
parts.append("%ds" % silver)
|
||||||
|
left -= silver * SILVER
|
||||||
|
if left > 0:
|
||||||
|
parts.append("%dc" % left)
|
||||||
|
return " ".join(parts)
|
||||||
1
client/scripts/state/currency.gd.uid
Normal file
1
client/scripts/state/currency.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cwnflbwacp5jf
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
class_name GameState
|
class_name GameState
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck,
|
## Luck-centric game state (spec decision 5). The SOLE home of numeric Luck,
|
||||||
## stats, world-NPC dispositions, and inventory — none of which enter the canon
|
## the character sheet, world-NPC dispositions, and inventory — none of which
|
||||||
## log (charter §7/§2). The log reads only luck_descriptor() from here.
|
## enter the canon log (charter §7/§2). The log reads only luck_descriptor()
|
||||||
|
## from here.
|
||||||
|
|
||||||
var luck: int = 0
|
var luck: int = 0
|
||||||
var luck_base: int = 0
|
var luck_base: int = 0
|
||||||
var stats: Dictionary = {} # {str, dex, con, fth, mag}
|
var sheet: CharacterSheet = null # the whole character (§2). Built by NewGame.
|
||||||
var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100)
|
var npc_dispositions: Dictionary = {} # world-npc id -> int (−100..100)
|
||||||
var inventory: Dictionary = {} # item_id -> qty
|
var inventory: Dictionary = {} # item_id -> qty (NEVER money — see purse_copper)
|
||||||
|
var purse_copper: int = 0 # ALL money, in copper. One integer (§4.1 of the spec).
|
||||||
var revealed_topics: Dictionary = {} # topic_id -> true
|
var revealed_topics: Dictionary = {} # topic_id -> true
|
||||||
var gifts_given: Dictionary = {} # item_id -> true (idempotency for give_item)
|
var gifts_given: Dictionary = {} # item_id -> true (idempotency for give_item)
|
||||||
|
|
||||||
@@ -41,6 +43,27 @@ func remove_item(item_id: String, qty: int) -> void:
|
|||||||
inventory.erase(item_id)
|
inventory.erase(item_id)
|
||||||
|
|
||||||
|
|
||||||
|
func add_copper(amount: int) -> void:
|
||||||
|
purse_copper = maxi(0, purse_copper + amount)
|
||||||
|
|
||||||
|
|
||||||
|
func grant(item_id: String, qty: int) -> void:
|
||||||
|
# The single routing point (with take): currency lands in the purse, every
|
||||||
|
# other item in the inventory dict. Money is never an inventory row, so it
|
||||||
|
# can never surface in an inventory grid — exclusion by construction.
|
||||||
|
if Currency.is_currency(item_id):
|
||||||
|
add_copper(Currency.value(item_id) * qty)
|
||||||
|
else:
|
||||||
|
add_item(item_id, qty)
|
||||||
|
|
||||||
|
|
||||||
|
func take(item_id: String, qty: int) -> void:
|
||||||
|
if Currency.is_currency(item_id):
|
||||||
|
add_copper(-Currency.value(item_id) * qty)
|
||||||
|
else:
|
||||||
|
remove_item(item_id, qty)
|
||||||
|
|
||||||
|
|
||||||
func mark_revealed(topic_id: String) -> void:
|
func mark_revealed(topic_id: String) -> void:
|
||||||
revealed_topics[topic_id] = true
|
revealed_topics[topic_id] = true
|
||||||
|
|
||||||
|
|||||||
336
client/scripts/theme/build_game_theme.gd
Normal file
336
client/scripts/theme/build_game_theme.gd
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
# client/scripts/theme/build_game_theme.gd
|
||||||
|
@tool
|
||||||
|
extends SceneTree
|
||||||
|
## One-shot Theme builder. Run headless:
|
||||||
|
## godot --headless -s res://scripts/theme/build_game_theme.gd
|
||||||
|
## Constructs game_theme.tres from Palette + the bundled fonts and saves it.
|
||||||
|
## The builder is the source of truth; the .tres is a committed artifact.
|
||||||
|
##
|
||||||
|
## build_theme() is a static func so it is reachable without saving/quitting —
|
||||||
|
## tests preload this script and call Builder.build_theme() directly to compare
|
||||||
|
## a fresh in-memory Theme against the committed .tres (drift guard). Preloading
|
||||||
|
## a script and calling a static func does not run _init() — only `.new()` does
|
||||||
|
## — so this is safe to call from a test without side effects.
|
||||||
|
## §2: presentation.
|
||||||
|
|
||||||
|
|
||||||
|
const OUT := "res://assets/theme/game_theme.tres"
|
||||||
|
const SERIF := "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
|
||||||
|
const SERIF_ITALIC := "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf"
|
||||||
|
const ACCENT := "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
|
||||||
|
const MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
var t := build_theme()
|
||||||
|
var err := ResourceSaver.save(t, OUT)
|
||||||
|
if err != OK:
|
||||||
|
push_error("theme save failed: %d" % err)
|
||||||
|
else:
|
||||||
|
print("wrote ", OUT)
|
||||||
|
quit()
|
||||||
|
|
||||||
|
|
||||||
|
static func build_theme() -> Theme:
|
||||||
|
var theme := Theme.new()
|
||||||
|
_fonts(theme)
|
||||||
|
_title_type(theme)
|
||||||
|
_rich_text(theme)
|
||||||
|
_primary_cta(theme)
|
||||||
|
_tabs(theme)
|
||||||
|
_dock_button(theme)
|
||||||
|
_parchment_button(theme)
|
||||||
|
_chip(theme)
|
||||||
|
_section_label(theme)
|
||||||
|
_select_card(theme)
|
||||||
|
_skill_chip(theme)
|
||||||
|
_skill_chip_granted(theme)
|
||||||
|
_tags_and_primary(theme)
|
||||||
|
_tiles(theme)
|
||||||
|
_cards_and_panels(theme)
|
||||||
|
return theme
|
||||||
|
|
||||||
|
|
||||||
|
static func _pill(bg: Color, radius: int, margin_x: int, margin_y: int) -> StyleBoxFlat:
|
||||||
|
# A BADGE, not a card. _flat() bakes in the 12/6 content margin every panel and
|
||||||
|
# button on the sheet wants — on a 9px "PRIMARY" flag that padding is most of the
|
||||||
|
# control, which is what made the badge swallow the ability card's rolled value.
|
||||||
|
# The mock's flags are padding:2px 9px (PRIMARY) and 3px 8px (CHOSEN).
|
||||||
|
var s := StyleBoxFlat.new()
|
||||||
|
s.bg_color = bg
|
||||||
|
s.border_color = bg
|
||||||
|
s.set_border_width_all(0)
|
||||||
|
s.set_corner_radius_all(radius)
|
||||||
|
s.content_margin_left = margin_x
|
||||||
|
s.content_margin_right = margin_x
|
||||||
|
s.content_margin_top = margin_y
|
||||||
|
s.content_margin_bottom = margin_y
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
static func _flat(bg: Color, border: Color, width := 1, radius := 4) -> StyleBoxFlat:
|
||||||
|
var s := StyleBoxFlat.new()
|
||||||
|
s.bg_color = bg
|
||||||
|
s.border_color = border
|
||||||
|
s.set_border_width_all(width)
|
||||||
|
s.set_corner_radius_all(radius)
|
||||||
|
s.content_margin_left = 12
|
||||||
|
s.content_margin_right = 12
|
||||||
|
s.content_margin_top = 6
|
||||||
|
s.content_margin_bottom = 6
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
static func _fonts(theme: Theme) -> void:
|
||||||
|
var serif: FontFile = load(SERIF)
|
||||||
|
# Global defaults: prose serif at body size.
|
||||||
|
theme.default_font = serif
|
||||||
|
theme.default_font_size = 18
|
||||||
|
# Heading role via the Label "Heading" variation.
|
||||||
|
theme.set_type_variation(ThemeKeys.HEADING, "Label")
|
||||||
|
theme.set_font(&"font", ThemeKeys.HEADING, serif)
|
||||||
|
theme.set_font_size(&"font_size", ThemeKeys.HEADING, 34)
|
||||||
|
theme.set_color(&"font_color", ThemeKeys.HEADING, Palette.INK_HEADING)
|
||||||
|
# Accent (hand-inked) role.
|
||||||
|
theme.set_type_variation(ThemeKeys.ACCENT, "Label")
|
||||||
|
theme.set_font(&"font", ThemeKeys.ACCENT, load(ACCENT))
|
||||||
|
theme.set_font_size(&"font_size", ThemeKeys.ACCENT, 24)
|
||||||
|
theme.set_color(&"font_color", ThemeKeys.ACCENT, Palette.INK_GOLD)
|
||||||
|
# Mono chrome role.
|
||||||
|
theme.set_type_variation(ThemeKeys.MONO, "Label")
|
||||||
|
theme.set_font(&"font", ThemeKeys.MONO, load(MONO))
|
||||||
|
theme.set_font_size(&"font_size", ThemeKeys.MONO, 14)
|
||||||
|
theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO)
|
||||||
|
# Card type — the name + blurb inside a select-card. These sit ON PARCHMENT, so
|
||||||
|
# they take INK, not CREAM. Godot's built-in default Label colour is WHITE, and a
|
||||||
|
# Label left bare inherits it: the race and calling names shipped invisible on the
|
||||||
|
# parchment sheet for exactly that reason. There is no such thing as an unstyled
|
||||||
|
# Label on this sheet — every one of them names a role. (mock: 19px/17px #3a2f1c
|
||||||
|
# title, 13px italic-serif #6a5a3a blurb.)
|
||||||
|
theme.set_type_variation(ThemeKeys.CARD_TITLE, "Label")
|
||||||
|
theme.set_font(&"font", ThemeKeys.CARD_TITLE, serif)
|
||||||
|
theme.set_font_size(&"font_size", ThemeKeys.CARD_TITLE, 20)
|
||||||
|
theme.set_color(&"font_color", ThemeKeys.CARD_TITLE, Palette.INK_HEADING)
|
||||||
|
theme.set_type_variation(ThemeKeys.CARD_BODY, "Label")
|
||||||
|
theme.set_font(&"font", ThemeKeys.CARD_BODY, serif)
|
||||||
|
theme.set_font_size(&"font_size", ThemeKeys.CARD_BODY, 14)
|
||||||
|
theme.set_color(&"font_color", ThemeKeys.CARD_BODY, Palette.INK_MUTED)
|
||||||
|
|
||||||
|
|
||||||
|
static func _title_type(theme: Theme) -> void:
|
||||||
|
# Title-screen display type (mock Title Screen): the big serif logo + the mono
|
||||||
|
# kicker above it. Label font-role variations (font/size/colour), the same
|
||||||
|
# shape as Heading/Accent/Mono.
|
||||||
|
theme.set_type_variation(ThemeKeys.TITLE_LOGO, "Label")
|
||||||
|
theme.set_font(&"font", ThemeKeys.TITLE_LOGO, load(SERIF))
|
||||||
|
theme.set_font_size(&"font_size", ThemeKeys.TITLE_LOGO, 116)
|
||||||
|
theme.set_color(&"font_color", ThemeKeys.TITLE_LOGO, Palette.CREAM)
|
||||||
|
theme.set_type_variation(ThemeKeys.TITLE_KICKER, "Label")
|
||||||
|
theme.set_font(&"font", ThemeKeys.TITLE_KICKER, load(MONO))
|
||||||
|
theme.set_font_size(&"font_size", ThemeKeys.TITLE_KICKER, 13)
|
||||||
|
theme.set_color(&"font_color", ThemeKeys.TITLE_KICKER, Palette.GOLD)
|
||||||
|
|
||||||
|
|
||||||
|
static func _rich_text(theme: Theme) -> void:
|
||||||
|
# Base RichTextLabel styling — the narrative "prose" voice (mock README §Typography:
|
||||||
|
# Georgia/serif for DM/flavor body, italic for emphasis). RichTextLabel does NOT
|
||||||
|
# inherit default_font/default_color, so without this its text renders in the engine
|
||||||
|
# default (near-white) and is unreadable on parchment. Every parchment prose surface
|
||||||
|
# (the narration book, later quest briefs/dialogue) reads dark serif ink from here.
|
||||||
|
theme.set_font(&"normal_font", "RichTextLabel", load(SERIF))
|
||||||
|
theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC))
|
||||||
|
theme.set_font_size(&"normal_font_size", "RichTextLabel", 22)
|
||||||
|
theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY)
|
||||||
|
|
||||||
|
|
||||||
|
static func _primary_cta(theme: Theme) -> void:
|
||||||
|
var k := ThemeKeys.PRIMARY_CTA
|
||||||
|
theme.set_type_variation(k, "Button")
|
||||||
|
theme.set_stylebox(&"normal", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 4))
|
||||||
|
theme.set_stylebox(&"hover", k, _flat(Palette.BLOOD_BRIGHT, Palette.BLOOD, 1, 4))
|
||||||
|
theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD_DARK, Palette.BLOOD, 1, 4))
|
||||||
|
# Unaffordable/disabled CTA state (2a shell needs this) — desaturated/greyed.
|
||||||
|
theme.set_stylebox(&"disabled", k, _flat(Palette.MUTED_MONO_DIM, Palette.MUTED_MONO_DIM, 1, 4))
|
||||||
|
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
|
||||||
|
theme.set_color(&"font_disabled_color", k, Palette.MUTED_MONO)
|
||||||
|
|
||||||
|
|
||||||
|
static func _tabs(theme: Theme) -> void:
|
||||||
|
var tab := ThemeKeys.TAB
|
||||||
|
theme.set_type_variation(tab, "Button")
|
||||||
|
theme.set_stylebox(&"normal", tab, _flat(Palette.TAB_INACTIVE_BG, Palette.PARCHMENT_BORDER_2, 1, 14))
|
||||||
|
theme.set_stylebox(&"hover", tab, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_2, 1, 14))
|
||||||
|
theme.set_stylebox(&"pressed", tab, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 1, 14))
|
||||||
|
theme.set_stylebox(&"focus", tab, StyleBoxEmpty.new())
|
||||||
|
theme.set_color(&"font_color", tab, Palette.INK_MUTED)
|
||||||
|
|
||||||
|
var tab_active := ThemeKeys.TAB_ACTIVE
|
||||||
|
theme.set_type_variation(tab_active, "Button")
|
||||||
|
theme.set_stylebox(&"normal", tab_active, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
|
||||||
|
theme.set_stylebox(&"hover", tab_active, _flat(Palette.BLOOD_BRIGHT, Palette.BLOOD_BRIGHT, 1, 14))
|
||||||
|
theme.set_stylebox(&"pressed", tab_active, _flat(Palette.BLOOD_DARK, Palette.BLOOD, 1, 14))
|
||||||
|
theme.set_stylebox(&"focus", tab_active, StyleBoxEmpty.new())
|
||||||
|
theme.set_color(&"font_color", tab_active, Palette.CREAM_BRIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
static func _dock_button(theme: Theme) -> void:
|
||||||
|
# Dark nav button for the slide-in system dock (mock 2a). Button-base so
|
||||||
|
# normal/hover/pressed styleboxes actually apply (a PanelContainer variation's
|
||||||
|
# lone `panel` stylebox does not skin a Button). Colours are dock-dark.
|
||||||
|
var k := ThemeKeys.DOCK_BUTTON
|
||||||
|
theme.set_type_variation(k, "Button")
|
||||||
|
theme.set_stylebox(&"normal", k, _flat(Palette.DARK_PANEL_70, Palette.BORDER_1, 1, 8))
|
||||||
|
theme.set_stylebox(&"hover", k, _flat(Palette.DARK_PANEL_85, Palette.GOLD, 1, 8))
|
||||||
|
theme.set_stylebox(&"pressed", k, _flat(Palette.DARK_PANEL_90, Palette.GOLD, 1, 8))
|
||||||
|
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||||
|
theme.set_color(&"font_color", k, Palette.CREAM_SECONDARY)
|
||||||
|
|
||||||
|
|
||||||
|
static func _parchment_button(theme: Theme) -> void:
|
||||||
|
# Clickable parchment row/card for the narration book (mock 2a response
|
||||||
|
# choices + the refire affordance). Button-base so normal/hover/pressed/
|
||||||
|
# disabled styleboxes actually apply (a PanelContainer variation's lone
|
||||||
|
# `panel` stylebox does not skin a Button). Colours are the mock's choice-card.
|
||||||
|
var k := ThemeKeys.PARCHMENT_BUTTON
|
||||||
|
theme.set_type_variation(k, "Button")
|
||||||
|
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 8))
|
||||||
|
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 8))
|
||||||
|
theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_INSET_2, Palette.INK_LABEL, 1, 8))
|
||||||
|
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 8))
|
||||||
|
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||||
|
theme.set_color(&"font_color", k, Palette.INK_BODY)
|
||||||
|
theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED)
|
||||||
|
|
||||||
|
|
||||||
|
static func _chip(theme: Theme) -> void:
|
||||||
|
# Base chip; the semantic colour (red/gold/grey) is applied per-use by script.
|
||||||
|
var k := ThemeKeys.CHIP
|
||||||
|
theme.set_type_variation(k, "Button")
|
||||||
|
theme.set_stylebox(&"normal", k, _flat(Color(Palette.MUTED_MONO, 0.12), Palette.MUTED_MONO, 1, 3))
|
||||||
|
theme.set_stylebox(&"hover", k, _flat(Color(Palette.MUTED_MONO, 0.22), Palette.MUTED_MONO, 1, 3))
|
||||||
|
theme.set_stylebox(&"pressed", k, _flat(Color(Palette.MUTED_MONO, 0.32), Palette.MUTED_MONO, 1, 3))
|
||||||
|
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||||
|
theme.set_font(&"font", k, load(MONO))
|
||||||
|
theme.set_font_size(&"font_size", k, 12)
|
||||||
|
theme.set_color(&"font_color", k, Palette.MUTED_MONO)
|
||||||
|
|
||||||
|
|
||||||
|
static func _section_label(theme: Theme) -> void:
|
||||||
|
# The mono section headers on the parchment sheet (RACE / CALLING / NAME …).
|
||||||
|
# Mono's own colour is for dark surfaces; on parchment it needs the muted ink.
|
||||||
|
theme.set_type_variation(ThemeKeys.SECTION_LABEL, "Label")
|
||||||
|
theme.set_font(&"font", ThemeKeys.SECTION_LABEL, load(MONO))
|
||||||
|
theme.set_font_size(&"font_size", ThemeKeys.SECTION_LABEL, 14)
|
||||||
|
theme.set_color(&"font_color", ThemeKeys.SECTION_LABEL, Palette.INK_LABEL_MUTED)
|
||||||
|
|
||||||
|
|
||||||
|
static func _select_card(theme: Theme) -> void:
|
||||||
|
# Race + calling cards. Button-base (a PanelContainer variation's lone `panel`
|
||||||
|
# stylebox does not skin a Button — ADR 0001). `pressed` IS the CHOSEN state:
|
||||||
|
# the card is a toggle in a ButtonGroup, so `pressed` persists after the click.
|
||||||
|
var k := ThemeKeys.SELECT_CARD
|
||||||
|
theme.set_type_variation(k, "Button")
|
||||||
|
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 12))
|
||||||
|
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 12))
|
||||||
|
theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_CARD, Palette.BLOOD, 3, 12))
|
||||||
|
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 12))
|
||||||
|
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||||
|
theme.set_color(&"font_color", k, Palette.INK_HEADING)
|
||||||
|
theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED)
|
||||||
|
|
||||||
|
|
||||||
|
static func _skill_chip(theme: Theme) -> void:
|
||||||
|
# A proficiency chip is a TOGGLE with three states the player must be able to
|
||||||
|
# read at a glance: unpicked (parchment), picked (crimson), and granted-by-race
|
||||||
|
# (dim + inert — the elf already has perception, so the chip cannot be pressed).
|
||||||
|
var k := ThemeKeys.SKILL_CHIP
|
||||||
|
theme.set_type_variation(k, "Button")
|
||||||
|
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 14))
|
||||||
|
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 14))
|
||||||
|
theme.set_stylebox(&"pressed", k, _flat(Palette.BLOOD, Palette.BLOOD, 1, 14))
|
||||||
|
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 14))
|
||||||
|
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||||
|
theme.set_font(&"font", k, load(MONO))
|
||||||
|
theme.set_font_size(&"font_size", k, 14)
|
||||||
|
theme.set_color(&"font_color", k, Palette.INK_LABEL)
|
||||||
|
theme.set_color(&"font_pressed_color", k, Palette.CREAM_BRIGHT)
|
||||||
|
theme.set_color(&"font_hover_color", k, Palette.INK_LABEL)
|
||||||
|
theme.set_color(&"font_disabled_color", k, Palette.PARCHMENT_BORDER_3)
|
||||||
|
|
||||||
|
|
||||||
|
static func _skill_chip_granted(theme: Theme) -> void:
|
||||||
|
# The FOURTH skill-chip state, split out of SkillChip's dim `disabled` look. A
|
||||||
|
# race-GRANTED skill (the elf already has perception) must read "you already have
|
||||||
|
# this" — OWNED — not "unavailable". So: a solid gold fill with cream text (the
|
||||||
|
# binding prefixes a ✓), where the pool-is-spent chips keep the plain dim disabled.
|
||||||
|
# It is inert like a disabled chip, so every interactive state paints the owned box.
|
||||||
|
var k := ThemeKeys.SKILL_CHIP_GRANTED
|
||||||
|
theme.set_type_variation(k, "Button")
|
||||||
|
var owned := _flat(Palette.INK_GOLD, Palette.INK_GOLD, 1, 14)
|
||||||
|
theme.set_stylebox(&"normal", k, owned)
|
||||||
|
theme.set_stylebox(&"hover", k, owned)
|
||||||
|
theme.set_stylebox(&"pressed", k, owned)
|
||||||
|
theme.set_stylebox(&"disabled", k, owned)
|
||||||
|
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||||
|
theme.set_font(&"font", k, load(MONO))
|
||||||
|
theme.set_font_size(&"font_size", k, 14)
|
||||||
|
theme.set_color(&"font_color", k, Palette.CREAM_BRIGHT)
|
||||||
|
theme.set_color(&"font_disabled_color", k, Palette.CREAM_BRIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
static func _tags_and_primary(theme: Theme) -> void:
|
||||||
|
# The two little flags that ride on a card's top edge, and the gold ring the
|
||||||
|
# calling's primary attribute wears. Label takes a `normal` stylebox in Godot 4.
|
||||||
|
var chosen := ThemeKeys.CHOSEN_TAG
|
||||||
|
theme.set_type_variation(chosen, "Label")
|
||||||
|
theme.set_stylebox(&"normal", chosen, _pill(Palette.BLOOD, 5, 8, 3))
|
||||||
|
theme.set_font(&"font", chosen, load(MONO))
|
||||||
|
theme.set_font_size(&"font_size", chosen, 11)
|
||||||
|
theme.set_color(&"font_color", chosen, Palette.CREAM_BRIGHT)
|
||||||
|
|
||||||
|
var primary_tag := ThemeKeys.PRIMARY_TAG
|
||||||
|
theme.set_type_variation(primary_tag, "Label")
|
||||||
|
theme.set_stylebox(&"normal", primary_tag, _pill(Palette.INK_GOLD, 5, 9, 2))
|
||||||
|
theme.set_font(&"font", primary_tag, load(MONO))
|
||||||
|
theme.set_font_size(&"font_size", primary_tag, 10)
|
||||||
|
theme.set_color(&"font_color", primary_tag, Palette.CREAM_BRIGHT)
|
||||||
|
|
||||||
|
# The ability card in both states. It is NOT a plain ParchmentCard: it wears the PRIMARY
|
||||||
|
# flag on its top edge, and _flat()'s 6px top margin puts the "STR" key label directly
|
||||||
|
# under the badge — they collided. The mock's card is padding:16px 10px 14px, and that
|
||||||
|
# 16px IS the headroom the badge needs. ParchmentCard cannot simply be retuned: the
|
||||||
|
# shell shares it, and the shell has no badge.
|
||||||
|
theme.set_stylebox(&"panel", ThemeKeys.ABILITY_CARD,
|
||||||
|
_ability_card(Palette.PARCHMENT_BORDER, 1))
|
||||||
|
theme.set_type_variation(ThemeKeys.ABILITY_CARD, "PanelContainer")
|
||||||
|
theme.set_type_variation(ThemeKeys.PRIMARY_CARD, "PanelContainer")
|
||||||
|
theme.set_stylebox(&"panel", ThemeKeys.PRIMARY_CARD,
|
||||||
|
_ability_card(Palette.INK_GOLD, 2))
|
||||||
|
|
||||||
|
|
||||||
|
static func _ability_card(border: Color, width: int) -> StyleBoxFlat:
|
||||||
|
var s := _flat(Palette.PARCHMENT_CARD, border, width, 12)
|
||||||
|
s.content_margin_top = 16
|
||||||
|
s.content_margin_bottom = 14
|
||||||
|
s.content_margin_left = 10
|
||||||
|
s.content_margin_right = 10
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
static func _tiles(theme: Theme) -> void:
|
||||||
|
var filled := _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER, 2, 6)
|
||||||
|
theme.set_type_variation(ThemeKeys.ITEM_TILE, "PanelContainer")
|
||||||
|
theme.set_stylebox(&"panel", ThemeKeys.ITEM_TILE, filled)
|
||||||
|
var empty := _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 6)
|
||||||
|
empty.draw_center = false
|
||||||
|
theme.set_type_variation(ThemeKeys.ITEM_TILE_EMPTY, "PanelContainer")
|
||||||
|
theme.set_stylebox(&"panel", ThemeKeys.ITEM_TILE_EMPTY, empty)
|
||||||
|
|
||||||
|
|
||||||
|
static func _cards_and_panels(theme: Theme) -> void:
|
||||||
|
theme.set_type_variation(ThemeKeys.PARCHMENT_CARD, "PanelContainer")
|
||||||
|
theme.set_stylebox(&"panel", ThemeKeys.PARCHMENT_CARD, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER, 1, 6))
|
||||||
|
theme.set_type_variation(ThemeKeys.PARCHMENT_INSET, "PanelContainer")
|
||||||
|
theme.set_stylebox(&"panel", ThemeKeys.PARCHMENT_INSET, _flat(Palette.PARCHMENT_INSET, Palette.PARCHMENT_BORDER_3, 1, 4))
|
||||||
|
theme.set_type_variation(ThemeKeys.DARK_PANEL, "PanelContainer")
|
||||||
|
theme.set_stylebox(&"panel", ThemeKeys.DARK_PANEL, _flat(Palette.DARK_PANEL_70, Palette.BORDER_4, 1, 4))
|
||||||
1
client/scripts/theme/build_game_theme.gd.uid
Normal file
1
client/scripts/theme/build_game_theme.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://drualc64s2x3p
|
||||||
94
client/scripts/theme/palette.gd
Normal file
94
client/scripts/theme/palette.gd
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
class_name Palette
|
||||||
|
extends RefCounted
|
||||||
|
## The single source of truth for every colour token (mockups README, "Color
|
||||||
|
## tokens"). §2: presentation — owns no state. No hex literal may live anywhere
|
||||||
|
## else in the client; every script, shader, and the Theme builder reads here.
|
||||||
|
|
||||||
|
# --- Dark backgrounds ---
|
||||||
|
const STAGE_1 := Color("0a0806")
|
||||||
|
const STAGE_2 := Color("0f0d0b")
|
||||||
|
const BAY_STRIPE_A := Color("26221c")
|
||||||
|
const BAY_STRIPE_B := Color("221e18")
|
||||||
|
const DARK_PANEL_70 := Color("120f0b", 0.7)
|
||||||
|
const DARK_PANEL_85 := Color("0e0b09", 0.85)
|
||||||
|
const DARK_PANEL_90 := Color("0a0806", 0.9)
|
||||||
|
# border ladder, light -> dark
|
||||||
|
const BORDER_1 := Color("6b6152")
|
||||||
|
const BORDER_2 := Color("5a5348")
|
||||||
|
const BORDER_3 := Color("4a4238")
|
||||||
|
const BORDER_4 := Color("3a352e")
|
||||||
|
const BORDER_5 := Color("1a1512")
|
||||||
|
|
||||||
|
# --- Title-screen ambiance (warm radial background) ---
|
||||||
|
const TITLE_WARM := Color("2a2018") # radial centre
|
||||||
|
const TITLE_MID := Color("160f0a") # radial midpoint (STAGE_1 is the edge)
|
||||||
|
|
||||||
|
# --- Parchment ---
|
||||||
|
const SHEET_TOP := Color("ece2ca")
|
||||||
|
const SHEET_BOTTOM := Color("e4d8bd")
|
||||||
|
const LOG_TOP := Color("e7dcc4")
|
||||||
|
const LOG_BOTTOM := Color("ddd0b2")
|
||||||
|
const PARCHMENT_CARD := Color("f1e8d2")
|
||||||
|
const PARCHMENT_INSET := Color("e6d9b8")
|
||||||
|
const PARCHMENT_INSET_2 := Color("d9c9a2")
|
||||||
|
const PARCHMENT_BORDER := Color("bfa878")
|
||||||
|
const PARCHMENT_BORDER_2 := Color("cbb684")
|
||||||
|
const PARCHMENT_BORDER_3 := Color("a99464")
|
||||||
|
const TAB_INACTIVE_BG := Color("efe6cf")
|
||||||
|
|
||||||
|
# --- Ink (text on parchment) ---
|
||||||
|
const INK_HEADING := Color("3a2f1c")
|
||||||
|
const INK_HEADING_2 := Color("4a3520")
|
||||||
|
const INK_BODY := Color("4a3f2c")
|
||||||
|
const INK_MUTED := Color("6a5a3a")
|
||||||
|
const INK_LABEL := Color("5a4326")
|
||||||
|
const INK_GOLD := Color("8f6a2a")
|
||||||
|
const INK_LABEL_MUTED := Color("8a7748") # section labels on parchment (creation sheet)
|
||||||
|
|
||||||
|
# --- Text on dark ---
|
||||||
|
const CREAM := Color("e8ddc8")
|
||||||
|
const CREAM_BRIGHT := Color("f0e6d0")
|
||||||
|
const CREAM_SECONDARY := Color("c9bda3")
|
||||||
|
const MUTED_MONO := Color("9a8f78")
|
||||||
|
const MUTED_MONO_DIM := Color("7a7060")
|
||||||
|
|
||||||
|
# --- Accents (muted for grit) ---
|
||||||
|
const BLOOD := Color("8f3a34") # core CTA / danger / selection / enemies
|
||||||
|
const BLOOD_BRIGHT := Color("b8524a") # hover
|
||||||
|
const BLOOD_DARK := Color("7a2f29") # CTA gradient bottom
|
||||||
|
const GOLD := Color("a9843f") # highlight / currency / current-turn
|
||||||
|
const GOLD_BRIGHT := Color("c8963f")
|
||||||
|
const STEEL := Color("5f8a9a") # allies / mana
|
||||||
|
const STEEL_BLUE := Color("4f7ab8")
|
||||||
|
const STEEL_DEEP := Color("243f6f")
|
||||||
|
const GREEN := Color("5f8a4f") # stamina / success / cleared
|
||||||
|
|
||||||
|
# --- Stat bars ---
|
||||||
|
const HP_A := Color("b8524a")
|
||||||
|
const HP_B := Color("8f3a34")
|
||||||
|
const MP_A := Color("4f7ab8")
|
||||||
|
const MP_B := Color("243f6f")
|
||||||
|
const STAMINA := Color("8f9a4f")
|
||||||
|
const BAR_TRACK := Color("2a251f")
|
||||||
|
const BAR_BORDER := Color("4a4238")
|
||||||
|
|
||||||
|
# --- Rarity (ordered common..legendary) ---
|
||||||
|
const RARITY: Array[Color] = [
|
||||||
|
Color("8a8378"), # 0 common
|
||||||
|
Color("5f8a4f"), # 1 uncommon
|
||||||
|
Color("4f7ab8"), # 2 rare
|
||||||
|
Color("8a5fb8"), # 3 epic
|
||||||
|
Color("c8963f"), # 4 legendary
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
static func rarity_color(rarity: int) -> Color:
|
||||||
|
return RARITY[clampi(rarity, 0, RARITY.size() - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
static func stat_bar(kind: StringName) -> Array:
|
||||||
|
match kind:
|
||||||
|
&"hp": return [HP_A, HP_B]
|
||||||
|
&"mp": return [MP_A, MP_B]
|
||||||
|
&"stamina": return [STAMINA, STAMINA]
|
||||||
|
_: return [MUTED_MONO, MUTED_MONO_DIM]
|
||||||
1
client/scripts/theme/palette.gd.uid
Normal file
1
client/scripts/theme/palette.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d4bext4g7reql
|
||||||
14
client/scripts/theme/surfaces/dark_bay.gd
Normal file
14
client/scripts/theme/surfaces/dark_bay.gd
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
class_name DarkBay
|
||||||
|
extends Panel
|
||||||
|
## Drop-in dark stripe+vignette bay backdrop. §2: presentation.
|
||||||
|
## Uniforms are set at runtime from Palette (deliberate deviation from spec §5.4's
|
||||||
|
## "baked into the .tscn/material" approach) — keeps Palette the single source of
|
||||||
|
## truth at the cost of an editor preview; the surface renders once the scene runs.
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var m := ShaderMaterial.new()
|
||||||
|
m.shader = load("res://assets/theme/shaders/dark_bay.gdshader")
|
||||||
|
m.set_shader_parameter("stripe_a", Palette.BAY_STRIPE_A)
|
||||||
|
m.set_shader_parameter("stripe_b", Palette.BAY_STRIPE_B)
|
||||||
|
material = m
|
||||||
1
client/scripts/theme/surfaces/dark_bay.gd.uid
Normal file
1
client/scripts/theme/surfaces/dark_bay.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://24c8pl0yx8wu
|
||||||
14
client/scripts/theme/surfaces/parchment_panel.gd
Normal file
14
client/scripts/theme/surfaces/parchment_panel.gd
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
class_name ParchmentPanel
|
||||||
|
extends Panel
|
||||||
|
## Drop-in parchment backdrop (mockups "Dark Bay + Parchment" motif). §2: presentation.
|
||||||
|
## Uniforms are set at runtime from Palette (deliberate deviation from spec §5.4's
|
||||||
|
## "baked into the .tscn/material" approach) — keeps Palette the single source of
|
||||||
|
## truth at the cost of an editor preview; the surface renders once the scene runs.
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var m := ShaderMaterial.new()
|
||||||
|
m.shader = load("res://assets/theme/shaders/parchment.gdshader")
|
||||||
|
m.set_shader_parameter("top", Palette.SHEET_TOP)
|
||||||
|
m.set_shader_parameter("bottom", Palette.SHEET_BOTTOM)
|
||||||
|
material = m
|
||||||
1
client/scripts/theme/surfaces/parchment_panel.gd.uid
Normal file
1
client/scripts/theme/surfaces/parchment_panel.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cnv1r00ane2la
|
||||||
16
client/scripts/theme/surfaces/vignette_overlay.gd
Normal file
16
client/scripts/theme/surfaces/vignette_overlay.gd
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class_name VignetteOverlay
|
||||||
|
extends ColorRect
|
||||||
|
## Drop-in edge-dim overlay for pause/dialogue dim. §2: presentation.
|
||||||
|
## The ShaderMaterial (and, for the other two surfaces, its Palette-sourced
|
||||||
|
## uniforms) is built at runtime in _ready() rather than baked into the .tscn
|
||||||
|
## (deliberate deviation from spec §5.4's "baked" approach) — keeps Palette the
|
||||||
|
## single source of truth at the cost of an editor preview; the surface renders
|
||||||
|
## once the scene runs.
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
color = Color(0, 0, 0, 0) # base transparent; shader draws the dim
|
||||||
|
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
var m := ShaderMaterial.new()
|
||||||
|
m.shader = load("res://assets/theme/shaders/vignette.gdshader")
|
||||||
|
material = m
|
||||||
1
client/scripts/theme/surfaces/vignette_overlay.gd.uid
Normal file
1
client/scripts/theme/surfaces/vignette_overlay.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://7bc7agrl6oi0
|
||||||
94
client/scripts/theme/theme_keys.gd
Normal file
94
client/scripts/theme/theme_keys.gd
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# client/scripts/theme/theme_keys.gd
|
||||||
|
class_name ThemeKeys
|
||||||
|
extends RefCounted
|
||||||
|
## Type-variation names for game_theme.tres. Scripts set `theme_type_variation`
|
||||||
|
## to one of these so a typo is a compile-time symbol error, not a silent miss.
|
||||||
|
## §2: presentation.
|
||||||
|
|
||||||
|
const PRIMARY_CTA := &"PrimaryCTA"
|
||||||
|
const TAB := &"Tab"
|
||||||
|
const TAB_ACTIVE := &"TabActive"
|
||||||
|
const CHIP := &"Chip"
|
||||||
|
const DOCK_BUTTON := &"DockButton"
|
||||||
|
const PARCHMENT_BUTTON := &"ParchmentButton"
|
||||||
|
const ITEM_TILE := &"ItemTile"
|
||||||
|
const ITEM_TILE_EMPTY := &"ItemTileEmpty"
|
||||||
|
const PARCHMENT_CARD := &"ParchmentCard"
|
||||||
|
const PARCHMENT_INSET := &"ParchmentInset"
|
||||||
|
const DARK_PANEL := &"DarkPanel"
|
||||||
|
const SELECT_CARD := &"SelectCard" # race/calling card: a Button whose `pressed` is CHOSEN
|
||||||
|
const SKILL_CHIP := &"SkillChip" # toggle: unpicked / picked / can't-pick-now (dim)
|
||||||
|
const SKILL_CHIP_GRANTED := &"SkillChipGranted" # a race-GRANTED skill: owned (gold + ✓), inert
|
||||||
|
const CHOSEN_TAG := &"ChosenTag" # the little crimson "CHOSEN" flag
|
||||||
|
const PRIMARY_TAG := &"PrimaryTag" # the little gold "PRIMARY" flag
|
||||||
|
const PRIMARY_CARD := &"PrimaryCard" # the ability card ringed as the calling's primary
|
||||||
|
const ABILITY_CARD := &"AbilityCard" # ...and its UNringed state. Not ParchmentCard: the
|
||||||
|
# ability card carries a badge on its top edge and
|
||||||
|
# needs the headroom for it (mock padding-top 16 vs
|
||||||
|
# the generic card's 6), and ParchmentCard is shared
|
||||||
|
# with the shell.
|
||||||
|
|
||||||
|
## Font roles (Label type-variations carrying a font, not a stylebox). Kept
|
||||||
|
## separate from ALL below, which is stylebox variations + their base type only.
|
||||||
|
const HEADING := &"Heading"
|
||||||
|
const ACCENT := &"Accent"
|
||||||
|
const MONO := &"Mono"
|
||||||
|
const TITLE_LOGO := &"TitleLogo"
|
||||||
|
const TITLE_KICKER := &"TitleKicker"
|
||||||
|
const SECTION_LABEL := &"SectionLabel"
|
||||||
|
const CARD_TITLE := &"CardTitle" # a select-card's name, dark ink ON PARCHMENT
|
||||||
|
const CARD_BODY := &"CardBody" # a select-card's blurb, muted ink ON PARCHMENT
|
||||||
|
|
||||||
|
## Every FONT role + the base Control type it decorates. Deliberately NOT folded
|
||||||
|
## into ALL — ALL's "stylebox variations only" contract is relied on elsewhere
|
||||||
|
## (test_every_variation_resolves, and the styleboxes the screens set). Kept as a
|
||||||
|
## second enumerable set so a guard that means "every variation the builder
|
||||||
|
## touches" can iterate BOTH and never silently cover only half of them: the
|
||||||
|
## drift guard used to iterate ALL alone, so a font, a font size or a font colour
|
||||||
|
## could change in the builder and the committed .tres stay stale, suite green.
|
||||||
|
const FONT_ROLES := {
|
||||||
|
HEADING: "Label",
|
||||||
|
ACCENT: "Label",
|
||||||
|
MONO: "Label",
|
||||||
|
TITLE_LOGO: "Label",
|
||||||
|
TITLE_KICKER: "Label",
|
||||||
|
SECTION_LABEL: "Label",
|
||||||
|
CARD_TITLE: "Label",
|
||||||
|
CARD_BODY: "Label",
|
||||||
|
}
|
||||||
|
|
||||||
|
## Base Control types the builder styles DIRECTLY — no set_type_variation is
|
||||||
|
## ever called for these, so a fresh Theme reports get_type_variation_base("")
|
||||||
|
## for them. Kept as its own set (not folded into ALL or FONT_ROLES) because a
|
||||||
|
## base type is not a variation: nothing sets `theme_type_variation` to
|
||||||
|
## "RichTextLabel" anywhere, the node's own class name is the lookup key.
|
||||||
|
## Today: RichTextLabel, the builder's prose voice (mock README Typography:
|
||||||
|
## serif body/italic emphasis) — the creation screen's origin panel and
|
||||||
|
## calling detail panel are RichTextLabels rendering this styling directly,
|
||||||
|
## i.e. every line of DM prose the screen shows.
|
||||||
|
const BASE_TYPES := {
|
||||||
|
&"RichTextLabel": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
## Every variation name + the base Control type it decorates. The builder and the
|
||||||
|
## test both iterate this so they can never drift apart.
|
||||||
|
const ALL := {
|
||||||
|
PRIMARY_CTA: "Button",
|
||||||
|
TAB: "Button",
|
||||||
|
TAB_ACTIVE: "Button",
|
||||||
|
CHIP: "Button",
|
||||||
|
DOCK_BUTTON: "Button",
|
||||||
|
PARCHMENT_BUTTON: "Button",
|
||||||
|
ITEM_TILE: "PanelContainer",
|
||||||
|
ITEM_TILE_EMPTY: "PanelContainer",
|
||||||
|
PARCHMENT_CARD: "PanelContainer",
|
||||||
|
PARCHMENT_INSET: "PanelContainer",
|
||||||
|
DARK_PANEL: "PanelContainer",
|
||||||
|
SELECT_CARD: "Button",
|
||||||
|
SKILL_CHIP: "Button",
|
||||||
|
SKILL_CHIP_GRANTED: "Button",
|
||||||
|
CHOSEN_TAG: "Label",
|
||||||
|
PRIMARY_TAG: "Label",
|
||||||
|
PRIMARY_CARD: "PanelContainer",
|
||||||
|
ABILITY_CARD: "PanelContainer",
|
||||||
|
}
|
||||||
1
client/scripts/theme/theme_keys.gd.uid
Normal file
1
client/scripts/theme/theme_keys.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://upoh5yt3dd4i
|
||||||
214
client/scripts/theme/theme_showcase.gd
Normal file
214
client/scripts/theme/theme_showcase.gd
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# client/scripts/theme/theme_showcase.gd
|
||||||
|
extends Control
|
||||||
|
## The eyeball proof for the shared Theme (spec §7). Builds every token, stylebox,
|
||||||
|
## font, surface, and bar on one scrollable screen. No AI, no state. §2: presentation.
|
||||||
|
const GAME_THEME := "res://assets/theme/game_theme.tres"
|
||||||
|
const SERIF_ITALIC := "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf"
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
theme = load(GAME_THEME)
|
||||||
|
# A root Control run directly (F6) is not reliably sized to the viewport by
|
||||||
|
# full-rect anchors — it collapses to (0,0), clipping the ScrollContainer and
|
||||||
|
# hiding everything. Size to the viewport explicitly (no full-rect anchors here:
|
||||||
|
# they conflict with setting `size` directly) and track resizes.
|
||||||
|
_fit_to_viewport()
|
||||||
|
get_viewport().size_changed.connect(_fit_to_viewport)
|
||||||
|
var scroll := ScrollContainer.new()
|
||||||
|
scroll.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(scroll)
|
||||||
|
var col := VBoxContainer.new()
|
||||||
|
col.custom_minimum_size = Vector2(1900, 0)
|
||||||
|
col.add_theme_constant_override("separation", 16)
|
||||||
|
scroll.add_child(col)
|
||||||
|
|
||||||
|
col.add_child(_heading("SHARED THEME — SHOWCASE"))
|
||||||
|
col.add_child(_swatches())
|
||||||
|
col.add_child(_variations())
|
||||||
|
col.add_child(_fonts())
|
||||||
|
col.add_child(_prose())
|
||||||
|
col.add_child(_rarity())
|
||||||
|
col.add_child(_bars())
|
||||||
|
col.add_child(_surfaces())
|
||||||
|
|
||||||
|
|
||||||
|
func _fit_to_viewport() -> void:
|
||||||
|
size = get_viewport_rect().size
|
||||||
|
|
||||||
|
|
||||||
|
func _heading(text: String) -> Label:
|
||||||
|
var l := Label.new()
|
||||||
|
l.text = text
|
||||||
|
l.theme_type_variation = ThemeKeys.HEADING
|
||||||
|
return l
|
||||||
|
|
||||||
|
|
||||||
|
func _swatches() -> Control:
|
||||||
|
var grid := GridContainer.new()
|
||||||
|
grid.columns = 8
|
||||||
|
var tokens := [
|
||||||
|
["STAGE_1", Palette.STAGE_1], ["BAY_STRIPE_A", Palette.BAY_STRIPE_A],
|
||||||
|
["DARK_PANEL_70", Palette.DARK_PANEL_70], ["SHEET_TOP", Palette.SHEET_TOP],
|
||||||
|
["PARCHMENT_CARD", Palette.PARCHMENT_CARD], ["BLOOD", Palette.BLOOD],
|
||||||
|
["GOLD", Palette.GOLD], ["STEEL", Palette.STEEL], ["GREEN", Palette.GREEN],
|
||||||
|
["INK_BODY", Palette.INK_BODY], ["CREAM", Palette.CREAM],
|
||||||
|
]
|
||||||
|
for entry in tokens:
|
||||||
|
var col := VBoxContainer.new()
|
||||||
|
var r := ColorRect.new()
|
||||||
|
r.color = entry[1]
|
||||||
|
r.custom_minimum_size = Vector2(120, 60)
|
||||||
|
col.add_child(r)
|
||||||
|
var caption := Label.new()
|
||||||
|
caption.text = entry[0]
|
||||||
|
caption.theme_type_variation = ThemeKeys.MONO
|
||||||
|
col.add_child(caption)
|
||||||
|
grid.add_child(col)
|
||||||
|
return grid
|
||||||
|
|
||||||
|
|
||||||
|
func _variations() -> Control:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 12)
|
||||||
|
var cta := Button.new(); cta.text = "ENTER THE WORLD"; cta.theme_type_variation = ThemeKeys.PRIMARY_CTA
|
||||||
|
var tab := Button.new(); tab.text = "INACTIVE"; tab.theme_type_variation = ThemeKeys.TAB
|
||||||
|
var tab_a := Button.new(); tab_a.text = "ACTIVE"; tab_a.theme_type_variation = ThemeKeys.TAB_ACTIVE
|
||||||
|
# Neutral chip (grey) plus the semantic colours a script applies per-use
|
||||||
|
# (spec §5.3): red for aggressive/combat actions, gold for pay/faith.
|
||||||
|
var chip := Button.new(); chip.text = "STEALTH · DEX"; chip.theme_type_variation = ThemeKeys.CHIP
|
||||||
|
var chip_red := _semantic_chip("INTIMIDATE · STR", Palette.BLOOD)
|
||||||
|
var chip_gold := _semantic_chip("PAY ◈40", Palette.GOLD)
|
||||||
|
for b in [cta, tab, tab_a, chip, chip_red, chip_gold]:
|
||||||
|
row.add_child(b)
|
||||||
|
var card := PanelContainer.new(); card.theme_type_variation = ThemeKeys.PARCHMENT_CARD
|
||||||
|
card.custom_minimum_size = Vector2(160, 80)
|
||||||
|
row.add_child(card)
|
||||||
|
|
||||||
|
# Filled vs empty item tile, side by side for comparison.
|
||||||
|
var tile_filled := PanelContainer.new()
|
||||||
|
tile_filled.theme_type_variation = ThemeKeys.ITEM_TILE
|
||||||
|
tile_filled.custom_minimum_size = Vector2(64, 64)
|
||||||
|
row.add_child(tile_filled)
|
||||||
|
var tile_empty := PanelContainer.new()
|
||||||
|
tile_empty.theme_type_variation = ThemeKeys.ITEM_TILE_EMPTY
|
||||||
|
tile_empty.custom_minimum_size = Vector2(64, 64)
|
||||||
|
row.add_child(tile_empty)
|
||||||
|
|
||||||
|
var inset := PanelContainer.new()
|
||||||
|
inset.theme_type_variation = ThemeKeys.PARCHMENT_INSET
|
||||||
|
inset.custom_minimum_size = Vector2(160, 80)
|
||||||
|
row.add_child(inset)
|
||||||
|
|
||||||
|
var dark := PanelContainer.new()
|
||||||
|
dark.theme_type_variation = ThemeKeys.DARK_PANEL
|
||||||
|
dark.custom_minimum_size = Vector2(160, 80)
|
||||||
|
row.add_child(dark)
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
func _semantic_chip(text: String, color: Color) -> Button:
|
||||||
|
# Chip base is neutral (ThemeKeys.CHIP); the semantic colour (§5.3) is set
|
||||||
|
# by the consuming script, here via font-colour + border overrides.
|
||||||
|
var chip := Button.new()
|
||||||
|
chip.text = text
|
||||||
|
chip.theme_type_variation = ThemeKeys.CHIP
|
||||||
|
chip.add_theme_color_override("font_color", color)
|
||||||
|
var tinted := StyleBoxFlat.new()
|
||||||
|
tinted.bg_color = Color(color, 0.15)
|
||||||
|
tinted.border_color = color
|
||||||
|
tinted.set_border_width_all(1)
|
||||||
|
tinted.set_corner_radius_all(3)
|
||||||
|
tinted.content_margin_left = 12
|
||||||
|
tinted.content_margin_right = 12
|
||||||
|
tinted.content_margin_top = 6
|
||||||
|
tinted.content_margin_bottom = 6
|
||||||
|
chip.add_theme_stylebox_override("normal", tinted)
|
||||||
|
return chip
|
||||||
|
|
||||||
|
|
||||||
|
func _prose() -> Control:
|
||||||
|
# Narrative italic (spec §6) — the bundled EB Garamond italic face, shown
|
||||||
|
# nowhere else on this screen. RichTextLabel is required for bbcode italics;
|
||||||
|
# a plain Label cannot render [i].
|
||||||
|
var rtl := RichTextLabel.new()
|
||||||
|
rtl.bbcode_enabled = true
|
||||||
|
rtl.fit_content = true
|
||||||
|
rtl.custom_minimum_size = Vector2(900, 0)
|
||||||
|
rtl.add_theme_font_override("normal_font", load(GAME_THEME).default_font)
|
||||||
|
rtl.add_theme_font_override("italics_font", load(SERIF_ITALIC))
|
||||||
|
rtl.text = "[i]The chamber is cold. Something waits in the dark.[/i]"
|
||||||
|
return rtl
|
||||||
|
|
||||||
|
|
||||||
|
func _fonts() -> Control:
|
||||||
|
# All three bundled font families, at their real Theme sizes, plus the
|
||||||
|
# default serif body face (not otherwise shown elsewhere on this screen).
|
||||||
|
var col := VBoxContainer.new()
|
||||||
|
col.add_theme_constant_override("separation", 6)
|
||||||
|
|
||||||
|
var body := Label.new()
|
||||||
|
body.text = "The road to the Ashmarch is longer than the maps admit."
|
||||||
|
col.add_child(body)
|
||||||
|
|
||||||
|
var accent := Label.new()
|
||||||
|
accent.text = "Vexcca of the Ashmarch"
|
||||||
|
accent.theme_type_variation = ThemeKeys.ACCENT
|
||||||
|
col.add_child(accent)
|
||||||
|
|
||||||
|
var mono := Label.new()
|
||||||
|
mono.text = "STR 14 · INITIATIVE +2 · AC 16"
|
||||||
|
mono.theme_type_variation = ThemeKeys.MONO
|
||||||
|
col.add_child(mono)
|
||||||
|
|
||||||
|
return col
|
||||||
|
|
||||||
|
|
||||||
|
func _rarity() -> Control:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
for i in range(5):
|
||||||
|
var tile := PanelContainer.new()
|
||||||
|
tile.theme_type_variation = ThemeKeys.ITEM_TILE
|
||||||
|
tile.custom_minimum_size = Vector2(72, 72)
|
||||||
|
var bar := ColorRect.new()
|
||||||
|
bar.color = Palette.rarity_color(i)
|
||||||
|
bar.custom_minimum_size = Vector2(60, 4)
|
||||||
|
tile.add_child(bar)
|
||||||
|
row.add_child(tile)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
func _bars() -> Control:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
for kind in [&"hp", &"mp", &"stamina"]:
|
||||||
|
var pair = Palette.stat_bar(kind)
|
||||||
|
var bar := ColorRect.new()
|
||||||
|
bar.color = pair[0]
|
||||||
|
bar.custom_minimum_size = Vector2(200, 18)
|
||||||
|
row.add_child(bar)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
func _surfaces() -> Control:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.custom_minimum_size = Vector2(0, 240)
|
||||||
|
for path in ["res://scenes/theme/surfaces/ParchmentPanel.tscn",
|
||||||
|
"res://scenes/theme/surfaces/DarkBay.tscn"]:
|
||||||
|
var s = load(path).instantiate()
|
||||||
|
s.custom_minimum_size = Vector2(600, 220)
|
||||||
|
row.add_child(s)
|
||||||
|
|
||||||
|
# VignetteOverlay draws a black-with-alpha dim; give it a mid-tone backing
|
||||||
|
# so the dim is visibly rendering rather than reading as empty space.
|
||||||
|
var vignette_slot := Control.new()
|
||||||
|
vignette_slot.custom_minimum_size = Vector2(600, 220)
|
||||||
|
var backing := ColorRect.new()
|
||||||
|
backing.color = Palette.STEEL
|
||||||
|
backing.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
vignette_slot.add_child(backing)
|
||||||
|
var vignette = load("res://scenes/theme/surfaces/VignetteOverlay.tscn").instantiate()
|
||||||
|
vignette.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
vignette_slot.add_child(vignette)
|
||||||
|
row.add_child(vignette_slot)
|
||||||
|
|
||||||
|
return row
|
||||||
1
client/scripts/theme/theme_showcase.gd.uid
Normal file
1
client/scripts/theme/theme_showcase.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ciqf5caopnsh3
|
||||||
1
client/scripts/ui/considering_indicator.gd.uid
Normal file
1
client/scripts/ui/considering_indicator.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://be6fibq5rd367
|
||||||
1
client/scripts/ui/considering_phrases.gd.uid
Normal file
1
client/scripts/ui/considering_phrases.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://0yb5865chxnp
|
||||||
304
client/scripts/ui/creation/character_creation.gd
Normal file
304
client/scripts/ui/creation/character_creation.gd
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
class_name CharacterCreation
|
||||||
|
extends Control
|
||||||
|
## The creation screen (mock: Character Creation). Layout is authored in
|
||||||
|
## CharacterCreation.tscn — open it in the editor to arrange it (ADR 0001). This
|
||||||
|
## script does on-load work only: @onready refs, signal wiring, and binding the
|
||||||
|
## draft into AUTHORED nodes. It creates no nodes and it owns no rules.
|
||||||
|
##
|
||||||
|
## Every rule lives in CreationDraft; every display string derived from a rules
|
||||||
|
## table lives in CreationCopy; the prose lives in ContentDB. This file is glue.
|
||||||
|
##
|
||||||
|
## §2: the screen never hands an attribute value to anything. It emits a seed and
|
||||||
|
## four choices, and NewGame.construct re-rolls from that seed itself.
|
||||||
|
## §7: LCK appears nowhere here. Five ability cards. Never six.
|
||||||
|
|
||||||
|
signal creation_confirmed(creation: Dictionary)
|
||||||
|
|
||||||
|
## Shown under the CTA when the draft is legal. When it is NOT legal, the first
|
||||||
|
## validation error takes this slot instead — the player is told what is wrong, in
|
||||||
|
## the same place, in the same dry voice. §13: even the nag is content.
|
||||||
|
const CTA_HINT := "no second chances out there"
|
||||||
|
|
||||||
|
## Injection seams — set BETWEEN instantiate() and add_child(), as the shell does
|
||||||
|
## for DmService. _ready builds the real ones when nothing was supplied.
|
||||||
|
var world: ContentDB = null
|
||||||
|
var origin: Dictionary = {}
|
||||||
|
|
||||||
|
var draft: CreationDraft
|
||||||
|
|
||||||
|
var _race_cards: Array = []
|
||||||
|
var _calling_cards: Array = []
|
||||||
|
var _pool_chips: Array = []
|
||||||
|
var _bonus_chips: Array = []
|
||||||
|
var _ability_cards: Array = []
|
||||||
|
|
||||||
|
@onready var _plate_name: Label = $Split/Bay/Col/Nameplate/Name
|
||||||
|
@onready var _plate_sub: Label = $Split/Bay/Col/Nameplate/Sub
|
||||||
|
@onready var _origin_text: RichTextLabel = $Split/Bay/Col/OriginCard/Box/Text
|
||||||
|
@onready var _race_row: HBoxContainer = $Split/Sheet/Body/RaceSection/Cards
|
||||||
|
@onready var _calling_row: HBoxContainer = $Split/Sheet/Body/CallingSection/Cards
|
||||||
|
@onready var _detail_blurb: RichTextLabel = $Split/Sheet/Body/CallingSection/Detail/Box/Blurb
|
||||||
|
@onready var _detail_mech: Label = $Split/Sheet/Body/CallingSection/Detail/Box/Mechanics
|
||||||
|
@onready var _pool_row: HBoxContainer = $Split/Sheet/Body/SkillSection/PoolRow
|
||||||
|
@onready var _pool_count: Label = $Split/Sheet/Body/SkillSection/PoolRow/Count
|
||||||
|
@onready var _bonus_row: HFlowContainer = $Split/Sheet/Body/SkillSection/BonusRow
|
||||||
|
@onready var _ability_row: HBoxContainer = $Split/Sheet/Body/AbilitySection/Cards
|
||||||
|
@onready var _points: Label = $Split/Sheet/Body/AbilitySection/Head/Points
|
||||||
|
@onready var _reroll: Button = $Split/Sheet/Body/AbilitySection/Head/Reroll
|
||||||
|
@onready var _name_edit: LineEdit = $Split/Sheet/Body/Footer/NameBox/NameEdit
|
||||||
|
@onready var _enter: Button = $Split/Sheet/Body/Footer/CtaBox/Enter
|
||||||
|
@onready var _error: Label = $Split/Sheet/Body/Footer/CtaBox/Error
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# The .tscn root has a fixed size and NO full-rect anchors — a root Control run
|
||||||
|
# via F6 is not sized by full-rect anchors and collapses to (0,0) (the M3-a bug).
|
||||||
|
_fit_to_viewport()
|
||||||
|
get_viewport().size_changed.connect(_fit_to_viewport)
|
||||||
|
|
||||||
|
if world == null:
|
||||||
|
world = ContentDB.new()
|
||||||
|
world.load_from(ContentDB.default_content_root())
|
||||||
|
if origin.is_empty():
|
||||||
|
origin = ContentDB.load_json(ContentDB.origin_path("deserter"))
|
||||||
|
|
||||||
|
_collect_authored_nodes()
|
||||||
|
_wire()
|
||||||
|
|
||||||
|
draft = CreationDraft.fresh()
|
||||||
|
draft.set_race(Races.IDS[0])
|
||||||
|
var allowed := _allowed_callings()
|
||||||
|
if not allowed.is_empty():
|
||||||
|
draft.set_calling(allowed[0])
|
||||||
|
# A degenerate origin (zero allowed callings, or no build_constraints at all)
|
||||||
|
# leaves calling_id unset rather than crashing — §13, never an error thrown
|
||||||
|
# at the player. The CTA simply stays disabled; there is no calling to invent.
|
||||||
|
_bind()
|
||||||
|
|
||||||
|
|
||||||
|
func _fit_to_viewport() -> void:
|
||||||
|
size = get_viewport_rect().size
|
||||||
|
|
||||||
|
|
||||||
|
func _allowed_callings() -> Array:
|
||||||
|
## The ORIGIN gates the callings. The screen does not hardcode seven — a later
|
||||||
|
## origin may allow fewer, and construct would reject a calling it disallows.
|
||||||
|
var allowed: Array = origin.get("build_constraints", {}).get("allowed_callings", [])
|
||||||
|
var out: Array = []
|
||||||
|
for id in Callings.IDS: # Callings.IDS fixes the ORDER; the origin fixes the SET
|
||||||
|
if id in allowed:
|
||||||
|
out.append(id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _collect_authored_nodes() -> void:
|
||||||
|
# Fixed authored nodes, gathered — never created (ADR 0001). Spares hide.
|
||||||
|
for i in range(4):
|
||||||
|
_race_cards.append(_race_row.get_node("Race%d" % i))
|
||||||
|
for i in range(7):
|
||||||
|
_calling_cards.append(_calling_row.get_node("Calling%d" % i))
|
||||||
|
for i in range(6):
|
||||||
|
_pool_chips.append(_pool_row.get_node("Pool%d" % i))
|
||||||
|
for i in range(9):
|
||||||
|
_bonus_chips.append(_bonus_row.get_node("Bonus%d" % i))
|
||||||
|
for i in range(5):
|
||||||
|
_ability_cards.append(_ability_row.get_node("Ab%d" % i))
|
||||||
|
|
||||||
|
|
||||||
|
func _wire() -> void:
|
||||||
|
for i in range(_race_cards.size()):
|
||||||
|
_race_cards[i].pressed.connect(_on_race_pressed.bind(i))
|
||||||
|
for i in range(_calling_cards.size()):
|
||||||
|
_calling_cards[i].pressed.connect(_on_calling_pressed.bind(i))
|
||||||
|
for i in range(_pool_chips.size()):
|
||||||
|
_pool_chips[i].pressed.connect(_on_pool_pressed.bind(i))
|
||||||
|
for i in range(_bonus_chips.size()):
|
||||||
|
_bonus_chips[i].pressed.connect(_on_bonus_pressed.bind(i))
|
||||||
|
for i in range(_ability_cards.size()):
|
||||||
|
var card: PanelContainer = _ability_cards[i]
|
||||||
|
card.get_node("Box/Buttons/Minus").pressed.connect(_on_minus_pressed.bind(i))
|
||||||
|
card.get_node("Box/Buttons/Plus").pressed.connect(_on_plus_pressed.bind(i))
|
||||||
|
_reroll.pressed.connect(_on_reroll_pressed)
|
||||||
|
_name_edit.text_changed.connect(_on_name_changed)
|
||||||
|
_enter.pressed.connect(_on_enter_pressed)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- input -> draft
|
||||||
|
|
||||||
|
func _on_race_pressed(i: int) -> void:
|
||||||
|
draft.set_race(Races.IDS[i])
|
||||||
|
_bind()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_calling_pressed(i: int) -> void:
|
||||||
|
var allowed := _allowed_callings()
|
||||||
|
if i >= allowed.size():
|
||||||
|
return
|
||||||
|
draft.set_calling(allowed[i])
|
||||||
|
_bind()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_pool_pressed(i: int) -> void:
|
||||||
|
var pool := draft.skill_pool()
|
||||||
|
if i < pool.size():
|
||||||
|
draft.toggle_skill(pool[i])
|
||||||
|
_bind()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_bonus_pressed(i: int) -> void:
|
||||||
|
draft.set_bonus_skill(Skills.IDS[i])
|
||||||
|
_bind()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_plus_pressed(i: int) -> void:
|
||||||
|
draft.increment(Attributes.IDS[i])
|
||||||
|
_bind()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_minus_pressed(i: int) -> void:
|
||||||
|
draft.decrement(Attributes.IDS[i])
|
||||||
|
_bind()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_reroll_pressed() -> void:
|
||||||
|
draft.reroll()
|
||||||
|
_bind()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_name_changed(text: String) -> void:
|
||||||
|
draft.name = text
|
||||||
|
_bind()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_enter_pressed() -> void:
|
||||||
|
if not draft.is_complete(origin, world):
|
||||||
|
return
|
||||||
|
creation_confirmed.emit(draft.to_creation())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- draft -> nodes
|
||||||
|
|
||||||
|
func _bind() -> void:
|
||||||
|
_bind_races()
|
||||||
|
_bind_callings()
|
||||||
|
_bind_skills()
|
||||||
|
_bind_abilities()
|
||||||
|
_bind_bay()
|
||||||
|
_bind_cta()
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_races() -> void:
|
||||||
|
for i in range(_race_cards.size()):
|
||||||
|
var id: String = Races.IDS[i]
|
||||||
|
var card: Button = _race_cards[i]
|
||||||
|
card.get_node("Box/Name").text = str(world.race(id).get("name", id.capitalize()))
|
||||||
|
card.get_node("Box/Blurb").text = str(world.race(id).get("blurb", ""))
|
||||||
|
card.get_node("Box/Trait").text = CreationCopy.race_trait(id)
|
||||||
|
card.button_pressed = (id == draft.race_id)
|
||||||
|
card.get_node("Tag").visible = card.button_pressed
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_callings() -> void:
|
||||||
|
var allowed := _allowed_callings()
|
||||||
|
for i in range(_calling_cards.size()):
|
||||||
|
var card: Button = _calling_cards[i]
|
||||||
|
if i >= allowed.size():
|
||||||
|
card.visible = false # a spare authored slot: hide it (ADR 0001)
|
||||||
|
continue
|
||||||
|
var id: String = allowed[i]
|
||||||
|
card.visible = true
|
||||||
|
card.get_node("Box/Name").text = str(world.calling(id).get("name", id.capitalize()))
|
||||||
|
card.get_node("Box/Chip").text = Callings.primary(id).to_upper()
|
||||||
|
card.button_pressed = (id == draft.calling_id)
|
||||||
|
card.get_node("Tag").visible = card.button_pressed
|
||||||
|
|
||||||
|
_detail_blurb.text = "[i]%s[/i]" % str(world.calling(draft.calling_id).get("blurb", ""))
|
||||||
|
_detail_mech.text = CreationCopy.calling_detail(draft.calling_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_skills() -> void:
|
||||||
|
var pool := draft.skill_pool()
|
||||||
|
_pool_count.text = "%d of %d chosen" % [draft.skills.size(), draft.picks_needed()]
|
||||||
|
for i in range(_pool_chips.size()):
|
||||||
|
var chip: Button = _pool_chips[i]
|
||||||
|
if i >= pool.size():
|
||||||
|
chip.visible = false
|
||||||
|
continue
|
||||||
|
var skill: String = pool[i]
|
||||||
|
var granted := draft.is_granted(skill)
|
||||||
|
chip.visible = true
|
||||||
|
# A granted skill reads as OWNED (gold + ✓ via SkillChipGranted), not merely
|
||||||
|
# disabled — otherwise it looks identical to a "picks are spent" chip and reads
|
||||||
|
# as "unavailable" rather than "you already have this".
|
||||||
|
chip.theme_type_variation = ThemeKeys.SKILL_CHIP_GRANTED if granted else ThemeKeys.SKILL_CHIP
|
||||||
|
chip.text = ("✓ " + CreationCopy.skill_label(skill)) if granted else CreationCopy.skill_label(skill)
|
||||||
|
chip.button_pressed = draft.is_picked(skill)
|
||||||
|
# Inert when the race already granted it, or when the picks are spent.
|
||||||
|
chip.disabled = granted or (not draft.is_picked(skill) and not draft.can_pick(skill))
|
||||||
|
|
||||||
|
_bonus_row.visible = draft.wants_bonus_skill()
|
||||||
|
for i in range(_bonus_chips.size()):
|
||||||
|
var skill: String = Skills.IDS[i]
|
||||||
|
var chip: Button = _bonus_chips[i]
|
||||||
|
chip.text = CreationCopy.skill_label(skill)
|
||||||
|
chip.button_pressed = (draft.bonus_skill == skill)
|
||||||
|
chip.disabled = not draft.can_take_bonus(skill) and draft.bonus_skill != skill
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_abilities() -> void:
|
||||||
|
var rolled := draft.rolled()
|
||||||
|
var final := draft.final()
|
||||||
|
var primary := Callings.primary(draft.calling_id)
|
||||||
|
_points.text = "points left: %d" % draft.points_left()
|
||||||
|
|
||||||
|
for i in range(_ability_cards.size()):
|
||||||
|
var stat: String = Attributes.IDS[i]
|
||||||
|
var card: PanelContainer = _ability_cards[i]
|
||||||
|
var spent := int(draft.spend.get(stat, 0))
|
||||||
|
card.get_node("Box/Key").text = stat.to_upper()
|
||||||
|
card.get_node("Box/Value").text = str(int(final[stat]))
|
||||||
|
card.get_node("Box/Rolled").text = "rolled %d%s" % [
|
||||||
|
int(rolled[stat]), (" +%d" % spent) if spent > 0 else ""]
|
||||||
|
card.get_node("Box/Buttons/Plus").disabled = not draft.can_increment(stat)
|
||||||
|
card.get_node("Box/Buttons/Minus").disabled = not draft.can_decrement(stat)
|
||||||
|
# Overlay/Tag, not Tag: the ability card is a PanelContainer, and a Container
|
||||||
|
# force-fits every child it has — which stretched the badge across the card and
|
||||||
|
# parked it on top of the rolled value. The Control overlay is not a Container,
|
||||||
|
# so the badge's anchors survive and it rides the card's top edge (ADR 0001).
|
||||||
|
card.get_node("Overlay/Tag").visible = (stat == primary)
|
||||||
|
card.theme_type_variation = ThemeKeys.PRIMARY_CARD if stat == primary else ThemeKeys.ABILITY_CARD
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_bay() -> void:
|
||||||
|
var shown_name := draft.name.strip_edges()
|
||||||
|
_plate_name.text = shown_name if shown_name != "" else "Nameless"
|
||||||
|
_plate_sub.text = "%s · %s" % [
|
||||||
|
str(world.race(draft.race_id).get("name", "")),
|
||||||
|
str(world.calling(draft.calling_id).get("name", "")),
|
||||||
|
]
|
||||||
|
_origin_text.text = "[i]%s[/i]" % _origin_prose()
|
||||||
|
|
||||||
|
|
||||||
|
func _origin_prose() -> String:
|
||||||
|
## The DM sizes you up — authored, not an AI call. The DM's real entrance is the
|
||||||
|
## shell's opening narration, one screen later, at the first moment where speaking
|
||||||
|
## means something (§13: authored text is CONTENT, not error handling).
|
||||||
|
var race: Dictionary = world.race(draft.race_id)
|
||||||
|
var calling: Dictionary = world.calling(draft.calling_id)
|
||||||
|
# A missing fragment degrades to the blurb rather than crashing. The content
|
||||||
|
# parity test stops that from ever shipping; this stops it from ever exploding.
|
||||||
|
var race_frag := str(race.get("fragment", race.get("blurb", "")))
|
||||||
|
var calling_frag := str(calling.get("fragment", calling.get("blurb", "")))
|
||||||
|
return "%s Now you carry a %s's work — %s" % [
|
||||||
|
race_frag, str(calling.get("name", "")).to_lower(), calling_frag]
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_cta() -> void:
|
||||||
|
## The errors array is the VALIDATOR's, written for code. What lands on the
|
||||||
|
## label is CreationCopy's, written for the player — "choose two more
|
||||||
|
## proficiencies", not "hedge_mage picks 2 skills, got 0" (§13). The mapping is
|
||||||
|
## in the presentation layer on purpose: NewGame.validate's strings are a
|
||||||
|
## contract, and this screen is not the only thing that reads them.
|
||||||
|
var errors := draft.errors(origin, world)
|
||||||
|
_enter.disabled = not errors.is_empty()
|
||||||
|
_error.text = CreationCopy.error_line(str(errors[0]), draft) if not errors.is_empty() else CTA_HINT
|
||||||
1
client/scripts/ui/creation/character_creation.gd.uid
Normal file
1
client/scripts/ui/creation/character_creation.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://3cxsq82gjlfk
|
||||||
149
client/scripts/ui/creation/creation_copy.gd
Normal file
149
client/scripts/ui/creation/creation_copy.gd
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
class_name CreationCopy
|
||||||
|
extends RefCounted
|
||||||
|
## Display strings for the creation screen, DERIVED from the rules tables.
|
||||||
|
##
|
||||||
|
## Prose lives in content (blurbs, fragments). NUMBERS LIVE IN CODE. The card reads
|
||||||
|
## both and repeats neither. A hit die written into a JSON — or typed into a string
|
||||||
|
## literal here — is a second source of truth, and it will eventually disagree with
|
||||||
|
## the table. Every number below is read from Callings/Races/Skills at call time, so
|
||||||
|
## retuning a calling retunes its card.
|
||||||
|
##
|
||||||
|
## §7: nothing here may mention Luck, in any form. §2: presentation only.
|
||||||
|
|
||||||
|
const ARMOR_WORD := {
|
||||||
|
"none": "no armour",
|
||||||
|
"light": "light armour",
|
||||||
|
"medium": "medium armour",
|
||||||
|
"heavy": "heavy armour",
|
||||||
|
}
|
||||||
|
|
||||||
|
## The one line the player reads when the draft will not hold. §13: a fallback is
|
||||||
|
## CONTENT, not error handling — so even an error nobody anticipated speaks in the
|
||||||
|
## DM's voice rather than showing him a validator's grammar.
|
||||||
|
const UNSPOKEN := "something in this does not hold — look again"
|
||||||
|
|
||||||
|
const NUMBER_WORD := {
|
||||||
|
1: "one", 2: "two", 3: "three", 4: "four", 5: "five",
|
||||||
|
6: "six", 7: "seven", 8: "eight", 9: "nine",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func calling_detail(id: String) -> String:
|
||||||
|
## e.g. "d8 · light armour · MP (FTH) · saves DEX + MAG · picks 4 skills · talent backstab"
|
||||||
|
if not Callings.exists(id):
|
||||||
|
return ""
|
||||||
|
var saves: Array = []
|
||||||
|
for s in Callings.saves(id):
|
||||||
|
saves.append(s.to_upper())
|
||||||
|
return "d%d · %s · %s · saves %s · picks %d skills · talent %s" % [
|
||||||
|
Callings.hit_die(id),
|
||||||
|
armor_word(id),
|
||||||
|
resource_word(id),
|
||||||
|
" + ".join(saves),
|
||||||
|
Callings.skill_count(id),
|
||||||
|
talent_word(id),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
static func talent_word(id: String) -> String:
|
||||||
|
## second_wind -> "second wind". Four of the seven talent IDS carry an
|
||||||
|
## underscore, and the card printed the raw id: the player read "talent
|
||||||
|
## second_wind" off the calling card. Ids are snake_case; humans are not — the
|
||||||
|
## same reason skill_label() exists. Read from the table at call time, so
|
||||||
|
## retuning a talent retunes the card.
|
||||||
|
return Callings.talent(id).replace("_", " ")
|
||||||
|
|
||||||
|
|
||||||
|
static func armor_word(id: String) -> String:
|
||||||
|
## The card says "no armour", not "none". Public so the test can assert against
|
||||||
|
## the DERIVED word rather than the raw table value — "none" is not a substring
|
||||||
|
## of "no armour", and asserting on the raw value only ever passed by accident.
|
||||||
|
return ARMOR_WORD.get(Callings.armor(id), Callings.armor(id))
|
||||||
|
|
||||||
|
|
||||||
|
static func resource_word(id: String) -> String:
|
||||||
|
## Martials live on cooldowns; casters on a pool off their casting stat.
|
||||||
|
if not Callings.is_caster(id):
|
||||||
|
return "cooldowns"
|
||||||
|
return "MP (%s)" % Callings.casting_stat(id).to_upper()
|
||||||
|
|
||||||
|
|
||||||
|
static func race_trait(id: String) -> String:
|
||||||
|
## e.g. "+1 to every save · +1 skill of choice" / "Poison resist · Nightsight"
|
||||||
|
if not Races.exists(id):
|
||||||
|
return ""
|
||||||
|
var parts: Array = []
|
||||||
|
if Races.save_bonus(id) > 0:
|
||||||
|
parts.append("+%d to every save" % Races.save_bonus(id))
|
||||||
|
for s in Races.granted_skills(id):
|
||||||
|
parts.append(skill_label(s).capitalize())
|
||||||
|
if Races.poison_save_bonus(id) > 0:
|
||||||
|
parts.append("Poison resist")
|
||||||
|
if Races.flag(id, "nightsight"):
|
||||||
|
parts.append("Nightsight")
|
||||||
|
if Races.flag(id, "claws"):
|
||||||
|
parts.append("Claws")
|
||||||
|
if Races.flag(id, "keen_scent"):
|
||||||
|
parts.append("Keen scent")
|
||||||
|
if Races.wants_bonus_skill(id):
|
||||||
|
parts.append("+1 skill of choice")
|
||||||
|
return " · ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
static func skill_label(skill: String) -> String:
|
||||||
|
## sleight_of_hand -> "sleight of hand". Ids are snake_case; humans are not.
|
||||||
|
return skill.replace("_", " ")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- §13: the nag
|
||||||
|
|
||||||
|
static func error_line(error: String, draft: CreationDraft) -> String:
|
||||||
|
## The validator's diagnostics are written for whoever is reading the errors
|
||||||
|
## array — "hedge_mage picks 2 skills, got 0", "unresolved ref: item:x". They are
|
||||||
|
## a CONTRACT (other code reads those strings), so they are not rewritten; they
|
||||||
|
## are TRANSLATED, here, in the presentation layer, where translating them
|
||||||
|
## belongs. What the player reads on the second screen of the game is authored
|
||||||
|
## copy: dry, second person, flat. Never an id, never a compiler's grammar (§13).
|
||||||
|
##
|
||||||
|
## Anything unmapped degrades to UNSPOKEN — never the raw string, never blank.
|
||||||
|
if error.begins_with("player name is required"):
|
||||||
|
return "name yourself"
|
||||||
|
if error.begins_with("unknown race"):
|
||||||
|
return "decide what blood you carry"
|
||||||
|
if error.begins_with("unknown calling"):
|
||||||
|
return "decide what you do for coin"
|
||||||
|
if error.begins_with("calling not allowed by origin"):
|
||||||
|
return "that trade is closed to you — you left it behind"
|
||||||
|
if error.contains(" picks ") and error.contains(" skills, got "):
|
||||||
|
return _proficiency_line(draft)
|
||||||
|
if error.begins_with("duplicate skill pick"):
|
||||||
|
return "you have taken the same proficiency twice"
|
||||||
|
if error.contains(" is not in the ") and error.ends_with(" pool"):
|
||||||
|
return "your calling never taught you that"
|
||||||
|
if error.contains(" is already granted by "):
|
||||||
|
return "you have that one already — it buys you nothing"
|
||||||
|
if error.contains(" must choose a bonus skill"):
|
||||||
|
return "one more proficiency, any of them — take it"
|
||||||
|
if error.begins_with("unknown bonus skill"):
|
||||||
|
return "no one here would call that a skill"
|
||||||
|
if error.begins_with("bonus skill ") and error.ends_with(" is already proficient"):
|
||||||
|
return "you have that one already — take another"
|
||||||
|
if error.contains(" does not get a bonus skill"):
|
||||||
|
return "your blood grants no proficiency of its own"
|
||||||
|
return UNSPOKEN
|
||||||
|
|
||||||
|
|
||||||
|
static func _proficiency_line(draft: CreationDraft) -> String:
|
||||||
|
## "choose two more proficiencies". The NUMBER is derived — Callings.skill_count
|
||||||
|
## is the rules table's business and the copy asks it, every time, rather than
|
||||||
|
## carrying a second copy of it that would one day disagree.
|
||||||
|
var left: int = draft.picks_left()
|
||||||
|
if left <= 0:
|
||||||
|
return "you have taken more proficiencies than your calling can carry"
|
||||||
|
if left == 1:
|
||||||
|
return "choose one more proficiency"
|
||||||
|
return "choose %s more proficiencies" % number_word(left)
|
||||||
|
|
||||||
|
|
||||||
|
static func number_word(n: int) -> String:
|
||||||
|
return str(NUMBER_WORD.get(n, n))
|
||||||
1
client/scripts/ui/creation/creation_copy.gd.uid
Normal file
1
client/scripts/ui/creation/creation_copy.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://rm6bkgstenf1
|
||||||
222
client/scripts/ui/creation/creation_draft.gd
Normal file
222
client/scripts/ui/creation/creation_draft.gd
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
class_name CreationDraft
|
||||||
|
extends RefCounted
|
||||||
|
## The creation screen's whole brain. Pure, node-free, headlessly testable.
|
||||||
|
##
|
||||||
|
## Stores exactly the seven things a creation Dictionary is made of, and nothing
|
||||||
|
## derived. It CARRIES A SEED, NOT A STAT BLOCK — the screen shows the roll by
|
||||||
|
## calling NewGame.roll_attributes(seed), the same function construct calls, so
|
||||||
|
## the numbers on screen are the numbers the player gets and the screen still
|
||||||
|
## never hands a number to anything (§2).
|
||||||
|
##
|
||||||
|
## It owns no rules of its own: errors() delegates to NewGame.validate. A second
|
||||||
|
## copy of the rules is a second thing to keep in sync, and it would lose.
|
||||||
|
##
|
||||||
|
## §7: LCK is not here. Not a field, not an accessor, not a key. It is rolled
|
||||||
|
## inside construct off the same seed, immediately after MAG, and never surfaces.
|
||||||
|
|
||||||
|
var name: String = ""
|
||||||
|
var race_id: String = ""
|
||||||
|
var calling_id: String = ""
|
||||||
|
var spend: Dictionary = {} # {stat: int} — additive only
|
||||||
|
var skills: Array = [] # the player's picks (NOT the race's grants)
|
||||||
|
var bonus_skill: String = "" # human only
|
||||||
|
|
||||||
|
## NOT named `seed` — `seed()` is a global GDScript function and a member that
|
||||||
|
## shadows it reads as a bug (and warns). The creation Dictionary's KEY is still
|
||||||
|
## "seed"; only the GDScript identifier differs.
|
||||||
|
var character_seed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
static func fresh() -> CreationDraft:
|
||||||
|
## The ONE place non-determinism enters the entire pipeline — and what it
|
||||||
|
## produces is a seed, which is thereafter the character's anchor forever.
|
||||||
|
var d := CreationDraft.new()
|
||||||
|
d.character_seed = new_seed()
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
static func new_seed() -> int:
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.randomize()
|
||||||
|
return int(rng.randi())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the numbers
|
||||||
|
|
||||||
|
func rolled() -> Dictionary:
|
||||||
|
return NewGame.roll_attributes(character_seed)
|
||||||
|
|
||||||
|
|
||||||
|
func final() -> Dictionary:
|
||||||
|
var out := rolled()
|
||||||
|
for stat in spend:
|
||||||
|
out[stat] = int(out[stat]) + int(spend[stat])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func spent_total() -> int:
|
||||||
|
var total := 0
|
||||||
|
for stat in spend:
|
||||||
|
total += int(spend[stat])
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
func points_left() -> int:
|
||||||
|
## NewGame.SPEND_POOL is the ONE authority on the pool's size. A local copy of the
|
||||||
|
## number would be a second thing to keep in sync, and this class's whole contract
|
||||||
|
## is that it keeps none.
|
||||||
|
return NewGame.SPEND_POOL - spent_total()
|
||||||
|
|
||||||
|
|
||||||
|
func can_increment(stat: String) -> bool:
|
||||||
|
return Attributes.exists(stat) and points_left() > 0
|
||||||
|
|
||||||
|
|
||||||
|
func increment(stat: String) -> void:
|
||||||
|
if not can_increment(stat):
|
||||||
|
return
|
||||||
|
spend[stat] = int(spend.get(stat, 0)) + 1
|
||||||
|
|
||||||
|
|
||||||
|
func can_decrement(stat: String) -> bool:
|
||||||
|
## Removes only what YOU spent. "Additive only" is a rule about where the number
|
||||||
|
## can END UP — never below the roll — not about whether a mis-click is
|
||||||
|
## recoverable. Those are different things.
|
||||||
|
return int(spend.get(stat, 0)) > 0
|
||||||
|
|
||||||
|
|
||||||
|
func decrement(stat: String) -> void:
|
||||||
|
if not can_decrement(stat):
|
||||||
|
return
|
||||||
|
var left := int(spend[stat]) - 1
|
||||||
|
if left <= 0:
|
||||||
|
spend.erase(stat)
|
||||||
|
else:
|
||||||
|
spend[stat] = left
|
||||||
|
|
||||||
|
|
||||||
|
func reroll() -> void:
|
||||||
|
## A re-roll IS a new seed. Nothing else. The spend clears because points spent
|
||||||
|
## against a roll that no longer exists are meaningless; race, calling, skills
|
||||||
|
## and name survive because none of them came from the die.
|
||||||
|
##
|
||||||
|
## Note what the player is NOT told: the seed drives the five visible attributes
|
||||||
|
## AND the hidden Luck roll. Chase a 16 and you re-roll your Luck, blind, every
|
||||||
|
## time. That is §7 working exactly as designed. Say nothing.
|
||||||
|
character_seed = new_seed()
|
||||||
|
spend = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the choices
|
||||||
|
|
||||||
|
func set_race(id: String) -> void:
|
||||||
|
race_id = id
|
||||||
|
# A pick the new race GRANTS now buys nothing, and construct rejects it by name.
|
||||||
|
# Drop it here rather than let the player press a button into a 422.
|
||||||
|
var granted: Array = Races.granted_skills(id)
|
||||||
|
var kept: Array = []
|
||||||
|
for s in skills:
|
||||||
|
if s not in granted:
|
||||||
|
kept.append(s)
|
||||||
|
skills = kept
|
||||||
|
if not Races.wants_bonus_skill(id):
|
||||||
|
bonus_skill = ""
|
||||||
|
|
||||||
|
|
||||||
|
func set_calling(id: String) -> void:
|
||||||
|
# The pool changed underneath the picks. Keeping them leaves the player holding
|
||||||
|
# an invalid draft he did not cause and cannot see.
|
||||||
|
if id != calling_id:
|
||||||
|
skills = []
|
||||||
|
calling_id = id
|
||||||
|
|
||||||
|
|
||||||
|
func skill_pool() -> Array:
|
||||||
|
return Callings.skill_pool(calling_id)
|
||||||
|
|
||||||
|
|
||||||
|
func picks_needed() -> int:
|
||||||
|
return Callings.skill_count(calling_id)
|
||||||
|
|
||||||
|
|
||||||
|
func picks_left() -> int:
|
||||||
|
return picks_needed() - skills.size()
|
||||||
|
|
||||||
|
|
||||||
|
func is_granted(skill: String) -> bool:
|
||||||
|
return skill in Races.granted_skills(race_id)
|
||||||
|
|
||||||
|
|
||||||
|
func is_picked(skill: String) -> bool:
|
||||||
|
return skill in skills
|
||||||
|
|
||||||
|
|
||||||
|
func can_pick(skill: String) -> bool:
|
||||||
|
if is_granted(skill):
|
||||||
|
return false
|
||||||
|
# The mirror of can_take_bonus's `not is_picked(skill)`. Without it the guard is
|
||||||
|
# one-directional: take athletics as the human's bonus, then pick it AGAIN from
|
||||||
|
# the calling's pool, and the draft is invalid ("bonus skill athletics is already
|
||||||
|
# proficient") — the player holding an invalid draft he did not cause and cannot
|
||||||
|
# see, which is the exact thing set_calling's comment forbids. The chip goes inert
|
||||||
|
# instead; the bonus row is right there and the player can hand it back.
|
||||||
|
if skill == bonus_skill:
|
||||||
|
return false
|
||||||
|
if skill not in skill_pool():
|
||||||
|
return false
|
||||||
|
return picks_left() > 0
|
||||||
|
|
||||||
|
|
||||||
|
func toggle_skill(skill: String) -> void:
|
||||||
|
if is_picked(skill):
|
||||||
|
skills.erase(skill)
|
||||||
|
return
|
||||||
|
if can_pick(skill):
|
||||||
|
skills.append(skill)
|
||||||
|
|
||||||
|
|
||||||
|
func wants_bonus_skill() -> bool:
|
||||||
|
return Races.wants_bonus_skill(race_id)
|
||||||
|
|
||||||
|
|
||||||
|
func can_take_bonus(skill: String) -> bool:
|
||||||
|
if not wants_bonus_skill():
|
||||||
|
return false
|
||||||
|
if not Skills.exists(skill):
|
||||||
|
return false
|
||||||
|
return not is_granted(skill) and not is_picked(skill)
|
||||||
|
|
||||||
|
|
||||||
|
func set_bonus_skill(skill: String) -> void:
|
||||||
|
if bonus_skill == skill:
|
||||||
|
bonus_skill = ""
|
||||||
|
return
|
||||||
|
if can_take_bonus(skill):
|
||||||
|
bonus_skill = skill
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- the contract
|
||||||
|
|
||||||
|
func to_creation() -> Dictionary:
|
||||||
|
## The plain Dictionary NewGame.construct takes. A saga (later) synthesizes one
|
||||||
|
## of these and skips this screen entirely — so it stays a plain Dictionary, and
|
||||||
|
## this scene must never become the only thing that can produce it.
|
||||||
|
return {
|
||||||
|
"name": name.strip_edges(),
|
||||||
|
"race_id": race_id,
|
||||||
|
"calling_id": calling_id,
|
||||||
|
"seed": character_seed, # the DICT key stays "seed" — the contract is unchanged
|
||||||
|
"spend": spend.duplicate(),
|
||||||
|
"skills": skills.duplicate(),
|
||||||
|
"bonus_skill": bonus_skill,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func errors(origin: Dictionary, world: ContentDB) -> Array:
|
||||||
|
## Delegates. The screen owns no second copy of the rules — it asks construct's
|
||||||
|
## validator what is wrong, and shows the answer.
|
||||||
|
return NewGame.validate(origin, world, to_creation())
|
||||||
|
|
||||||
|
|
||||||
|
func is_complete(origin: Dictionary, world: ContentDB) -> bool:
|
||||||
|
return errors(origin, world).is_empty()
|
||||||
1
client/scripts/ui/creation/creation_draft.gd.uid
Normal file
1
client/scripts/ui/creation/creation_draft.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dkxuw5c4r6tld
|
||||||
138
client/scripts/ui/shell/main_window_shell.gd
Normal file
138
client/scripts/ui/shell/main_window_shell.gd
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
class_name MainWindowShell
|
||||||
|
extends Control
|
||||||
|
## The exploration HUD — the frame every screen lives in (mock 2a). Two-panel
|
||||||
|
## tabletop split: a dark isometric world slot (turn rail, minimap, system dock,
|
||||||
|
## command bar) + the permanent DM narration book. §2: the shell owns HUD state
|
||||||
|
## (ShellState) and consumes DM text (never owns narrative truth). It reuses the
|
||||||
|
## proven Theme (M3-a) and DM loop (M2) — nothing here is reinvented.
|
||||||
|
##
|
||||||
|
## Layout is authored in MainWindowShell.tscn (and its SystemDock/NarrationBook
|
||||||
|
## sub-scenes) — open it in the editor to arrange it. This script binds the seed
|
||||||
|
## ShellState into the authored nodes, wires the DM loop, and keeps the runtime
|
||||||
|
## viewport-fit. The turn-order tokens and consumable slots are a FIXED set of
|
||||||
|
## authored nodes bound from state (extra slots hide); combat/inventory become the
|
||||||
|
## writers later.
|
||||||
|
|
||||||
|
# HttpTransport carries no class_name (unlike DmService/FallbackLibrary) — every
|
||||||
|
# other call site (narrate_harness.gd, test_net_primitives.gd) reaches it via
|
||||||
|
# preload, so this mirrors the existing pattern rather than inventing a new one.
|
||||||
|
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||||
|
|
||||||
|
var service: DmService = null # injection seam; real HttpTransport built if null
|
||||||
|
var injected_log: CanonLog = null # injection seam; _build_seed_log() if null (F6/standalone)
|
||||||
|
var injected_state: GameState = null # injection seam; ShellState.seed() if null
|
||||||
|
|
||||||
|
var _state: ShellState
|
||||||
|
var _log: CanonLog
|
||||||
|
var _http: HTTPRequest
|
||||||
|
|
||||||
|
@onready var book: NarrationBook = $Split/BookPanel/Book
|
||||||
|
@onready var dock: SystemDock = $Split/World/SystemDock
|
||||||
|
@onready var _tokens: HBoxContainer = $Split/World/TurnRail/Col/Tokens
|
||||||
|
@onready var _hp: Label = $Split/World/CommandBar/Vitals/HP
|
||||||
|
@onready var _mp: Label = $Split/World/CommandBar/Vitals/MP
|
||||||
|
@onready var _purse: Label = $Split/World/CommandBar/Vitals/Purse
|
||||||
|
@onready var _consumables: HBoxContainer = $Split/World/CommandBar/Consumables
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# The theme is assigned on the scene root in the editor (so it previews). Keep
|
||||||
|
# the runtime viewport-fit: a root Control run via F6 is not sized by full-rect
|
||||||
|
# anchors — the .tscn root uses a fixed size + no full-rect preset (M3-a bug).
|
||||||
|
_fit_to_viewport()
|
||||||
|
get_viewport().size_changed.connect(_fit_to_viewport)
|
||||||
|
|
||||||
|
# The injected character wins; the seed calls are the F6/standalone fallback —
|
||||||
|
# same shape as `service == null` building a real transport below.
|
||||||
|
_state = ShellState.for_character(injected_state, injected_log) if injected_state != null else ShellState.seed()
|
||||||
|
_log = injected_log if injected_log != null else _build_seed_log()
|
||||||
|
_bind_state()
|
||||||
|
|
||||||
|
if service == null:
|
||||||
|
_http = HTTPRequest.new()
|
||||||
|
add_child(_http)
|
||||||
|
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
|
||||||
|
|
||||||
|
book.setup(service, _log, _state)
|
||||||
|
dock.setup(_state)
|
||||||
|
dock.screen_requested.connect(_on_screen_requested)
|
||||||
|
book.narrate() # initial narration; async, fire-and-forget
|
||||||
|
|
||||||
|
|
||||||
|
func _fit_to_viewport() -> void:
|
||||||
|
size = get_viewport_rect().size
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_state() -> void:
|
||||||
|
_bind_turn_rail()
|
||||||
|
_bind_vitals()
|
||||||
|
_bind_consumables()
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_turn_rail() -> void:
|
||||||
|
# Fill the authored token labels from the turn order; hide any spare slots.
|
||||||
|
var tokens := _tokens.get_children()
|
||||||
|
for i in range(tokens.size()):
|
||||||
|
var label: Label = tokens[i]
|
||||||
|
if i < _state.turn_order.size():
|
||||||
|
var e: TurnEntry = _state.turn_order[i]
|
||||||
|
label.text = "✕" if e.is_downed else e.initials
|
||||||
|
label.add_theme_color_override("font_color", _side_color(e))
|
||||||
|
label.visible = true
|
||||||
|
else:
|
||||||
|
label.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _side_color(e: TurnEntry) -> Color:
|
||||||
|
if e.is_downed:
|
||||||
|
return Palette.BLOOD
|
||||||
|
if e.is_active:
|
||||||
|
return Palette.GOLD
|
||||||
|
match e.side:
|
||||||
|
&"enemy": return Palette.BLOOD_BRIGHT
|
||||||
|
&"ally": return Palette.STEEL
|
||||||
|
_: return Palette.CREAM
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_vitals() -> void:
|
||||||
|
_hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]]
|
||||||
|
_mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]]
|
||||||
|
_purse.text = "◈ %s" % Currency.format(_state.vitals["purse_copper"])
|
||||||
|
_purse.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_consumables() -> void:
|
||||||
|
# The 8 slots are authored; bind labels + fill/empty variation, hide spares.
|
||||||
|
var slots := _consumables.get_children()
|
||||||
|
for i in range(slots.size()):
|
||||||
|
var slot: PanelContainer = slots[i]
|
||||||
|
var label: Label = slot.get_node("Label")
|
||||||
|
if i < _state.consumables.size():
|
||||||
|
var c: Dictionary = _state.consumables[i]
|
||||||
|
label.text = c["label"]
|
||||||
|
slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY
|
||||||
|
slot.visible = true
|
||||||
|
else:
|
||||||
|
slot.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_screen_requested(_id: StringName) -> void:
|
||||||
|
pass # routing seam — the dock's screens land in later milestones (§17)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_seed_log() -> CanonLog:
|
||||||
|
# Code owns the canon log (§11). Hand-built until character creation (M4) and
|
||||||
|
# save/load (M9) construct the real one — mirrors narrate_harness. No Luck
|
||||||
|
# number is rendered anywhere (§7); the descriptor is prose-only.
|
||||||
|
var log := CanonLog.new()
|
||||||
|
log.player = LogPlayer.new("Vexcca", "human", "sellsword", "Fortune spits on you")
|
||||||
|
log.set_location("lower_ward", "the Lower Ward")
|
||||||
|
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
||||||
|
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
||||||
|
log.push_event("Followed the beastfolk fence into the Lower Ward after dark")
|
||||||
|
log.push_event("The sewer bite on Vexcca's left arm has begun to turn")
|
||||||
|
log.add_fact("the fence deals out of the Lower Ward")
|
||||||
|
log.add_fact("Vexcca carries an infected bite on the left arm")
|
||||||
|
log.active_quests.append(Quest.new("the_fences_price", "The Fence's Price", "active", "Settle with the beastfolk fence"))
|
||||||
|
log.add_humiliation("h_0001", "haggled badly and got a wrist crushed for it", 6, 4)
|
||||||
|
return log
|
||||||
1
client/scripts/ui/shell/main_window_shell.gd.uid
Normal file
1
client/scripts/ui/shell/main_window_shell.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bobihkpvsax2u
|
||||||
61
client/scripts/ui/shell/narration_book.gd
Normal file
61
client/scripts/ui/shell/narration_book.gd
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
class_name NarrationBook
|
||||||
|
extends Control
|
||||||
|
## The permanent DM narration book (mock 2a, right panel). §2: text — it consumes
|
||||||
|
## /dm/narrate prose and never treats it as truth; only harvested [FACT] tags are
|
||||||
|
## written back to the canon log (§11). Prose-only this milestone: the numbered
|
||||||
|
## response choices and the "describe your own action" line are faithful but inert
|
||||||
|
## (no action system yet); one in-world refire affordance re-runs narration.
|
||||||
|
##
|
||||||
|
## Layout is authored in NarrationBook.tscn (editor-previewable). This script only
|
||||||
|
## binds the header, drives the narrate loop, and swaps prose text.
|
||||||
|
|
||||||
|
var last_degraded: bool = false
|
||||||
|
|
||||||
|
var _service: DmService
|
||||||
|
var _log: CanonLog
|
||||||
|
var _state: ShellState
|
||||||
|
var _indicator: ConsideringIndicator
|
||||||
|
|
||||||
|
@onready var _header: Label = $Margin/Col/Header
|
||||||
|
@onready var _prose: RichTextLabel = $Margin/Col/Prose
|
||||||
|
@onready var _degraded_tag: Label = $Margin/Col/DegradedTag
|
||||||
|
@onready var _refire: Button = $Margin/Col/Refire
|
||||||
|
@onready var _status: Label = $Margin/Col/Status
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# The considering indicator is a non-visual Node (a Timer + label driver), so
|
||||||
|
# it stays code-created rather than authored in the scene.
|
||||||
|
_indicator = ConsideringIndicator.new()
|
||||||
|
add_child(_indicator)
|
||||||
|
_refire.pressed.connect(_on_refire_pressed)
|
||||||
|
|
||||||
|
|
||||||
|
func setup(service: DmService, log: CanonLog, state: ShellState) -> void:
|
||||||
|
_service = service
|
||||||
|
_log = log
|
||||||
|
_state = state
|
||||||
|
_header.text = "%s · %s" % [state.round_label, state.location_label]
|
||||||
|
|
||||||
|
|
||||||
|
func narrate() -> void:
|
||||||
|
if _service == null or _log == null:
|
||||||
|
return
|
||||||
|
_refire.disabled = true
|
||||||
|
_indicator.start(_status)
|
||||||
|
var result: NarrateResult = await _service.narrate(_log)
|
||||||
|
_indicator.stop()
|
||||||
|
show_prose(result)
|
||||||
|
for f in result.facts:
|
||||||
|
_log.add_fact(f) # §11: code applies the state write, explicitly
|
||||||
|
_refire.disabled = false
|
||||||
|
|
||||||
|
|
||||||
|
func show_prose(result: NarrateResult) -> void:
|
||||||
|
_prose.text = result.display_text
|
||||||
|
last_degraded = result.degraded
|
||||||
|
_degraded_tag.visible = result.degraded
|
||||||
|
|
||||||
|
|
||||||
|
func _on_refire_pressed() -> void:
|
||||||
|
narrate()
|
||||||
1
client/scripts/ui/shell/narration_book.gd.uid
Normal file
1
client/scripts/ui/shell/narration_book.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://blmbc06xpea3x
|
||||||
52
client/scripts/ui/shell/shell_state.gd
Normal file
52
client/scripts/ui/shell/shell_state.gd
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
class_name ShellState
|
||||||
|
extends RefCounted
|
||||||
|
## The HUD state the Main Window shell owns (§2: state). Seed values mirror mock
|
||||||
|
## 2a; later systems (combat vitals, inventory consumables) become the writers.
|
||||||
|
## No Luck/LCK value lives here — it is never surfaced (§7).
|
||||||
|
|
||||||
|
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "purse_copper": 0}
|
||||||
|
var turn_order: Array[TurnEntry] = []
|
||||||
|
var consumables: Array = []
|
||||||
|
var round_label: String = ""
|
||||||
|
var location_label: String = ""
|
||||||
|
var dock_open: bool = true
|
||||||
|
|
||||||
|
|
||||||
|
func toggle_dock() -> bool:
|
||||||
|
dock_open = not dock_open
|
||||||
|
return dock_open
|
||||||
|
|
||||||
|
|
||||||
|
static func seed() -> ShellState:
|
||||||
|
var s := ShellState.new()
|
||||||
|
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "purse_copper": 347}
|
||||||
|
s.turn_order = [
|
||||||
|
TurnEntry.new("EL", 17, &"you", true, false),
|
||||||
|
TurnEntry.new("DW", 12, &"ally"),
|
||||||
|
TurnEntry.new("HU", 9, &"ally"),
|
||||||
|
TurnEntry.new("GK", 0, &"enemy", false, true),
|
||||||
|
]
|
||||||
|
var labels := ["Salve", "Draught", "Bomb", "Antidote", "Ration", "Torch", "", ""]
|
||||||
|
s.consumables = []
|
||||||
|
for i in range(labels.size()):
|
||||||
|
s.consumables.append({"hotkey": i + 1, "label": labels[i]})
|
||||||
|
s.round_label = "ROUND 4"
|
||||||
|
s.location_label = "THE LOWER WARD"
|
||||||
|
s.dock_open = true
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
static func for_character(state: GameState, log: CanonLog) -> ShellState:
|
||||||
|
## The real HUD state for a constructed character. Vitals + location come from
|
||||||
|
## the character (§2 — code owns them); turn_order/consumables/round_label are
|
||||||
|
## still seed() placeholders because combat (M5) and inventory (M7) own those.
|
||||||
|
var s := ShellState.seed() # start from the placeholders, then overwrite what is real
|
||||||
|
s.vitals = {
|
||||||
|
"hp": state.sheet.hp,
|
||||||
|
"hp_max": state.sheet.max_hp(),
|
||||||
|
"mp": state.sheet.mp,
|
||||||
|
"mp_max": state.sheet.max_mp(),
|
||||||
|
"purse_copper": state.purse_copper,
|
||||||
|
}
|
||||||
|
s.location_label = log.location.name
|
||||||
|
return s
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user