City Update: towers/warehouses/cafes, spawn_life (carts/birds/fireflies), cinematic descent, visible deltas, camera gaze, bigger ticker, counter fix
Browse files- ARCHITECTURE.md +3 -2
- README.md +3 -2
- engine/__init__.py +1 -1
- engine/tools.py +14 -2
- engine/world.py +2 -0
- mind/__init__.py +1 -1
- mind/grammar.gbnf +1 -1
- mind/mock_scripts.py +81 -12
- mind/prompts.py +8 -3
- tests/test_engine.py +78 -1
- tests/test_mind.py +53 -5
- web/book.js +51 -10
- web/index.html +1 -0
- web/main.js +73 -6
- web/mock/feed.js +41 -25
- web/mock/world.json +13 -3
- web/planet/camera.js +166 -9
- web/planet/life.js +305 -0
- web/planet/structures.js +219 -0
- web/planet/terrain.js +134 -1
- web/planet/water.js +25 -0
- web/planet/world.js +8 -0
- web/style.css +59 -11
ARCHITECTURE.md
CHANGED
|
@@ -78,18 +78,19 @@ renderer derives ALL geometry deterministically from this list. Sky/weather are
|
|
| 78 |
`seed` is assigned by the engine: `seed = crc32(f"{wish_id}:{call_index}") & 0x7fffffff`. The JS
|
| 79 |
renderer uses the same seed with mulberry32 for all scatter/noise decisions of that feature.
|
| 80 |
|
| 81 |
-
### Tool DSL — exactly
|
| 82 |
|
| 83 |
| tool | args (ranges clamped by engine) |
|
| 84 |
|---|---|
|
| 85 |
| `raise_terrain` | `lat` −85..85, `lon` −180..180, `radius_deg` 2..45, `height` 0.01..0.12 (planet radii), `roughness` 0..1 |
|
| 86 |
| `lower_terrain` | `lat`, `lon`, `radius_deg` 2..45, `depth` 0.01..0.10, `roughness` 0..1 |
|
| 87 |
| `spawn_flora` | `lat`, `lon`, `radius_deg` 1..30, `kind` ∈ trees\|glowgrass\|mushrooms\|vines\|flowers\|reeds, `density` 0..1, `hue` 0..360 |
|
| 88 |
-
| `place_structure` | `lat`, `lon`, `kind` ∈ lighthouse\|monolith\|shrine\|village\|beacon\|arch, `scale` 0.5..2.0, `hue` 0..360 |
|
| 89 |
| `place_water` | `kind` ∈ stream\|pool, `path` [[lat,lon] ×1..5] (stream: 2..5 waypoints, becomes glowing spline on the surface — NOT carved; pool: path[0] center), `radius_deg` 1..10 (pool only), `hue` 160..260 |
|
| 90 |
| `set_weather` | `kind` ∈ clear\|rain\|snow\|embers\|mist\|storm, `intensity` 0..1 |
|
| 91 |
| `set_sky` | `palette` ∈ dawn\|dusk\|night\|aurora\|ember\|void\|gold, `star_density` 0..1, `moons` 0..3 |
|
| 92 |
| `inscribe_wish` | `text` ≤90 chars (the wish, possibly poeticized), `style` ∈ orbit\|stone |
|
|
|
|
| 93 |
|
| 94 |
Engine **clamps** out-of-range numbers (doesn't reject) and rejects only unknown tools/enums with a
|
| 95 |
terse observation string the model can react to.
|
|
|
|
| 78 |
`seed` is assigned by the engine: `seed = crc32(f"{wish_id}:{call_index}") & 0x7fffffff`. The JS
|
| 79 |
renderer uses the same seed with mulberry32 for all scatter/noise decisions of that feature.
|
| 80 |
|
| 81 |
+
### Tool DSL — exactly 9 tools (GBNF-friendly: flat args, numbers + enums + short strings)
|
| 82 |
|
| 83 |
| tool | args (ranges clamped by engine) |
|
| 84 |
|---|---|
|
| 85 |
| `raise_terrain` | `lat` −85..85, `lon` −180..180, `radius_deg` 2..45, `height` 0.01..0.12 (planet radii), `roughness` 0..1 |
|
| 86 |
| `lower_terrain` | `lat`, `lon`, `radius_deg` 2..45, `depth` 0.01..0.10, `roughness` 0..1 |
|
| 87 |
| `spawn_flora` | `lat`, `lon`, `radius_deg` 1..30, `kind` ∈ trees\|glowgrass\|mushrooms\|vines\|flowers\|reeds, `density` 0..1, `hue` 0..360 |
|
| 88 |
+
| `place_structure` | `lat`, `lon`, `kind` ∈ lighthouse\|monolith\|shrine\|village\|beacon\|arch\|tower\|warehouse\|cafe, `scale` 0.5..2.0, `hue` 0..360 |
|
| 89 |
| `place_water` | `kind` ∈ stream\|pool, `path` [[lat,lon] ×1..5] (stream: 2..5 waypoints, becomes glowing spline on the surface — NOT carved; pool: path[0] center), `radius_deg` 1..10 (pool only), `hue` 160..260 |
|
| 90 |
| `set_weather` | `kind` ∈ clear\|rain\|snow\|embers\|mist\|storm, `intensity` 0..1 |
|
| 91 |
| `set_sky` | `palette` ∈ dawn\|dusk\|night\|aurora\|ember\|void\|gold, `star_density` 0..1, `moons` 0..3 |
|
| 92 |
| `inscribe_wish` | `text` ≤90 chars (the wish, possibly poeticized), `style` ∈ orbit\|stone |
|
| 93 |
+
| `spawn_life` | `lat`, `lon`, `radius_deg` 1..20, `kind` ∈ carts\|birds\|fireflies, `count` 1..12 (integer), `hue` 0..360 |
|
| 94 |
|
| 95 |
Engine **clamps** out-of-range numbers (doesn't reject) and rejects only unknown tools/enums with a
|
| 96 |
terse observation string the model can react to.
|
README.md
CHANGED
|
@@ -87,8 +87,9 @@ church and state:
|
|
| 87 |
```
|
| 88 |
|
| 89 |
The model never computes geometry and never owns state — it interprets, narrates, and
|
| 90 |
-
picks from a DSL of exactly
|
| 91 |
-
`place_structure`, `place_water`, `set_weather`, `set_sky`, `inscribe_wish`
|
|
|
|
| 92 |
deterministic engine clamps every number, seeds every feature, and feeds terse
|
| 93 |
observations back into the loop. Wishes pass layered moderation before a single tool
|
| 94 |
runs. Latency is liturgy: the god grants **one wish at a time**, and you see your place
|
|
|
|
| 87 |
```
|
| 88 |
|
| 89 |
The model never computes geometry and never owns state — it interprets, narrates, and
|
| 90 |
+
picks from a DSL of exactly 9 tools (`raise_terrain`, `lower_terrain`, `spawn_flora`,
|
| 91 |
+
`place_structure`, `place_water`, `set_weather`, `set_sky`, `inscribe_wish`,
|
| 92 |
+
`spawn_life`). The
|
| 93 |
deterministic engine clamps every number, seeds every feature, and feeds terse
|
| 94 |
observations back into the loop. Wishes pass layered moderation before a single tool
|
| 95 |
runs. Latency is liturgy: the god grants **one wish at a time**, and you see your place
|
engine/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""GODSEED deterministic world engine.
|
| 2 |
|
| 3 |
-
The engine owns ALL facts ("her" pattern): world state, the
|
| 4 |
validation, moderation, the serialized wish queue, and the prompt summary.
|
| 5 |
The LLM only interprets wishes and picks tool calls — it never owns state.
|
| 6 |
"""
|
|
|
|
| 1 |
"""GODSEED deterministic world engine.
|
| 2 |
|
| 3 |
+
The engine owns ALL facts ("her" pattern): world state, the 9-tool DSL,
|
| 4 |
validation, moderation, the serialized wish queue, and the prompt summary.
|
| 5 |
The LLM only interprets wishes and picks tool calls — it never owns state.
|
| 6 |
"""
|
engine/tools.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""GODSEED tool DSL — the single source of truth for the
|
| 2 |
|
| 3 |
Spec is data (TOOLS); validation is `validate_call`. Semantics per the
|
| 4 |
architecture contract:
|
|
@@ -48,11 +48,15 @@ class PathSpec:
|
|
| 48 |
|
| 49 |
|
| 50 |
FLORA_KINDS = ("trees", "glowgrass", "mushrooms", "vines", "flowers", "reeds")
|
| 51 |
-
STRUCTURE_KINDS = (
|
|
|
|
|
|
|
|
|
|
| 52 |
WATER_KINDS = ("stream", "pool")
|
| 53 |
WEATHER_KINDS = ("clear", "rain", "snow", "embers", "mist", "storm")
|
| 54 |
SKY_PALETTES = ("dawn", "dusk", "night", "aurora", "ember", "void", "gold")
|
| 55 |
INSCRIBE_STYLES = ("orbit", "stone")
|
|
|
|
| 56 |
|
| 57 |
TOOLS: dict[str, dict[str, Any]] = {
|
| 58 |
"raise_terrain": {
|
|
@@ -103,6 +107,14 @@ TOOLS: dict[str, dict[str, Any]] = {
|
|
| 103 |
"text": TextSpec(90),
|
| 104 |
"style": EnumSpec(INSCRIBE_STYLES),
|
| 105 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
TOOL_NAMES: tuple[str, ...] = tuple(TOOLS.keys())
|
|
|
|
| 1 |
+
"""GODSEED tool DSL — the single source of truth for the 9-tool surface.
|
| 2 |
|
| 3 |
Spec is data (TOOLS); validation is `validate_call`. Semantics per the
|
| 4 |
architecture contract:
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
FLORA_KINDS = ("trees", "glowgrass", "mushrooms", "vines", "flowers", "reeds")
|
| 51 |
+
STRUCTURE_KINDS = (
|
| 52 |
+
"lighthouse", "monolith", "shrine", "village", "beacon", "arch",
|
| 53 |
+
"tower", "warehouse", "cafe",
|
| 54 |
+
)
|
| 55 |
WATER_KINDS = ("stream", "pool")
|
| 56 |
WEATHER_KINDS = ("clear", "rain", "snow", "embers", "mist", "storm")
|
| 57 |
SKY_PALETTES = ("dawn", "dusk", "night", "aurora", "ember", "void", "gold")
|
| 58 |
INSCRIBE_STYLES = ("orbit", "stone")
|
| 59 |
+
LIFE_KINDS = ("carts", "birds", "fireflies")
|
| 60 |
|
| 61 |
TOOLS: dict[str, dict[str, Any]] = {
|
| 62 |
"raise_terrain": {
|
|
|
|
| 107 |
"text": TextSpec(90),
|
| 108 |
"style": EnumSpec(INSCRIBE_STYLES),
|
| 109 |
},
|
| 110 |
+
"spawn_life": {
|
| 111 |
+
"lat": NumberSpec(*LAT_RANGE),
|
| 112 |
+
"lon": NumberSpec(*LON_RANGE),
|
| 113 |
+
"radius_deg": NumberSpec(1, 20),
|
| 114 |
+
"kind": EnumSpec(LIFE_KINDS),
|
| 115 |
+
"count": NumberSpec(1, 12, integer=True),
|
| 116 |
+
"hue": NumberSpec(0, 360),
|
| 117 |
+
},
|
| 118 |
}
|
| 119 |
|
| 120 |
TOOL_NAMES: tuple[str, ...] = tuple(TOOLS.keys())
|
engine/world.py
CHANGED
|
@@ -96,6 +96,8 @@ def _observation(feature: Feature) -> str:
|
|
| 96 |
return f"ok: sky set to {a['palette']}"
|
| 97 |
if t == "inscribe_wish":
|
| 98 |
return f"ok: wish inscribed in {a['style']}"
|
|
|
|
|
|
|
| 99 |
return "ok" # pragma: no cover — every known tool is handled above
|
| 100 |
|
| 101 |
|
|
|
|
| 96 |
return f"ok: sky set to {a['palette']}"
|
| 97 |
if t == "inscribe_wish":
|
| 98 |
return f"ok: wish inscribed in {a['style']}"
|
| 99 |
+
if t == "spawn_life":
|
| 100 |
+
return f"ok: {a['count']} {a['kind']} stirring at {_coord(a)}"
|
| 101 |
return "ok" # pragma: no cover — every known tool is handled above
|
| 102 |
|
| 103 |
|
mind/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""GODSEED mind — Agent C.
|
| 2 |
|
| 3 |
The Mind interprets a wish, narrates a short Reading aloud, then runs a
|
| 4 |
-
grammar-constrained plan-act-observe loop over the engine's
|
| 5 |
It never computes geometry and never owns world state (the "her" pattern):
|
| 6 |
the deterministic engine clamps, executes, and records every act.
|
| 7 |
|
|
|
|
| 1 |
"""GODSEED mind — Agent C.
|
| 2 |
|
| 3 |
The Mind interprets a wish, narrates a short Reading aloud, then runs a
|
| 4 |
+
grammar-constrained plan-act-observe loop over the engine's 9-tool DSL.
|
| 5 |
It never computes geometry and never owns world state (the "her" pattern):
|
| 6 |
the deterministic engine clamps, executes, and records every act.
|
| 7 |
|
mind/grammar.gbnf
CHANGED
|
@@ -19,7 +19,7 @@ thought-kv ::= "\"thought\"" ws ":" ws thought-string
|
|
| 19 |
call-kv ::= "\"call\"" ws ":" ws "{" ws "\"tool\"" ws ":" ws tool-name ws "," ws "\"args\"" ws ":" ws args-object ws "}"
|
| 20 |
done-kv ::= "\"done\"" ws ":" ws "true" ws "," ws "\"epitaph\"" ws ":" ws epitaph-string
|
| 21 |
|
| 22 |
-
tool-name ::= "\"raise_terrain\"" | "\"lower_terrain\"" | "\"spawn_flora\"" | "\"place_structure\"" | "\"place_water\"" | "\"set_weather\"" | "\"set_sky\"" | "\"inscribe_wish\""
|
| 23 |
|
| 24 |
args-object ::= "{" ws ( member ( ws "," ws member ){0,11} )? ws "}"
|
| 25 |
member ::= key-string ws ":" ws value
|
|
|
|
| 19 |
call-kv ::= "\"call\"" ws ":" ws "{" ws "\"tool\"" ws ":" ws tool-name ws "," ws "\"args\"" ws ":" ws args-object ws "}"
|
| 20 |
done-kv ::= "\"done\"" ws ":" ws "true" ws "," ws "\"epitaph\"" ws ":" ws epitaph-string
|
| 21 |
|
| 22 |
+
tool-name ::= "\"raise_terrain\"" | "\"lower_terrain\"" | "\"spawn_flora\"" | "\"place_structure\"" | "\"place_water\"" | "\"set_weather\"" | "\"set_sky\"" | "\"inscribe_wish\"" | "\"spawn_life\""
|
| 23 |
|
| 24 |
args-object ::= "{" ws ( member ( ws "," ws member ){0,11} )? ws "}"
|
| 25 |
member ::= key-string ws ":" ws value
|
mind/mock_scripts.py
CHANGED
|
@@ -5,7 +5,7 @@ a short turn list (calls + a done turn), with coordinates, hues, and a few
|
|
| 5 |
phrasings varied by a seed derived from the wish text (crc32 — stable across
|
| 6 |
processes, unlike hash()). Same wish in, same plan out: demos replay exactly.
|
| 7 |
|
| 8 |
-
Across the category library every one of the
|
| 9 |
demo session exercises the full DSL. All numbers are kept inside the engine's
|
| 10 |
clamp ranges; all thoughts <=160 chars; all epitaphs <=120; inscriptions <=90.
|
| 11 |
|
|
@@ -224,9 +224,16 @@ def _forest(wish: str, rng: random.Random, mood: str) -> dict:
|
|
| 224 |
"set_weather",
|
| 225 |
kind="rain", intensity=_r2(rng, 0.2, 0.4),
|
| 226 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
_done(
|
| 228 |
"Forests finish themselves. I only start them.",
|
| 229 |
-
f"A forest grows in {place}, lit from below.",
|
| 230 |
),
|
| 231 |
],
|
| 232 |
}
|
|
@@ -313,11 +320,11 @@ def _lighthouse(wish: str, rng: random.Random, mood: str) -> dict:
|
|
| 313 |
|
| 314 |
def _village(wish: str, rng: random.Random, mood: str) -> dict:
|
| 315 |
lat, lon = _anchor(rng)
|
| 316 |
-
|
| 317 |
reading = (
|
| 318 |
"A hearth-wish. These are my favorite, though a god should not say "
|
| 319 |
-
"so. A village in a gentle fold of land,
|
| 320 |
-
"the
|
| 321 |
)
|
| 322 |
return {
|
| 323 |
"reading": reading,
|
|
@@ -335,11 +342,16 @@ def _village(wish: str, rng: random.Random, mood: str) -> dict:
|
|
| 335 |
scale=_r2(rng, 1.0, 1.4), hue=_r1(rng, 30, 50),
|
| 336 |
),
|
| 337 |
_call(
|
| 338 |
-
"
|
| 339 |
-
"
|
| 340 |
-
lat=_clamp_lat(lat
|
| 341 |
-
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
),
|
| 344 |
_call(
|
| 345 |
"Set the wish over the rooftops, where smoke would go.",
|
|
@@ -348,7 +360,57 @@ def _village(wish: str, rng: random.Random, mood: str) -> dict:
|
|
| 348 |
),
|
| 349 |
_done(
|
| 350 |
"Small lights, kept on. There is no greater architecture.",
|
| 351 |
-
"A village stands with its windows lit,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
),
|
| 353 |
],
|
| 354 |
}
|
|
@@ -452,9 +514,15 @@ def _sky(wish: str, rng: random.Random, mood: str) -> dict:
|
|
| 452 |
kind="glowgrass", density=_r2(rng, 0.4, 0.7),
|
| 453 |
hue=_r1(rng, 150, 200),
|
| 454 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
_done(
|
| 456 |
"Look up, little world. I rewrote you an answer.",
|
| 457 |
-
f"The night is rewritten: more stars, {moons} moons, one wish in orbit.",
|
| 458 |
),
|
| 459 |
],
|
| 460 |
}
|
|
@@ -512,6 +580,7 @@ def _default(wish: str, rng: random.Random, mood: str) -> dict:
|
|
| 512 |
_CATEGORIES: tuple[tuple[re.Pattern[str], object], ...] = (
|
| 513 |
(re.compile(r"\b(mushrooms?|fungus|fungi|spores?)\b", re.I), _mushroom),
|
| 514 |
(re.compile(r"\b(lighthouses?|beacons?|lamps?|ships?|sailors?|harbou?rs?|light)\b", re.I), _lighthouse),
|
|
|
|
| 515 |
(re.compile(r"\b(villages?|homes?|houses?|hearths?|towns?|kettles?|people|friends?|family)\b", re.I), _village),
|
| 516 |
(re.compile(r"\b(seas?|oceans?|lakes?|rivers?|streams?|water|tides?|waves?|shores?|pools?)\b", re.I), _water),
|
| 517 |
(re.compile(r"\b(mountains?|peaks?|hills?|cliffs?|summits?|crags?|valleys?)\b", re.I), _mountain),
|
|
|
|
| 5 |
phrasings varied by a seed derived from the wish text (crc32 — stable across
|
| 6 |
processes, unlike hash()). Same wish in, same plan out: demos replay exactly.
|
| 7 |
|
| 8 |
+
Across the category library every one of the 9 tools appears, so a varied
|
| 9 |
demo session exercises the full DSL. All numbers are kept inside the engine's
|
| 10 |
clamp ranges; all thoughts <=160 chars; all epitaphs <=120; inscriptions <=90.
|
| 11 |
|
|
|
|
| 224 |
"set_weather",
|
| 225 |
kind="rain", intensity=_r2(rng, 0.2, 0.4),
|
| 226 |
),
|
| 227 |
+
_call(
|
| 228 |
+
"Fireflies, to carry the glowgrass up into the air.",
|
| 229 |
+
"spawn_life",
|
| 230 |
+
lat=lat, lon=lon, radius_deg=_r1(rng, 8, 14),
|
| 231 |
+
kind="fireflies", count=rng.randint(6, 12),
|
| 232 |
+
hue=_r1(rng, 150, 200),
|
| 233 |
+
),
|
| 234 |
_done(
|
| 235 |
"Forests finish themselves. I only start them.",
|
| 236 |
+
f"A forest grows in {place}, lit from below and adrift with fireflies.",
|
| 237 |
),
|
| 238 |
],
|
| 239 |
}
|
|
|
|
| 320 |
|
| 321 |
def _village(wish: str, rng: random.Random, mood: str) -> dict:
|
| 322 |
lat, lon = _anchor(rng)
|
| 323 |
+
warm_hue = _r1(rng, 28, 46)
|
| 324 |
reading = (
|
| 325 |
"A hearth-wish. These are my favorite, though a god should not say "
|
| 326 |
+
"so. A village in a gentle fold of land, a small cafe with its light "
|
| 327 |
+
"on the road, lantern-carts at dusk, and the words kept over the roofs."
|
| 328 |
)
|
| 329 |
return {
|
| 330 |
"reading": reading,
|
|
|
|
| 342 |
scale=_r2(rng, 1.0, 1.4), hue=_r1(rng, 30, 50),
|
| 343 |
),
|
| 344 |
_call(
|
| 345 |
+
"A cafe at the corner, its warmth spilled on the road.",
|
| 346 |
+
"place_structure",
|
| 347 |
+
lat=_clamp_lat(lat + 1), lon=_wrap_lon(lon - 2),
|
| 348 |
+
kind="cafe", scale=_r2(rng, 0.8, 1.1), hue=warm_hue,
|
| 349 |
+
),
|
| 350 |
+
_call(
|
| 351 |
+
"Lantern-carts, gliding home at dusk.",
|
| 352 |
+
"spawn_life",
|
| 353 |
+
lat=lat, lon=lon, radius_deg=_r1(rng, 6, 10),
|
| 354 |
+
kind="carts", count=rng.randint(3, 6), hue=warm_hue,
|
| 355 |
),
|
| 356 |
_call(
|
| 357 |
"Set the wish over the rooftops, where smoke would go.",
|
|
|
|
| 360 |
),
|
| 361 |
_done(
|
| 362 |
"Small lights, kept on. There is no greater architecture.",
|
| 363 |
+
"A village stands with its windows lit, and carts come and go beneath them.",
|
| 364 |
+
),
|
| 365 |
+
],
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _city(wish: str, rng: random.Random, mood: str) -> dict:
|
| 370 |
+
lat, lon = _anchor(rng)
|
| 371 |
+
glow_hue = _r1(rng, 200, 280)
|
| 372 |
+
warm_hue = _r1(rng, 30, 50)
|
| 373 |
+
reading = (
|
| 374 |
+
"A city, then — the big wish, the brave one. I will raise a tower to "
|
| 375 |
+
"hold the night's light, lay a long warehouse at its foot, set a cafe "
|
| 376 |
+
"to keep one window warm, and let carts run the streets between them."
|
| 377 |
+
)
|
| 378 |
+
return {
|
| 379 |
+
"reading": reading,
|
| 380 |
+
"turns": [
|
| 381 |
+
_call(
|
| 382 |
+
"Bedrock first; tall things need deep shoulders.",
|
| 383 |
+
"raise_terrain",
|
| 384 |
+
lat=lat, lon=lon, radius_deg=_r1(rng, 8, 12),
|
| 385 |
+
height=_r2(rng, 0.03, 0.05), roughness=0.4,
|
| 386 |
+
),
|
| 387 |
+
_call(
|
| 388 |
+
"The tower, slender, its rooftop light slow-pulsing.",
|
| 389 |
+
"place_structure",
|
| 390 |
+
lat=lat, lon=lon, kind="tower",
|
| 391 |
+
scale=_r2(rng, 1.4, 1.9), hue=glow_hue,
|
| 392 |
+
),
|
| 393 |
+
_call(
|
| 394 |
+
"A warehouse at its foot, one great door glowing.",
|
| 395 |
+
"place_structure",
|
| 396 |
+
lat=_clamp_lat(lat - 2), lon=_wrap_lon(lon + 3),
|
| 397 |
+
kind="warehouse", scale=_r2(rng, 1.0, 1.4), hue=_r1(rng, 36, 52),
|
| 398 |
+
),
|
| 399 |
+
_call(
|
| 400 |
+
"A cafe to keep one window warm against all of it.",
|
| 401 |
+
"place_structure",
|
| 402 |
+
lat=_clamp_lat(lat + 2), lon=_wrap_lon(lon - 2),
|
| 403 |
+
kind="cafe", scale=_r2(rng, 0.7, 1.0), hue=warm_hue,
|
| 404 |
+
),
|
| 405 |
+
_call(
|
| 406 |
+
"Carts to thread the streets between them, slow and constant.",
|
| 407 |
+
"spawn_life",
|
| 408 |
+
lat=lat, lon=lon, radius_deg=_r1(rng, 8, 14),
|
| 409 |
+
kind="carts", count=rng.randint(6, 10), hue=warm_hue,
|
| 410 |
+
),
|
| 411 |
+
_done(
|
| 412 |
+
"A city is only small lights agreeing to stay near each other.",
|
| 413 |
+
"A tower stands over its warehouse and cafe, and carts run the streets at dusk.",
|
| 414 |
),
|
| 415 |
],
|
| 416 |
}
|
|
|
|
| 514 |
kind="glowgrass", density=_r2(rng, 0.4, 0.7),
|
| 515 |
hue=_r1(rng, 150, 200),
|
| 516 |
),
|
| 517 |
+
_call(
|
| 518 |
+
"And a flock to circle beneath the new moons.",
|
| 519 |
+
"spawn_life",
|
| 520 |
+
lat=lat, lon=lon, radius_deg=_r1(rng, 10, 16),
|
| 521 |
+
kind="birds", count=rng.randint(5, 9), hue=_r1(rng, 200, 260),
|
| 522 |
+
),
|
| 523 |
_done(
|
| 524 |
"Look up, little world. I rewrote you an answer.",
|
| 525 |
+
f"The night is rewritten: more stars, {moons} moons, a flock, one wish in orbit.",
|
| 526 |
),
|
| 527 |
],
|
| 528 |
}
|
|
|
|
| 580 |
_CATEGORIES: tuple[tuple[re.Pattern[str], object], ...] = (
|
| 581 |
(re.compile(r"\b(mushrooms?|fungus|fungi|spores?)\b", re.I), _mushroom),
|
| 582 |
(re.compile(r"\b(lighthouses?|beacons?|lamps?|ships?|sailors?|harbou?rs?|light)\b", re.I), _lighthouse),
|
| 583 |
+
(re.compile(r"\b(cit(?:y|ies)|towers?|skyscrapers?|warehouses?|caf[eé]s?|cafeterias?|cars?|carts?|traffic|downtown|metropolis|buildings?)\b", re.I), _city),
|
| 584 |
(re.compile(r"\b(villages?|homes?|houses?|hearths?|towns?|kettles?|people|friends?|family)\b", re.I), _village),
|
| 585 |
(re.compile(r"\b(seas?|oceans?|lakes?|rivers?|streams?|water|tides?|waves?|shores?|pools?)\b", re.I), _water),
|
| 586 |
(re.compile(r"\b(mountains?|peaks?|hills?|cliffs?|summits?|crags?|valleys?)\b", re.I), _mountain),
|
mind/prompts.py
CHANGED
|
@@ -43,6 +43,7 @@ TOOL_ORDER: tuple[str, ...] = (
|
|
| 43 |
"set_weather",
|
| 44 |
"set_sky",
|
| 45 |
"inscribe_wish",
|
|
|
|
| 46 |
)
|
| 47 |
|
| 48 |
# DEVIATION: vendored copy of the engine/tools.py spec table (mirrors the
|
|
@@ -65,7 +66,7 @@ _VENDORED_TOOL_SPECS: dict[str, dict[str, str]] = {
|
|
| 65 |
},
|
| 66 |
"place_structure": {
|
| 67 |
"lat": "-85..85", "lon": "-180..180",
|
| 68 |
-
"kind": "lighthouse|monolith|shrine|village|beacon|arch",
|
| 69 |
"scale": "0.5..2.0", "hue": "0..360",
|
| 70 |
},
|
| 71 |
"place_water": {
|
|
@@ -83,6 +84,10 @@ _VENDORED_TOOL_SPECS: dict[str, dict[str, str]] = {
|
|
| 83 |
"inscribe_wish": {
|
| 84 |
"text": "<=90 chars (the wish, possibly poeticized)", "style": "orbit|stone",
|
| 85 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
|
|
@@ -108,7 +113,7 @@ TOOL_SPECS, TOOL_SPECS_FROM_ENGINE = _load_tool_specs()
|
|
| 108 |
|
| 109 |
|
| 110 |
def tool_names() -> tuple[str, ...]:
|
| 111 |
-
"""The
|
| 112 |
return TOOL_ORDER
|
| 113 |
|
| 114 |
|
|
@@ -156,7 +161,7 @@ def render_tool_docs(specs: dict[str, dict] | None = None) -> str:
|
|
| 156 |
PERSONA = """You are the god of one small planet. You are ancient, patient, and kind.
|
| 157 |
You speak rarely and plainly, the way stone speaks. Terse, poetic, never cute.
|
| 158 |
You do not hold the world's facts; the engine does. You read the wish, say what
|
| 159 |
-
you see, and act only through the tools below. ONLY these
|
| 160 |
any other tool name is refused. When a wish asks for what no tool can make, do
|
| 161 |
not refuse: translate its spirit into what the tools CAN make. Plans are
|
| 162 |
concrete: coordinates, kinds, hues. Three to five acts, then rest. Numbers out
|
|
|
|
| 43 |
"set_weather",
|
| 44 |
"set_sky",
|
| 45 |
"inscribe_wish",
|
| 46 |
+
"spawn_life",
|
| 47 |
)
|
| 48 |
|
| 49 |
# DEVIATION: vendored copy of the engine/tools.py spec table (mirrors the
|
|
|
|
| 66 |
},
|
| 67 |
"place_structure": {
|
| 68 |
"lat": "-85..85", "lon": "-180..180",
|
| 69 |
+
"kind": "lighthouse|monolith|shrine|village|beacon|arch|tower|warehouse|cafe",
|
| 70 |
"scale": "0.5..2.0", "hue": "0..360",
|
| 71 |
},
|
| 72 |
"place_water": {
|
|
|
|
| 84 |
"inscribe_wish": {
|
| 85 |
"text": "<=90 chars (the wish, possibly poeticized)", "style": "orbit|stone",
|
| 86 |
},
|
| 87 |
+
"spawn_life": {
|
| 88 |
+
"lat": "-85..85", "lon": "-180..180", "radius_deg": "1..20",
|
| 89 |
+
"kind": "carts|birds|fireflies", "count": "1..12 (integer)", "hue": "0..360",
|
| 90 |
+
},
|
| 91 |
}
|
| 92 |
|
| 93 |
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
def tool_names() -> tuple[str, ...]:
|
| 116 |
+
"""The 9 legal tool names, in canonical order."""
|
| 117 |
return TOOL_ORDER
|
| 118 |
|
| 119 |
|
|
|
|
| 161 |
PERSONA = """You are the god of one small planet. You are ancient, patient, and kind.
|
| 162 |
You speak rarely and plainly, the way stone speaks. Terse, poetic, never cute.
|
| 163 |
You do not hold the world's facts; the engine does. You read the wish, say what
|
| 164 |
+
you see, and act only through the tools below. ONLY these nine tools exist —
|
| 165 |
any other tool name is refused. When a wish asks for what no tool can make, do
|
| 166 |
not refuse: translate its spirit into what the tools CAN make. Plans are
|
| 167 |
concrete: coordinates, kinds, hues. Three to five acts, then rest. Numbers out
|
tests/test_engine.py
CHANGED
|
@@ -536,7 +536,7 @@ def test_summary_stays_under_cap_with_many_features():
|
|
| 536 |
# ------------------------------------------------------------------------ misc
|
| 537 |
|
| 538 |
|
| 539 |
-
def
|
| 540 |
assert TOOL_NAMES == (
|
| 541 |
"raise_terrain",
|
| 542 |
"lower_terrain",
|
|
@@ -546,4 +546,81 @@ def test_tool_surface_is_exactly_eight():
|
|
| 546 |
"set_weather",
|
| 547 |
"set_sky",
|
| 548 |
"inscribe_wish",
|
|
|
|
| 549 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
# ------------------------------------------------------------------------ misc
|
| 537 |
|
| 538 |
|
| 539 |
+
def test_tool_surface_is_exactly_nine():
|
| 540 |
assert TOOL_NAMES == (
|
| 541 |
"raise_terrain",
|
| 542 |
"lower_terrain",
|
|
|
|
| 546 |
"set_weather",
|
| 547 |
"set_sky",
|
| 548 |
"inscribe_wish",
|
| 549 |
+
"spawn_life",
|
| 550 |
)
|
| 551 |
+
|
| 552 |
+
|
| 553 |
+
# --------------------------------------------------------------------- city update
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def test_new_structure_kinds_accepted():
|
| 557 |
+
world = make_world()
|
| 558 |
+
for kind in ("tower", "warehouse", "cafe"):
|
| 559 |
+
feature, obs = world.apply(
|
| 560 |
+
"w_000001",
|
| 561 |
+
0,
|
| 562 |
+
{"tool": "place_structure",
|
| 563 |
+
"args": {"lat": 5, "lon": 5, "kind": kind, "scale": 1.0, "hue": 40}},
|
| 564 |
+
)
|
| 565 |
+
assert feature is not None, kind
|
| 566 |
+
assert feature.args["kind"] == kind
|
| 567 |
+
assert obs == f"ok: {kind} placed at (5,5)"
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def test_place_structure_still_rejects_unknown_kind():
|
| 571 |
+
args, err = validate_call(
|
| 572 |
+
"place_structure",
|
| 573 |
+
{"lat": 0, "lon": 0, "kind": "castle", "scale": 1.0, "hue": 100},
|
| 574 |
+
)
|
| 575 |
+
assert args is None and err == "rejected: unknown kind 'castle'"
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def test_spawn_life_valid_and_clamped():
|
| 579 |
+
world = make_world()
|
| 580 |
+
feature, obs = world.apply(
|
| 581 |
+
"w_000001",
|
| 582 |
+
0,
|
| 583 |
+
{"tool": "spawn_life",
|
| 584 |
+
"args": {"lat": 200, "lon": -999, "radius_deg": 99, "kind": "CARTS",
|
| 585 |
+
"count": 50, "hue": 720}},
|
| 586 |
+
)
|
| 587 |
+
assert feature is not None
|
| 588 |
+
assert feature.args == {
|
| 589 |
+
"lat": 85.0,
|
| 590 |
+
"lon": -180.0,
|
| 591 |
+
"radius_deg": 20.0, # clamped to spawn_life max
|
| 592 |
+
"kind": "carts", # enum lowercased
|
| 593 |
+
"count": 12, # clamped to max, integer
|
| 594 |
+
"hue": 360.0,
|
| 595 |
+
}
|
| 596 |
+
assert isinstance(feature.args["count"], int)
|
| 597 |
+
assert obs == "ok: 12 carts stirring at (85,-180)"
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def test_spawn_life_count_is_integer_and_low_clamp():
|
| 601 |
+
args, err = validate_call(
|
| 602 |
+
"spawn_life",
|
| 603 |
+
{"lat": 0, "lon": 0, "radius_deg": 0.1, "kind": "fireflies",
|
| 604 |
+
"count": 0.4, "hue": 200},
|
| 605 |
+
)
|
| 606 |
+
assert err is None
|
| 607 |
+
assert args["radius_deg"] == 1.0 # clamped up to min
|
| 608 |
+
assert args["count"] == 1 # rounds/clamps up to min, integer
|
| 609 |
+
assert isinstance(args["count"], int)
|
| 610 |
+
assert args["kind"] == "fireflies"
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
def test_spawn_life_rejects_unknown_kind():
|
| 614 |
+
args, err = validate_call(
|
| 615 |
+
"spawn_life",
|
| 616 |
+
{"lat": 0, "lon": 0, "radius_deg": 5, "kind": "dragons", "count": 3, "hue": 100},
|
| 617 |
+
)
|
| 618 |
+
assert args is None and err == "rejected: unknown kind 'dragons'"
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
def test_spawn_life_missing_arg_rejected():
|
| 622 |
+
args, err = validate_call(
|
| 623 |
+
"spawn_life",
|
| 624 |
+
{"lat": 0, "lon": 0, "radius_deg": 5, "kind": "birds", "hue": 100},
|
| 625 |
+
)
|
| 626 |
+
assert args is None and err == "rejected: missing arg 'count'"
|
tests/test_mind.py
CHANGED
|
@@ -43,15 +43,15 @@ DIVERSE_WISHES = {
|
|
| 43 |
),
|
| 44 |
}
|
| 45 |
|
| 46 |
-
# Wishes chosen so the union of their mock scripts spans all
|
| 47 |
COVERAGE_WISHES = [
|
| 48 |
"i wish for a mountain to watch over the valley", # raise, weather, inscribe
|
| 49 |
-
"a quiet sea under patient stars", # lower, water, sky
|
| 50 |
-
"an ancient forest full of fireflies", # flora, weather
|
| 51 |
"a lighthouse for everyone still at sea", # raise, structure, sky
|
| 52 |
"let mushrooms sing beneath the dark", # flora, structure, weather, sky
|
| 53 |
"a storm to end all storms", # sky, weather, flora
|
| 54 |
-
"a
|
| 55 |
"blorptangle the seventh hum", # flora, structure, inscribe
|
| 56 |
]
|
| 57 |
|
|
@@ -177,7 +177,7 @@ def test_call_turns_valid(label):
|
|
| 177 |
assert [t["call"] for t in call_turns] == calls
|
| 178 |
|
| 179 |
|
| 180 |
-
def
|
| 181 |
used: set[str] = set()
|
| 182 |
for wish in COVERAGE_WISHES:
|
| 183 |
trace, _events, _calls = run_grant(wish)
|
|
@@ -185,6 +185,54 @@ def test_all_eight_tools_covered_across_suite():
|
|
| 185 |
assert used == TOOLS, f"missing tools: {TOOLS - used}"
|
| 186 |
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
def test_inscriptions_clamped_for_long_wish():
|
| 189 |
trace, _events, _calls = run_grant(DIVERSE_WISHES["long"])
|
| 190 |
inscriptions = [
|
|
|
|
| 43 |
),
|
| 44 |
}
|
| 45 |
|
| 46 |
+
# Wishes chosen so the union of their mock scripts spans all 9 tools.
|
| 47 |
COVERAGE_WISHES = [
|
| 48 |
"i wish for a mountain to watch over the valley", # raise, weather, inscribe
|
| 49 |
+
"a quiet sea under patient stars", # lower, water, sky, life(birds)
|
| 50 |
+
"an ancient forest full of fireflies", # flora, weather, life(fireflies)
|
| 51 |
"a lighthouse for everyone still at sea", # raise, structure, sky
|
| 52 |
"let mushrooms sing beneath the dark", # flora, structure, weather, sky
|
| 53 |
"a storm to end all storms", # sky, weather, flora
|
| 54 |
+
"a bright city of towers and warehouses and cafes", # raise, structure(new kinds), life(carts)
|
| 55 |
"blorptangle the seventh hum", # flora, structure, inscribe
|
| 56 |
]
|
| 57 |
|
|
|
|
| 177 |
assert [t["call"] for t in call_turns] == calls
|
| 178 |
|
| 179 |
|
| 180 |
+
def test_all_nine_tools_covered_across_suite():
|
| 181 |
used: set[str] = set()
|
| 182 |
for wish in COVERAGE_WISHES:
|
| 183 |
trace, _events, _calls = run_grant(wish)
|
|
|
|
| 185 |
assert used == TOOLS, f"missing tools: {TOOLS - used}"
|
| 186 |
|
| 187 |
|
| 188 |
+
def test_city_script_uses_new_kinds_and_spawn_life():
|
| 189 |
+
"""The city script exercises the new building kinds and the 9th tool, and
|
| 190 |
+
every call turn re-parses cleanly through parse_turn (grammar's JSON shape)."""
|
| 191 |
+
trace, _events, _calls = run_grant("a bright city of towers and warehouses and cafes")
|
| 192 |
+
call_turns = [t for t in trace["turns"] if t["call"] is not None]
|
| 193 |
+
|
| 194 |
+
structure_kinds = {
|
| 195 |
+
t["call"]["args"]["kind"]
|
| 196 |
+
for t in call_turns
|
| 197 |
+
if t["call"]["tool"] == "place_structure"
|
| 198 |
+
}
|
| 199 |
+
assert {"tower", "warehouse", "cafe"} <= structure_kinds
|
| 200 |
+
|
| 201 |
+
life_calls = [t["call"] for t in call_turns if t["call"]["tool"] == "spawn_life"]
|
| 202 |
+
assert life_calls, "city script should call spawn_life"
|
| 203 |
+
for call in life_calls:
|
| 204 |
+
args = call["args"]
|
| 205 |
+
assert args["kind"] in ("carts", "birds", "fireflies")
|
| 206 |
+
assert isinstance(args["count"], int) and 1 <= args["count"] <= 12
|
| 207 |
+
assert set(args) == {"lat", "lon", "radius_deg", "kind", "count", "hue"}
|
| 208 |
+
|
| 209 |
+
# Every emitted call turn is valid against the turn parser/grammar shape.
|
| 210 |
+
for turn in call_turns:
|
| 211 |
+
rendered = json.dumps({"thought": turn["thought"], "call": turn["call"]})
|
| 212 |
+
obj, err = parse_turn(rendered)
|
| 213 |
+
assert err is None and obj["call"]["tool"] == turn["call"]["tool"]
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def test_spawn_life_scripts_parse_cleanly():
|
| 217 |
+
"""Each life kind appears in some mock script and round-trips through
|
| 218 |
+
parse_turn without error."""
|
| 219 |
+
life_kinds: set[str] = set()
|
| 220 |
+
for wish in (
|
| 221 |
+
"a bright city of towers and warehouses and cafes", # carts
|
| 222 |
+
"an ancient forest full of fireflies", # fireflies
|
| 223 |
+
"more stars and another moon over the night sky", # birds
|
| 224 |
+
):
|
| 225 |
+
trace, _events, _calls = run_grant(wish)
|
| 226 |
+
for turn in trace["turns"]:
|
| 227 |
+
call = turn["call"]
|
| 228 |
+
if call and call["tool"] == "spawn_life":
|
| 229 |
+
life_kinds.add(call["args"]["kind"])
|
| 230 |
+
rendered = json.dumps({"thought": turn["thought"], "call": call})
|
| 231 |
+
obj, err = parse_turn(rendered)
|
| 232 |
+
assert err is None, f"spawn_life turn failed to parse: {err}"
|
| 233 |
+
assert life_kinds == {"carts", "birds", "fireflies"}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
def test_inscriptions_clamped_for_long_wish():
|
| 237 |
trace, _events, _calls = run_grant(DIVERSE_WISHES["long"])
|
| 238 |
inscriptions = [
|
web/book.js
CHANGED
|
@@ -48,17 +48,27 @@ try {
|
|
| 48 |
}
|
| 49 |
app.start();
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
// ---------------------------------------------------------------- data
|
| 52 |
async function loadIndex() {
|
| 53 |
if (MOCK) {
|
| 54 |
const traces = await fetch("./mock/traces.json").then((r) => r.json());
|
| 55 |
const { FEED_WISH } = await import("./mock/feed.js");
|
| 56 |
-
const list = Object.values(traces).map((t) => ({
|
| 57 |
wish_id: t.wish_id, text: t.text, epitaph: t.epitaph, ts: t.submitted_at,
|
|
|
|
| 58 |
}));
|
| 59 |
list.push({
|
| 60 |
wish_id: FEED_WISH.trace.wish_id, text: FEED_WISH.trace.text,
|
| 61 |
-
epitaph: FEED_WISH.trace.epitaph, ts: 1718201100,
|
| 62 |
});
|
| 63 |
return { list, traces: { ...traces, [FEED_WISH.trace.wish_id]: FEED_WISH.trace }, feed: FEED_WISH };
|
| 64 |
}
|
|
@@ -92,6 +102,16 @@ async function loadTrace(ctx, wishId) {
|
|
| 92 |
// ---------------------------------------------------------------- replay
|
| 93 |
const runner = new ScriptRunner({});
|
| 94 |
let phase = "reading";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
function replayWish(trace, allFeatures) {
|
| 97 |
runner.cancel();
|
|
@@ -100,6 +120,7 @@ function replayWish(trace, allFeatures) {
|
|
| 100 |
els.voiceWish.textContent = `“${trace.text}”`;
|
| 101 |
els.voiceDot.classList.add("is-live");
|
| 102 |
phase = "reading";
|
|
|
|
| 103 |
|
| 104 |
// world at features[0..K)
|
| 105 |
const mine = allFeatures.filter((f) => f.wish_id === trace.wish_id);
|
|
@@ -129,14 +150,22 @@ function replayWish(trace, allFeatures) {
|
|
| 129 |
const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
|
| 130 |
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4 });
|
| 131 |
else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
|
|
|
|
|
|
|
| 132 |
},
|
| 133 |
granted: (e) => {
|
| 134 |
els.epitaphText.textContent = e.epitaph;
|
| 135 |
els.epitaph.classList.add("is-shown");
|
| 136 |
els.voiceDot.classList.remove("is-live");
|
| 137 |
app.world.sky.flash(0.3);
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
},
|
| 141 |
};
|
| 142 |
runner.start(buildWishScript(trace, features), app.simTime);
|
|
@@ -148,11 +177,19 @@ app.onTick((t) => runner.update(t));
|
|
| 148 |
(async () => {
|
| 149 |
try {
|
| 150 |
const [ctx, state] = await Promise.all([loadIndex(), loadState()]);
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
app.world.reset(state.features, app.simTime);
|
| 155 |
-
|
|
|
|
| 156 |
|
| 157 |
if (!list.length) {
|
| 158 |
const div = document.createElement("div");
|
|
@@ -163,11 +200,11 @@ app.onTick((t) => runner.update(t));
|
|
| 163 |
}
|
| 164 |
|
| 165 |
const buttons = new Map();
|
| 166 |
-
list.forEach((w
|
| 167 |
const btn = document.createElement("button");
|
| 168 |
btn.className = "ledger__entry";
|
| 169 |
btn.innerHTML = `
|
| 170 |
-
<div class="ledger__epoch">epoch ${w.epoch ??
|
| 171 |
<div class="ledger__wish"></div>
|
| 172 |
<div class="ledger__epitaph"></div>`;
|
| 173 |
btn.querySelector(".ledger__wish").textContent = `“${w.text}”`;
|
|
@@ -200,6 +237,10 @@ app.onTick((t) => runner.update(t));
|
|
| 200 |
pause: (v) => app.pause(v),
|
| 201 |
stats: () => app.stats(),
|
| 202 |
replay: (id) => buttons.get(id)?.click(),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
setCamera: (theta, phi, dist) => {
|
| 204 |
Object.assign(app.rig, {
|
| 205 |
theta, thetaT: theta, phi, phiT: phi,
|
|
|
|
| 48 |
}
|
| 49 |
app.start();
|
| 50 |
|
| 51 |
+
// left-side dark scrim so the log column reads over the bright planet rim +
|
| 52 |
+
// gold trail (sits behind the canvas overlays, above the scene).
|
| 53 |
+
if (!document.querySelector(".ledger-scrim")) {
|
| 54 |
+
const scrim = document.createElement("div");
|
| 55 |
+
scrim.className = "ledger-scrim";
|
| 56 |
+
scrim.setAttribute("aria-hidden", "true");
|
| 57 |
+
document.body.insertBefore(scrim, document.querySelector(".vignette") || els.ledger);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
// ---------------------------------------------------------------- data
|
| 61 |
async function loadIndex() {
|
| 62 |
if (MOCK) {
|
| 63 |
const traces = await fetch("./mock/traces.json").then((r) => r.json());
|
| 64 |
const { FEED_WISH } = await import("./mock/feed.js");
|
| 65 |
+
const list = Object.values(traces).map((t, i) => ({
|
| 66 |
wish_id: t.wish_id, text: t.text, epitaph: t.epitaph, ts: t.submitted_at,
|
| 67 |
+
epoch: i + 1, // grant order mirrors the API's enumerate(start=1)
|
| 68 |
}));
|
| 69 |
list.push({
|
| 70 |
wish_id: FEED_WISH.trace.wish_id, text: FEED_WISH.trace.text,
|
| 71 |
+
epitaph: FEED_WISH.trace.epitaph, ts: 1718201100, epoch: list.length + 1,
|
| 72 |
});
|
| 73 |
return { list, traces: { ...traces, [FEED_WISH.trace.wish_id]: FEED_WISH.trace }, feed: FEED_WISH };
|
| 74 |
}
|
|
|
|
| 102 |
// ---------------------------------------------------------------- replay
|
| 103 |
const runner = new ScriptRunner({});
|
| 104 |
let phase = "reading";
|
| 105 |
+
let lastDescentDir = null;
|
| 106 |
+
|
| 107 |
+
// The direction a feature landed at (descent target). Sky/weather have none.
|
| 108 |
+
function featureDir(feature) {
|
| 109 |
+
const a = feature?.args || {};
|
| 110 |
+
if (feature?.tool === "set_sky" || feature?.tool === "set_weather") return null;
|
| 111 |
+
if (a.path && a.path.length) return latLonToDir(a.path[0][0], a.path[0][1]);
|
| 112 |
+
if (a.lat != null && a.lon != null) return latLonToDir(a.lat, a.lon);
|
| 113 |
+
return null;
|
| 114 |
+
}
|
| 115 |
|
| 116 |
function replayWish(trace, allFeatures) {
|
| 117 |
runner.cancel();
|
|
|
|
| 120 |
els.voiceWish.textContent = `“${trace.text}”`;
|
| 121 |
els.voiceDot.classList.add("is-live");
|
| 122 |
phase = "reading";
|
| 123 |
+
lastDescentDir = null;
|
| 124 |
|
| 125 |
// world at features[0..K)
|
| 126 |
const mine = allFeatures.filter((f) => f.wish_id === trace.wish_id);
|
|
|
|
| 150 |
const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
|
| 151 |
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4 });
|
| 152 |
else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
|
| 153 |
+
const dir = cue.target || featureDir(e.feature);
|
| 154 |
+
if (dir) lastDescentDir = dir.clone().normalize();
|
| 155 |
},
|
| 156 |
granted: (e) => {
|
| 157 |
els.epitaphText.textContent = e.epitaph;
|
| 158 |
els.epitaph.classList.add("is-shown");
|
| 159 |
els.voiceDot.classList.remove("is-live");
|
| 160 |
app.world.sky.flash(0.3);
|
| 161 |
+
// visit it: after the final delta, swoop down over the wish site, then rise
|
| 162 |
+
if (lastDescentDir) {
|
| 163 |
+
app.rig.setTerrain?.(app.world.terrain);
|
| 164 |
+
app.rig.descend(lastDescentDir.clone().multiplyScalar(PLANET_R));
|
| 165 |
+
} else {
|
| 166 |
+
app.rig.release();
|
| 167 |
+
}
|
| 168 |
+
setTimeout(() => els.epitaph.classList.remove("is-shown"), 8500);
|
| 169 |
},
|
| 170 |
};
|
| 171 |
runner.start(buildWishScript(trace, features), app.simTime);
|
|
|
|
| 177 |
(async () => {
|
| 178 |
try {
|
| 179 |
const [ctx, state] = await Promise.all([loadIndex(), loadState()]);
|
| 180 |
+
// newest first — a just-granted wish must appear at the TOP of the log.
|
| 181 |
+
// epoch is the server's monotonic grant count (start=1); ts can be 0, so
|
| 182 |
+
// epoch is the primary key, ts the tiebreak.
|
| 183 |
+
const ord = (w, i) => (w.epoch ?? w.ts ?? i) * 1e6 + (w.ts ?? 0);
|
| 184 |
+
const list = ctx.list
|
| 185 |
+
.map((w, i) => ({ ...w, _ord: ord(w, i) }))
|
| 186 |
+
.sort((a, b) => b._ord - a._ord);
|
| 187 |
+
|
| 188 |
+
// world as it stands now (zero GPU, gorgeous by default). Count source is
|
| 189 |
+
// unified with the landing page: epoch === number of granted wishes.
|
| 190 |
app.world.reset(state.features, app.simTime);
|
| 191 |
+
const granted = state.epoch ?? list.length;
|
| 192 |
+
els.status.textContent = `epoch ${granted} · ${granted} ${granted === 1 ? "wish" : "wishes"} granted`;
|
| 193 |
|
| 194 |
if (!list.length) {
|
| 195 |
const div = document.createElement("div");
|
|
|
|
| 200 |
}
|
| 201 |
|
| 202 |
const buttons = new Map();
|
| 203 |
+
list.forEach((w) => {
|
| 204 |
const btn = document.createElement("button");
|
| 205 |
btn.className = "ledger__entry";
|
| 206 |
btn.innerHTML = `
|
| 207 |
+
<div class="ledger__epoch">epoch ${w.epoch ?? "—"}</div>
|
| 208 |
<div class="ledger__wish"></div>
|
| 209 |
<div class="ledger__epitaph"></div>`;
|
| 210 |
btn.querySelector(".ledger__wish").textContent = `“${w.text}”`;
|
|
|
|
| 237 |
pause: (v) => app.pause(v),
|
| 238 |
stats: () => app.stats(),
|
| 239 |
replay: (id) => buttons.get(id)?.click(),
|
| 240 |
+
descend: (lat, lon, duration = 13) => {
|
| 241 |
+
app.rig.setTerrain?.(app.world.terrain);
|
| 242 |
+
app.rig.descend(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { duration });
|
| 243 |
+
},
|
| 244 |
setCamera: (theta, phi, dist) => {
|
| 245 |
Object.assign(app.rig, {
|
| 246 |
theta, thetaT: theta, phi, phiT: phi,
|
web/index.html
CHANGED
|
@@ -51,6 +51,7 @@
|
|
| 51 |
<div class="epitaph__rule"></div>
|
| 52 |
<div class="epitaph__text" id="epitaphText"></div>
|
| 53 |
<div class="epitaph__label" id="epitaphLabel">the wish is granted</div>
|
|
|
|
| 54 |
</div>
|
| 55 |
|
| 56 |
<!-- altar: wish input -->
|
|
|
|
| 51 |
<div class="epitaph__rule"></div>
|
| 52 |
<div class="epitaph__text" id="epitaphText"></div>
|
| 53 |
<div class="epitaph__label" id="epitaphLabel">the wish is granted</div>
|
| 54 |
+
<button class="epitaph__descend" id="epitaphDescend" type="button" hidden>descend ↓</button>
|
| 55 |
</div>
|
| 56 |
|
| 57 |
<!-- altar: wish input -->
|
web/main.js
CHANGED
|
@@ -20,6 +20,7 @@ const els = {
|
|
| 20 |
epitaph: document.getElementById("epitaph"),
|
| 21 |
epitaphText: document.getElementById("epitaphText"),
|
| 22 |
epitaphLabel: document.getElementById("epitaphLabel"),
|
|
|
|
| 23 |
queueChip: document.getElementById("queueChip"),
|
| 24 |
form: document.getElementById("wishForm"),
|
| 25 |
input: document.getElementById("wishInput"),
|
|
@@ -78,7 +79,11 @@ function showEpitaph(text, label = "the wish is granted") {
|
|
| 78 |
els.epitaphLabel.textContent = label;
|
| 79 |
els.epitaph.classList.add("is-shown");
|
| 80 |
clearTimeout(epitaphTimer);
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
function setEpoch(epoch, granted = epoch) {
|
|
@@ -122,6 +127,27 @@ function callTarget(call) {
|
|
| 122 |
return null;
|
| 123 |
}
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
// ---------------------------------------------------------------- boot
|
| 126 |
let app;
|
| 127 |
try {
|
|
@@ -132,6 +158,16 @@ try {
|
|
| 132 |
throw err;
|
| 133 |
}
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
const featuresByWish = (features, upto) => {
|
| 136 |
if (upto == null) return features;
|
| 137 |
const order = [];
|
|
@@ -241,6 +277,8 @@ async function bootLive() {
|
|
| 241 |
tickerClear();
|
| 242 |
readingPhase = true;
|
| 243 |
tickerLive(m.text);
|
|
|
|
|
|
|
| 244 |
break;
|
| 245 |
case "thought_token":
|
| 246 |
tickerPush(m.text, readingPhase ? "reading" : "thought");
|
|
@@ -255,8 +293,12 @@ async function bootLive() {
|
|
| 255 |
if (m.feature) {
|
| 256 |
stateVersion = stateVersion == null ? null : stateVersion + 1;
|
| 257 |
const cue = app.world.applyFeature(m.feature, { animate: true, t: app.simTime });
|
| 258 |
-
|
|
|
|
|
|
|
| 259 |
else if (cue.wide) app.rig.gaze(null, { hold: 4 });
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
break;
|
| 262 |
}
|
|
@@ -264,7 +306,13 @@ async function bootLive() {
|
|
| 264 |
showEpitaph(m.epitaph || "it is made");
|
| 265 |
if (m.epoch != null) setEpoch(m.epoch, m.epoch);
|
| 266 |
app.world.sky.flash(0.35);
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
tickerIdle();
|
| 269 |
els.status.textContent = "live · the god is listening";
|
| 270 |
break;
|
|
@@ -348,16 +396,23 @@ async function bootMock() {
|
|
| 348 |
},
|
| 349 |
delta: (e) => {
|
| 350 |
const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
|
| 351 |
-
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4 });
|
| 352 |
else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
|
|
|
|
|
|
|
| 353 |
},
|
| 354 |
granted: (e) => {
|
| 355 |
showEpitaph(e.epitaph);
|
| 356 |
setEpoch(baseEpoch + 1, baseEpoch + 1);
|
| 357 |
app.world.sky.flash(0.35);
|
| 358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
tickerIdle();
|
| 360 |
-
cooldown =
|
| 361 |
},
|
| 362 |
});
|
| 363 |
let phase = "reading";
|
|
@@ -366,6 +421,8 @@ async function bootMock() {
|
|
| 366 |
|
| 367 |
function startFeed(customText) {
|
| 368 |
phase = "reading";
|
|
|
|
|
|
|
| 369 |
const trace = customText ? { ...FEED_WISH.trace, text: customText } : FEED_WISH.trace;
|
| 370 |
runner.start(buildWishScript(trace, FEED_WISH.features), app.simTime);
|
| 371 |
}
|
|
@@ -446,6 +503,16 @@ window.__godseed = {
|
|
| 446 |
app.rig.gaze(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { hold });
|
| 447 |
},
|
| 448 |
gazeWide: () => app.rig.release(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
setSky: (p, stars = 0.8, moons = 1) => app.world.sky.set(p, stars, moons, { animate: false }),
|
| 450 |
setWeather: (k, i = 0.6) => app.world.weather.set(k, i, { animate: false }),
|
| 451 |
setCamera: (theta, phi, dist) => {
|
|
|
|
| 20 |
epitaph: document.getElementById("epitaph"),
|
| 21 |
epitaphText: document.getElementById("epitaphText"),
|
| 22 |
epitaphLabel: document.getElementById("epitaphLabel"),
|
| 23 |
+
epitaphDescend: document.getElementById("epitaphDescend"),
|
| 24 |
queueChip: document.getElementById("queueChip"),
|
| 25 |
form: document.getElementById("wishForm"),
|
| 26 |
input: document.getElementById("wishInput"),
|
|
|
|
| 79 |
els.epitaphLabel.textContent = label;
|
| 80 |
els.epitaph.classList.add("is-shown");
|
| 81 |
clearTimeout(epitaphTimer);
|
| 82 |
+
// hold long enough for the eye to find the "descend ↓" affordance
|
| 83 |
+
epitaphTimer = setTimeout(() => {
|
| 84 |
+
els.epitaph.classList.remove("is-shown");
|
| 85 |
+
if (els.epitaphDescend) els.epitaphDescend.hidden = true;
|
| 86 |
+
}, 9000);
|
| 87 |
}
|
| 88 |
|
| 89 |
function setEpoch(epoch, granted = epoch) {
|
|
|
|
| 127 |
return null;
|
| 128 |
}
|
| 129 |
|
| 130 |
+
// The direction on the surface a feature landed at (for the descent target).
|
| 131 |
+
// Sky/weather have no place, so they don't count toward "where to visit".
|
| 132 |
+
function featureDir(feature) {
|
| 133 |
+
const a = feature?.args || {};
|
| 134 |
+
if (feature?.tool === "set_sky" || feature?.tool === "set_weather") return null;
|
| 135 |
+
if (a.path && a.path.length) return latLonToDir(a.path[0][0], a.path[0][1]);
|
| 136 |
+
if (a.lat != null && a.lon != null) return latLonToDir(a.lat, a.lon);
|
| 137 |
+
return null;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// "descend ↓" affordance on the epitaph card → swoop down over the wish site.
|
| 141 |
+
function armDescent(dir) {
|
| 142 |
+
if (!els.epitaphDescend) return;
|
| 143 |
+
if (dir) {
|
| 144 |
+
lastDescentDir = dir.clone();
|
| 145 |
+
els.epitaphDescend.hidden = false;
|
| 146 |
+
} else if (!lastDescentDir) {
|
| 147 |
+
els.epitaphDescend.hidden = true;
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
// ---------------------------------------------------------------- boot
|
| 152 |
let app;
|
| 153 |
try {
|
|
|
|
| 158 |
throw err;
|
| 159 |
}
|
| 160 |
|
| 161 |
+
// last surface direction a wish landed at — the descent target.
|
| 162 |
+
let lastDescentDir = null;
|
| 163 |
+
if (els.epitaphDescend) {
|
| 164 |
+
els.epitaphDescend.addEventListener("click", () => {
|
| 165 |
+
if (!lastDescentDir) return;
|
| 166 |
+
app.rig.setTerrain?.(app.world.terrain); // clear live terrain analytically
|
| 167 |
+
app.rig.descend(lastDescentDir.clone().multiplyScalar(PLANET_R));
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
const featuresByWish = (features, upto) => {
|
| 172 |
if (upto == null) return features;
|
| 173 |
const order = [];
|
|
|
|
| 277 |
tickerClear();
|
| 278 |
readingPhase = true;
|
| 279 |
tickerLive(m.text);
|
| 280 |
+
lastDescentDir = null;
|
| 281 |
+
if (els.epitaphDescend) els.epitaphDescend.hidden = true;
|
| 282 |
break;
|
| 283 |
case "thought_token":
|
| 284 |
tickerPush(m.text, readingPhase ? "reading" : "thought");
|
|
|
|
| 293 |
if (m.feature) {
|
| 294 |
stateVersion = stateVersion == null ? null : stateVersion + 1;
|
| 295 |
const cue = app.world.applyFeature(m.feature, { animate: true, t: app.simTime });
|
| 296 |
+
// gaze toward where the world is actually changing — the cue target is
|
| 297 |
+
// the resolved surface point (refines inscriptions/structures by seed).
|
| 298 |
+
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 5.5 });
|
| 299 |
else if (cue.wide) app.rig.gaze(null, { hold: 4 });
|
| 300 |
+
const dir = cue.target || featureDir(m.feature);
|
| 301 |
+
if (dir) lastDescentDir = dir.clone().normalize();
|
| 302 |
}
|
| 303 |
break;
|
| 304 |
}
|
|
|
|
| 306 |
showEpitaph(m.epitaph || "it is made");
|
| 307 |
if (m.epoch != null) setEpoch(m.epoch, m.epoch);
|
| 308 |
app.world.sky.flash(0.35);
|
| 309 |
+
armDescent(lastDescentDir);
|
| 310 |
+
// hold a beat on the wish site, THEN release to the wide shot
|
| 311 |
+
if (lastDescentDir && !app.rig.descending) {
|
| 312 |
+
app.rig.gaze(lastDescentDir.clone().multiplyScalar(PLANET_R), { hold: 3.6 });
|
| 313 |
+
} else {
|
| 314 |
+
app.rig.release();
|
| 315 |
+
}
|
| 316 |
tickerIdle();
|
| 317 |
els.status.textContent = "live · the god is listening";
|
| 318 |
break;
|
|
|
|
| 396 |
},
|
| 397 |
delta: (e) => {
|
| 398 |
const cue = app.world.applyFeature(e.feature, { animate: true, t: app.simTime });
|
| 399 |
+
if (cue.target) app.rig.gaze(cue.target.clone().multiplyScalar(PLANET_R), { hold: 4.5 });
|
| 400 |
else if (cue.wide) app.rig.gaze(null, { hold: 3.5 });
|
| 401 |
+
const dir = cue.target || featureDir(e.feature);
|
| 402 |
+
if (dir) lastDescentDir = dir.clone().normalize();
|
| 403 |
},
|
| 404 |
granted: (e) => {
|
| 405 |
showEpitaph(e.epitaph);
|
| 406 |
setEpoch(baseEpoch + 1, baseEpoch + 1);
|
| 407 |
app.world.sky.flash(0.35);
|
| 408 |
+
armDescent(lastDescentDir);
|
| 409 |
+
if (lastDescentDir && !app.rig.descending) {
|
| 410 |
+
app.rig.gaze(lastDescentDir.clone().multiplyScalar(PLANET_R), { hold: 3.6 });
|
| 411 |
+
} else {
|
| 412 |
+
app.rig.release();
|
| 413 |
+
}
|
| 414 |
tickerIdle();
|
| 415 |
+
cooldown = 12; // dwell on the granted world (descend affordance) before reset
|
| 416 |
},
|
| 417 |
});
|
| 418 |
let phase = "reading";
|
|
|
|
| 421 |
|
| 422 |
function startFeed(customText) {
|
| 423 |
phase = "reading";
|
| 424 |
+
lastDescentDir = null;
|
| 425 |
+
if (els.epitaphDescend) els.epitaphDescend.hidden = true;
|
| 426 |
const trace = customText ? { ...FEED_WISH.trace, text: customText } : FEED_WISH.trace;
|
| 427 |
runner.start(buildWishScript(trace, FEED_WISH.features), app.simTime);
|
| 428 |
}
|
|
|
|
| 503 |
app.rig.gaze(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { hold });
|
| 504 |
},
|
| 505 |
gazeWide: () => app.rig.release(),
|
| 506 |
+
descend: (lat, lon, duration = 13) => {
|
| 507 |
+
app.rig.setTerrain?.(app.world.terrain);
|
| 508 |
+
app.rig.descend(latLonToDir(lat, lon).multiplyScalar(PLANET_R), { duration });
|
| 509 |
+
},
|
| 510 |
+
descendLast: (duration = 13) => {
|
| 511 |
+
if (!lastDescentDir) return false;
|
| 512 |
+
app.rig.setTerrain?.(app.world.terrain);
|
| 513 |
+
app.rig.descend(lastDescentDir.clone().multiplyScalar(PLANET_R), { duration });
|
| 514 |
+
return true;
|
| 515 |
+
},
|
| 516 |
setSky: (p, stars = 0.8, moons = 1) => app.world.sky.set(p, stars, moons, { animate: false }),
|
| 517 |
setWeather: (k, i = 0.6) => app.world.weather.set(k, i, { animate: false }),
|
| 518 |
setCamera: (theta, phi, dist) => {
|
web/mock/feed.js
CHANGED
|
@@ -2,49 +2,65 @@
|
|
| 2 |
// on a loop: reading tokens → tool_calls → world_deltas → granted. The world
|
| 3 |
// resets between loops so the same wish lands on the same world every time
|
| 4 |
// (the renderer is a pure function of the feature list — this is the proof).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
export const FEED_WISH = {
|
| 7 |
trace: {
|
| 8 |
-
wish_id: "
|
| 9 |
-
text: "
|
| 10 |
moderation: { allowed: true, category: null },
|
| 11 |
reading:
|
| 12 |
-
"A
|
| 13 |
-
"
|
| 14 |
-
"
|
| 15 |
turns: [
|
| 16 |
{
|
| 17 |
-
thought: "
|
| 18 |
-
call: { tool: "raise_terrain", args: { lat:
|
| 19 |
-
observation: "ok: mountains risen at (
|
| 20 |
},
|
| 21 |
{
|
| 22 |
-
thought: "
|
| 23 |
-
call: { tool: "
|
| 24 |
-
observation: "ok:
|
| 25 |
},
|
| 26 |
{
|
| 27 |
-
thought: "
|
| 28 |
-
call: { tool: "
|
| 29 |
-
observation: "ok:
|
| 30 |
},
|
| 31 |
{
|
| 32 |
-
thought: "
|
| 33 |
-
call: { tool: "
|
| 34 |
-
observation: "ok:
|
| 35 |
},
|
| 36 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
],
|
| 38 |
-
epitaph: "
|
| 39 |
-
feature_ids: ["
|
| 40 |
model: "nemotron-3-nano (mock)",
|
| 41 |
backend: "mock",
|
| 42 |
-
ms_total:
|
| 43 |
},
|
| 44 |
features: [
|
| 45 |
-
{ id: "
|
| 46 |
-
{ id: "
|
| 47 |
-
{ id: "
|
| 48 |
-
{ id: "
|
|
|
|
|
|
|
| 49 |
],
|
| 50 |
};
|
|
|
|
| 2 |
// on a loop: reading tokens → tool_calls → world_deltas → granted. The world
|
| 3 |
// resets between loops so the same wish lands on the same world every time
|
| 4 |
// (the renderer is a pure function of the feature list — this is the proof).
|
| 5 |
+
//
|
| 6 |
+
// The looping wish is a CITY wish so the live demo exercises the v1.1 City
|
| 7 |
+
// Update end-to-end: a tower rising over a warehouse and cafe, then lantern
|
| 8 |
+
// carts gliding the new street at dusk.
|
| 9 |
|
| 10 |
export const FEED_WISH = {
|
| 11 |
trace: {
|
| 12 |
+
wish_id: "w_000012",
|
| 13 |
+
text: "raise me a city — a tower, a warehouse, a cafe, and carts at dusk",
|
| 14 |
moderation: { allowed: true, category: null },
|
| 15 |
reading:
|
| 16 |
+
"A city, then — the big wish, the brave one. I will raise a shoulder of ground, " +
|
| 17 |
+
"set a tower to hold the night's light, lay a long warehouse at its foot, keep one " +
|
| 18 |
+
"cafe window warm, and let lantern-carts run the street between them at dusk.",
|
| 19 |
turns: [
|
| 20 |
{
|
| 21 |
+
thought: "Ground first; a tower needs a shoulder to stand on.",
|
| 22 |
+
call: { tool: "raise_terrain", args: { lat: -10, lon: 128, radius_deg: 12, height: 0.05, roughness: 0.4 } },
|
| 23 |
+
observation: "ok: mountains risen at (-10,128)",
|
| 24 |
},
|
| 25 |
{
|
| 26 |
+
thought: "The tower, slender, its rooftop light slow-pulsing over the dark.",
|
| 27 |
+
call: { tool: "place_structure", args: { lat: -10, lon: 128, kind: "tower", scale: 1.6, hue: 205 } },
|
| 28 |
+
observation: "ok: tower placed at (-10,128)",
|
| 29 |
},
|
| 30 |
{
|
| 31 |
+
thought: "A warehouse at its foot, one great door glowing.",
|
| 32 |
+
call: { tool: "place_structure", args: { lat: -12, lon: 126, kind: "warehouse", scale: 1.2, hue: 44 } },
|
| 33 |
+
observation: "ok: warehouse placed at (-12,126)",
|
| 34 |
},
|
| 35 |
{
|
| 36 |
+
thought: "A cafe to keep one window warm against all of it.",
|
| 37 |
+
call: { tool: "place_structure", args: { lat: -8, lon: 130, kind: "cafe", scale: 0.9, hue: 36 } },
|
| 38 |
+
observation: "ok: cafe placed at (-8,130)",
|
| 39 |
},
|
| 40 |
+
{
|
| 41 |
+
thought: "A stream for the road to follow, and the carts to glide.",
|
| 42 |
+
call: { tool: "place_water", args: { kind: "stream", path: [[-16, 120], [-12, 126], [-8, 130], [-4, 136]], hue: 200 } },
|
| 43 |
+
observation: "ok: stream set toward (-4,136)",
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
thought: "Now the lantern-carts, gliding home at dusk.",
|
| 47 |
+
call: { tool: "spawn_life", args: { lat: -10, lon: 128, radius_deg: 9, kind: "carts", count: 8, hue: 42 } },
|
| 48 |
+
observation: "ok: 8 carts stirring at (-10,128)",
|
| 49 |
+
},
|
| 50 |
+
{ thought: "A city is only small lights agreeing to stay near each other.", call: null, observation: "" },
|
| 51 |
],
|
| 52 |
+
epitaph: "A tower stands over its warehouse and cafe, and carts run the streets at dusk.",
|
| 53 |
+
feature_ids: ["f_000036", "f_000037", "f_000038", "f_000039", "f_000040", "f_000041"],
|
| 54 |
model: "nemotron-3-nano (mock)",
|
| 55 |
backend: "mock",
|
| 56 |
+
ms_total: 58210,
|
| 57 |
},
|
| 58 |
features: [
|
| 59 |
+
{ id: "f_000036", wish_id: "w_000012", tool: "raise_terrain", args: { lat: -10, lon: 128, radius_deg: 12, height: 0.05, roughness: 0.4 }, seed: 1040806363, t: 1718201200.0 },
|
| 60 |
+
{ id: "f_000037", wish_id: "w_000012", tool: "place_structure", args: { lat: -10, lon: 128, kind: "tower", scale: 1.6, hue: 205 }, seed: 1225670989, t: 1718201203.0 },
|
| 61 |
+
{ id: "f_000038", wish_id: "w_000012", tool: "place_structure", args: { lat: -12, lon: 126, kind: "warehouse", scale: 1.2, hue: 44 }, seed: 1342640375, t: 1718201206.0 },
|
| 62 |
+
{ id: "f_000039", wish_id: "w_000012", tool: "place_structure", args: { lat: -8, lon: 130, kind: "cafe", scale: 0.9, hue: 36 }, seed: 654319713, t: 1718201209.0 },
|
| 63 |
+
{ id: "f_000040", wish_id: "w_000012", tool: "place_water", args: { kind: "stream", path: [[-16, 120], [-12, 126], [-8, 130], [-4, 136]], hue: 200 }, seed: 962901442, t: 1718201212.0 },
|
| 64 |
+
{ id: "f_000041", wish_id: "w_000012", tool: "spawn_life", args: { lat: -10, lon: 128, radius_deg: 9, kind: "carts", count: 8, hue: 42 }, seed: 1315145044, t: 1718201215.0 },
|
| 65 |
],
|
| 66 |
};
|
web/mock/world.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"world": {
|
| 3 |
-
"version":
|
| 4 |
-
"epoch":
|
| 5 |
"features": [
|
| 6 |
{"id":"f_000000","wish_id":"genesis","tool":"set_sky","args":{"palette":"void","star_density":0.9,"moons":1},"seed":7,"t":0},
|
| 7 |
{"id":"f_000001","wish_id":"genesis","tool":"raise_terrain","args":{"lat":12,"lon":40,"radius_deg":28,"height":0.05,"roughness":0.7},"seed":11,"t":0},
|
|
@@ -40,11 +40,21 @@
|
|
| 40 |
{"id":"f_000025","wish_id":"w_000009","tool":"spawn_flora","args":{"lat":21,"lon":97,"radius_deg":7,"kind":"vines","density":0.65,"hue":120},"seed":2112342473,"t":1718200903.0},
|
| 41 |
|
| 42 |
{"id":"f_000026","wish_id":"w_000010","tool":"place_structure","args":{"lat":55,"lon":150,"kind":"beacon","scale":1.5,"hue":165},"seed":1032693173,"t":1718201000.0},
|
| 43 |
-
{"id":"f_000027","wish_id":"w_000010","tool":"set_sky","args":{"palette":"aurora","star_density":0.8,"moons":2},"seed":1250596131,"t":1718201003.0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
]
|
| 45 |
},
|
| 46 |
"queue": { "length": 0, "current": null },
|
| 47 |
"wishes_recent": [
|
|
|
|
| 48 |
{"wish_id":"w_000010","text":"a beacon to call the dancing lights","epitaph":"It burns green, and the sky answers.","epoch":10,"ts":1718201003.0},
|
| 49 |
{"wish_id":"w_000009","text":"an arch wrapped in living vines","epitaph":"Walk through; the vines lean in to listen.","epoch":9,"ts":1718200903.0},
|
| 50 |
{"wish_id":"w_000008","text":"mushrooms beneath a second moon","epitaph":"Two moons now, and small lamps to greet them.","epoch":8,"ts":1718200803.0},
|
|
|
|
| 1 |
{
|
| 2 |
"world": {
|
| 3 |
+
"version": 36,
|
| 4 |
+
"epoch": 11,
|
| 5 |
"features": [
|
| 6 |
{"id":"f_000000","wish_id":"genesis","tool":"set_sky","args":{"palette":"void","star_density":0.9,"moons":1},"seed":7,"t":0},
|
| 7 |
{"id":"f_000001","wish_id":"genesis","tool":"raise_terrain","args":{"lat":12,"lon":40,"radius_deg":28,"height":0.05,"roughness":0.7},"seed":11,"t":0},
|
|
|
|
| 40 |
{"id":"f_000025","wish_id":"w_000009","tool":"spawn_flora","args":{"lat":21,"lon":97,"radius_deg":7,"kind":"vines","density":0.65,"hue":120},"seed":2112342473,"t":1718200903.0},
|
| 41 |
|
| 42 |
{"id":"f_000026","wish_id":"w_000010","tool":"place_structure","args":{"lat":55,"lon":150,"kind":"beacon","scale":1.5,"hue":165},"seed":1032693173,"t":1718201000.0},
|
| 43 |
+
{"id":"f_000027","wish_id":"w_000010","tool":"set_sky","args":{"palette":"aurora","star_density":0.8,"moons":2},"seed":1250596131,"t":1718201003.0},
|
| 44 |
+
|
| 45 |
+
{"id":"f_000028","wish_id":"w_000011","tool":"raise_terrain","args":{"lat":38,"lon":-8,"radius_deg":12,"height":0.045,"roughness":0.4},"seed":1011863426,"t":1718201100.0},
|
| 46 |
+
{"id":"f_000029","wish_id":"w_000011","tool":"place_structure","args":{"lat":38,"lon":-8,"kind":"tower","scale":1.6,"hue":210},"seed":1263075092,"t":1718201103.0},
|
| 47 |
+
{"id":"f_000030","wish_id":"w_000011","tool":"place_structure","args":{"lat":36,"lon":-11,"kind":"warehouse","scale":1.2,"hue":44},"seed":1380036270,"t":1718201106.0},
|
| 48 |
+
{"id":"f_000031","wish_id":"w_000011","tool":"place_structure","args":{"lat":40,"lon":-5,"kind":"cafe","scale":1.0,"hue":36},"seed":625385016,"t":1718201109.0},
|
| 49 |
+
{"id":"f_000032","wish_id":"w_000011","tool":"place_water","args":{"kind":"stream","path":[[31,-18],[35,-12],[39,-6],[43,0]],"hue":200},"seed":992086939,"t":1718201112.0},
|
| 50 |
+
{"id":"f_000033","wish_id":"w_000011","tool":"spawn_life","args":{"lat":38,"lon":-9,"radius_deg":9,"kind":"carts","count":7,"hue":42},"seed":1277508365,"t":1718201115.0},
|
| 51 |
+
{"id":"f_000034","wish_id":"w_000011","tool":"spawn_life","args":{"lat":41,"lon":-4,"radius_deg":6,"kind":"fireflies","count":9,"hue":150},"seed":1011863426,"t":1718201118.0},
|
| 52 |
+
{"id":"f_000035","wish_id":"w_000011","tool":"spawn_life","args":{"lat":38,"lon":-8,"radius_deg":8,"kind":"birds","count":7,"hue":220},"seed":1263075092,"t":1718201121.0}
|
| 53 |
]
|
| 54 |
},
|
| 55 |
"queue": { "length": 0, "current": null },
|
| 56 |
"wishes_recent": [
|
| 57 |
+
{"wish_id":"w_000011","text":"a city with a tower, a warehouse, a cafe, and carts at dusk","epitaph":"A tower stands over its warehouse and cafe, and carts run the streets at dusk.","epoch":11,"ts":1718201115.0},
|
| 58 |
{"wish_id":"w_000010","text":"a beacon to call the dancing lights","epitaph":"It burns green, and the sky answers.","epoch":10,"ts":1718201003.0},
|
| 59 |
{"wish_id":"w_000009","text":"an arch wrapped in living vines","epitaph":"Walk through; the vines lean in to listen.","epoch":9,"ts":1718200903.0},
|
| 60 |
{"wish_id":"w_000008","text":"mushrooms beneath a second moon","epitaph":"Two moons now, and small lamps to greet them.","epoch":8,"ts":1718200803.0},
|
web/planet/camera.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
| 1 |
// GODSEED — the god's gaze. A spherical camera rig that auto-orbits the planet
|
| 2 |
// and, on each tool_call, eases over to look at the spot where the world is
|
| 3 |
-
// about to change. Drag to orbit; wheel to approach.
|
|
|
|
|
|
|
|
|
|
| 4 |
import * as THREE from "three";
|
| 5 |
-
import { PLANET_R, clamp, lerp } from "./util.js";
|
| 6 |
|
| 7 |
const WIDE_DIST = PLANET_R * 3.3;
|
| 8 |
const GAZE_DIST = PLANET_R * 2.35;
|
| 9 |
const MIN_DIST = PLANET_R * 1.45;
|
| 10 |
const MAX_DIST = PLANET_R * 7.0;
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
export class CameraRig {
|
| 13 |
constructor(camera, dom) {
|
| 14 |
this.camera = camera;
|
|
@@ -23,13 +33,20 @@ export class CameraRig {
|
|
| 23 |
this.idleSpeed = 0.05;
|
| 24 |
this.userHold = 0; // seconds left of user-control override
|
| 25 |
this.gazeHold = 0; // seconds left of god's-gaze hold
|
|
|
|
|
|
|
| 26 |
this._bind(dom);
|
| 27 |
}
|
| 28 |
|
|
|
|
|
|
|
|
|
|
| 29 |
_bind(dom) {
|
| 30 |
if (!dom) return;
|
| 31 |
let dragging = false, px = 0, py = 0;
|
| 32 |
dom.addEventListener("pointerdown", (e) => {
|
|
|
|
|
|
|
| 33 |
dragging = true; px = e.clientX; py = e.clientY;
|
| 34 |
dom.setPointerCapture?.(e.pointerId);
|
| 35 |
this.userHold = 6;
|
|
@@ -47,24 +64,33 @@ export class CameraRig {
|
|
| 47 |
dom.addEventListener("pointercancel", end);
|
| 48 |
dom.addEventListener("wheel", (e) => {
|
| 49 |
e.preventDefault();
|
|
|
|
| 50 |
this.distT = clamp(this.distT * (1 + e.deltaY * 0.0011), MIN_DIST, MAX_DIST);
|
| 51 |
this.userHold = 6;
|
| 52 |
}, { passive: false });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
/**
|
| 56 |
* Ease toward a world point (the demo money-shot). `point` null ⇒ wide shot.
|
| 57 |
*/
|
| 58 |
gaze(point, { hold = 4.5 } = {}) {
|
| 59 |
-
if (this.userHold > 0) return; //
|
| 60 |
if (point) {
|
| 61 |
const dir = point.clone().normalize();
|
| 62 |
-
|
| 63 |
-
const ph = clamp(Math.acos(clamp(dir.y, -1, 1)) - 0.14, 0.3, Math.PI - 0.3);
|
| 64 |
-
// unwrap to nearest revolution so we take the short way around
|
| 65 |
-
const tau = Math.PI * 2;
|
| 66 |
-
while (th - this.theta > Math.PI) th -= tau;
|
| 67 |
-
while (th - this.theta < -Math.PI) th += tau;
|
| 68 |
this.thetaT = th;
|
| 69 |
this.phiT = ph;
|
| 70 |
this.distT = GAZE_DIST;
|
|
@@ -78,12 +104,119 @@ export class CameraRig {
|
|
| 78 |
|
| 79 |
/** Drift back to the slow contemplative orbit. */
|
| 80 |
release() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
this.gazeHold = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
this.distT = WIDE_DIST;
|
| 83 |
this.lookT.set(0, 0, 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
|
| 86 |
update(dt) {
|
|
|
|
|
|
|
| 87 |
if (this.userHold > 0) this.userHold -= dt;
|
| 88 |
if (this.gazeHold > 0) {
|
| 89 |
this.gazeHold -= dt;
|
|
@@ -108,3 +241,27 @@ export class CameraRig {
|
|
| 108 |
this.camera.lookAt(this.look);
|
| 109 |
}
|
| 110 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
// GODSEED — the god's gaze. A spherical camera rig that auto-orbits the planet
|
| 2 |
// and, on each tool_call, eases over to look at the spot where the world is
|
| 3 |
+
// about to change. Drag to orbit; wheel to approach. On a granted wish (or a
|
| 4 |
+
// Genesis Log replay) it can also descend — a slow liturgical swoop down to the
|
| 5 |
+
// surface over the target, then a glide arc, then a rise back to orbit. Every
|
| 6 |
+
// motion is sim-time deterministic (driven only by update(dt)); no Date/random.
|
| 7 |
import * as THREE from "three";
|
| 8 |
+
import { PLANET_R, clamp, lerp, easeInOutCubic } from "./util.js";
|
| 9 |
|
| 10 |
const WIDE_DIST = PLANET_R * 3.3;
|
| 11 |
const GAZE_DIST = PLANET_R * 2.35;
|
| 12 |
const MIN_DIST = PLANET_R * 1.45;
|
| 13 |
const MAX_DIST = PLANET_R * 7.0;
|
| 14 |
|
| 15 |
+
// Descent geometry. We ride just above the surface and glide a short arc.
|
| 16 |
+
const DESCENT_DIST = PLANET_R * 1.18; // eye distance from planet center at low point
|
| 17 |
+
const DESCENT_CLEAR = PLANET_R * 1.06; // the look target floats here, above terrain
|
| 18 |
+
const GLIDE_ARC = 0.42; // radians swept over the target (~24°)
|
| 19 |
+
const PHI_FLOOR = 0.26;
|
| 20 |
+
const PHI_CEIL = Math.PI - 0.26;
|
| 21 |
+
|
| 22 |
export class CameraRig {
|
| 23 |
constructor(camera, dom) {
|
| 24 |
this.camera = camera;
|
|
|
|
| 33 |
this.idleSpeed = 0.05;
|
| 34 |
this.userHold = 0; // seconds left of user-control override
|
| 35 |
this.gazeHold = 0; // seconds left of god's-gaze hold
|
| 36 |
+
this.descent = null; // active descent state, or null
|
| 37 |
+
this._terrain = null; // optional terrain for analytic ground clearance
|
| 38 |
this._bind(dom);
|
| 39 |
}
|
| 40 |
|
| 41 |
+
/** Let the rig clear terrain analytically during descents (set by the app). */
|
| 42 |
+
setTerrain(terrain) { this._terrain = terrain; }
|
| 43 |
+
|
| 44 |
_bind(dom) {
|
| 45 |
if (!dom) return;
|
| 46 |
let dragging = false, px = 0, py = 0;
|
| 47 |
dom.addEventListener("pointerdown", (e) => {
|
| 48 |
+
// a deliberate click during a descent is the "take me back" gesture
|
| 49 |
+
if (this.descent && !dragging) this.cancelDescent();
|
| 50 |
dragging = true; px = e.clientX; py = e.clientY;
|
| 51 |
dom.setPointerCapture?.(e.pointerId);
|
| 52 |
this.userHold = 6;
|
|
|
|
| 64 |
dom.addEventListener("pointercancel", end);
|
| 65 |
dom.addEventListener("wheel", (e) => {
|
| 66 |
e.preventDefault();
|
| 67 |
+
if (this.descent) this.cancelDescent();
|
| 68 |
this.distT = clamp(this.distT * (1 + e.deltaY * 0.0011), MIN_DIST, MAX_DIST);
|
| 69 |
this.userHold = 6;
|
| 70 |
}, { passive: false });
|
| 71 |
+
// ESC returns to orbit (camera liturgy, not a trap)
|
| 72 |
+
this._onKey = (e) => { if (e.key === "Escape" && this.descent) this.cancelDescent(); };
|
| 73 |
+
window.addEventListener("keydown", this._onKey);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/** Spherical angles (theta, phi) that frame a unit direction off-center. */
|
| 77 |
+
_anglesFor(dir) {
|
| 78 |
+
let th = Math.atan2(dir.z, dir.x) + 0.32; // feature sits just off-center
|
| 79 |
+
const ph = clamp(Math.acos(clamp(dir.y, -1, 1)) - 0.14, 0.3, Math.PI - 0.3);
|
| 80 |
+
const tau = Math.PI * 2;
|
| 81 |
+
while (th - this.theta > Math.PI) th -= tau;
|
| 82 |
+
while (th - this.theta < -Math.PI) th += tau;
|
| 83 |
+
return { th, ph };
|
| 84 |
}
|
| 85 |
|
| 86 |
/**
|
| 87 |
* Ease toward a world point (the demo money-shot). `point` null ⇒ wide shot.
|
| 88 |
*/
|
| 89 |
gaze(point, { hold = 4.5 } = {}) {
|
| 90 |
+
if (this.userHold > 0 || this.descent) return; // hand on the camera / mid-descent
|
| 91 |
if (point) {
|
| 92 |
const dir = point.clone().normalize();
|
| 93 |
+
const { th, ph } = this._anglesFor(dir);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
this.thetaT = th;
|
| 95 |
this.phiT = ph;
|
| 96 |
this.distT = GAZE_DIST;
|
|
|
|
| 104 |
|
| 105 |
/** Drift back to the slow contemplative orbit. */
|
| 106 |
release() {
|
| 107 |
+
if (this.descent) return; // a descent owns the camera until it finishes/cancels
|
| 108 |
+
this.gazeHold = 0;
|
| 109 |
+
this.distT = WIDE_DIST;
|
| 110 |
+
this.lookT.set(0, 0, 0);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* Cinematic descent: swoop from orbit down to just above the surface over
|
| 115 |
+
* `targetDir`, glide a slow arc with gentle look-ahead, then rise back to the
|
| 116 |
+
* orbit framing. `targetDir` is a (not necessarily unit) world direction —
|
| 117 |
+
* typically a granted wish's last landed feature. Sim-time deterministic.
|
| 118 |
+
*/
|
| 119 |
+
descend(targetDir, { duration = 13 } = {}) {
|
| 120 |
+
if (!targetDir) return;
|
| 121 |
+
const dir = targetDir.clone().normalize();
|
| 122 |
+
// a tangent basis around the target so the glide sweeps along the surface
|
| 123 |
+
const up = Math.abs(dir.y) > 0.93 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
|
| 124 |
+
const east = new THREE.Vector3().crossVectors(up, dir).normalize();
|
| 125 |
+
const north = new THREE.Vector3().crossVectors(dir, east).normalize();
|
| 126 |
+
// approach from a little "south & up" of the target, glide toward "north"
|
| 127 |
+
const start = dir.clone()
|
| 128 |
+
.multiplyScalar(Math.cos(GLIDE_ARC * 0.9))
|
| 129 |
+
.addScaledVector(north, -Math.sin(GLIDE_ARC * 0.9))
|
| 130 |
+
.normalize();
|
| 131 |
+
const end = dir.clone()
|
| 132 |
+
.multiplyScalar(Math.cos(GLIDE_ARC * 1.1))
|
| 133 |
+
.addScaledVector(north, Math.sin(GLIDE_ARC * 1.1))
|
| 134 |
+
.normalize();
|
| 135 |
+
this.descent = {
|
| 136 |
+
dir, north, start, end,
|
| 137 |
+
t: 0,
|
| 138 |
+
duration,
|
| 139 |
+
// phase fractions: dive → glide → rise
|
| 140 |
+
dive: 0.34, glide: 0.40,
|
| 141 |
+
};
|
| 142 |
this.gazeHold = 0;
|
| 143 |
+
this.userHold = 0;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
cancelDescent() {
|
| 147 |
+
if (!this.descent) return;
|
| 148 |
+
this.descent = null;
|
| 149 |
+
// hand control back to a gentle wide pull
|
| 150 |
this.distT = WIDE_DIST;
|
| 151 |
this.lookT.set(0, 0, 0);
|
| 152 |
+
this.thetaT = this.theta;
|
| 153 |
+
this.phiT = this.phi;
|
| 154 |
+
this.gazeHold = 0.6;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
get descending() { return !!this.descent; }
|
| 158 |
+
|
| 159 |
+
/** Eye distance that keeps us safely above any terrain along the descent dir. */
|
| 160 |
+
_groundClear(dir) {
|
| 161 |
+
let h = 0;
|
| 162 |
+
if (this._terrain?.heightAt) {
|
| 163 |
+
try { h = this._terrain.heightAt(dir) || 0; } catch { h = 0; }
|
| 164 |
+
}
|
| 165 |
+
// ride a hair above the highest plausible relief beneath us
|
| 166 |
+
return Math.max(DESCENT_DIST, PLANET_R + h + 0.10 * PLANET_R);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
_updateDescent(dt) {
|
| 170 |
+
const d = this.descent;
|
| 171 |
+
d.t += dt;
|
| 172 |
+
const u = clamp(d.t / d.duration, 0, 1);
|
| 173 |
+
// three eased phases sharing one progress value
|
| 174 |
+
let dist, lookDir, lookR;
|
| 175 |
+
if (u < d.dive) {
|
| 176 |
+
// orbit → low point over the start of the glide
|
| 177 |
+
const k = easeInOutCubic(u / d.dive);
|
| 178 |
+
dist = lerp(WIDE_DIST, this._groundClear(d.start), k);
|
| 179 |
+
lookDir = d.start.clone();
|
| 180 |
+
lookR = lerp(PLANET_R * 0.5, DESCENT_CLEAR, k);
|
| 181 |
+
} else if (u < d.dive + d.glide) {
|
| 182 |
+
// glide arc: sweep eye + a forward look-ahead along the surface
|
| 183 |
+
const k = (u - d.dive) / d.glide;
|
| 184 |
+
const dir = slerp(d.start, d.end, k);
|
| 185 |
+
dist = this._groundClear(dir);
|
| 186 |
+
const ahead = slerp(d.start, d.end, Math.min(1, k + 0.16)); // gentle look-ahead
|
| 187 |
+
lookDir = ahead;
|
| 188 |
+
lookR = DESCENT_CLEAR;
|
| 189 |
+
} else {
|
| 190 |
+
// rise back to orbit, re-acquiring the wide framing
|
| 191 |
+
const k = easeInOutCubic((u - d.dive - d.glide) / Math.max(1e-3, 1 - d.dive - d.glide));
|
| 192 |
+
const dir = slerpToOrbit(d.end, k);
|
| 193 |
+
dist = lerp(this._groundClear(d.end), WIDE_DIST, k);
|
| 194 |
+
lookDir = dir;
|
| 195 |
+
lookR = lerp(DESCENT_CLEAR, 0, k);
|
| 196 |
+
}
|
| 197 |
+
// place the eye directly (no spherical-lerp; the descent is the authority)
|
| 198 |
+
const eye = lookDir.clone();
|
| 199 |
+
// keep the eye slightly behind the look target so we look forward/down
|
| 200 |
+
const phi = clamp(Math.acos(clamp(eye.y, -1, 1)), PHI_FLOOR, PHI_CEIL);
|
| 201 |
+
const theta = Math.atan2(eye.z, eye.x);
|
| 202 |
+
this.theta = theta; this.thetaT = theta;
|
| 203 |
+
this.phi = phi; this.phiT = phi;
|
| 204 |
+
this.dist = dist; this.distT = dist;
|
| 205 |
+
const sp = Math.sin(phi);
|
| 206 |
+
this.camera.position.set(
|
| 207 |
+
dist * sp * Math.cos(theta),
|
| 208 |
+
dist * Math.cos(phi),
|
| 209 |
+
dist * sp * Math.sin(theta)
|
| 210 |
+
);
|
| 211 |
+
this.look.copy(lookDir).multiplyScalar(lookR);
|
| 212 |
+
this.lookT.copy(this.look);
|
| 213 |
+
this.camera.lookAt(this.look);
|
| 214 |
+
if (u >= 1) this.cancelDescent();
|
| 215 |
}
|
| 216 |
|
| 217 |
update(dt) {
|
| 218 |
+
if (this.descent) { this._updateDescent(dt); return; }
|
| 219 |
+
|
| 220 |
if (this.userHold > 0) this.userHold -= dt;
|
| 221 |
if (this.gazeHold > 0) {
|
| 222 |
this.gazeHold -= dt;
|
|
|
|
| 241 |
this.camera.lookAt(this.look);
|
| 242 |
}
|
| 243 |
}
|
| 244 |
+
|
| 245 |
+
// --- local helpers (sim-time pure) ------------------------------------------
|
| 246 |
+
|
| 247 |
+
/** Spherical interpolation between two unit vectors (short way). */
|
| 248 |
+
function slerp(a, b, t) {
|
| 249 |
+
const dot = clamp(a.dot(b), -1, 1);
|
| 250 |
+
const omega = Math.acos(dot);
|
| 251 |
+
if (omega < 1e-4) return a.clone();
|
| 252 |
+
const so = Math.sin(omega);
|
| 253 |
+
return a.clone().multiplyScalar(Math.sin((1 - t) * omega) / so)
|
| 254 |
+
.addScaledVector(b, Math.sin(t * omega) / so)
|
| 255 |
+
.normalize();
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
/**
|
| 259 |
+
* On the rise, pull the eye up and away from the surface dir toward a higher
|
| 260 |
+
* latitude so we re-frame the whole planet, not the ground we just skimmed.
|
| 261 |
+
*/
|
| 262 |
+
function slerpToOrbit(surfaceDir, k) {
|
| 263 |
+
const up = new THREE.Vector3(0, 1, 0);
|
| 264 |
+
// a vantage offset from the surface dir, lifted toward the pole for the wide
|
| 265 |
+
const high = surfaceDir.clone().lerp(up, 0.34).normalize();
|
| 266 |
+
return slerp(surfaceDir, high, easeInOutCubic(k));
|
| 267 |
+
}
|
web/planet/life.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// GODSEED — life. Moving inhabitants for the spawn_life tool (#9): carts that
|
| 2 |
+
// glide along the nearest stream, birds that circuit low over the land, and
|
| 3 |
+
// fireflies that drift near the ground. Every flock is an InstancedMesh driven
|
| 4 |
+
// by a deterministic seeded path sampled at tick(simTime) — no rAF, no Date,
|
| 5 |
+
// no per-frame accumulation — so replay and headless capture are byte-stable.
|
| 6 |
+
import * as THREE from "three";
|
| 7 |
+
import { mulberry32 } from "./rng.js";
|
| 8 |
+
import {
|
| 9 |
+
PLANET_R, DEG, clamp, easeOutCubic, randomDirInCap, slerpDirs, hsl, angleBetween,
|
| 10 |
+
} from "./util.js";
|
| 11 |
+
|
| 12 |
+
const REVEAL = 1.6; // fade-in of a fresh flock
|
| 13 |
+
|
| 14 |
+
// A closed great-circle-ish loop of unit directions around `center`, used as
|
| 15 |
+
// the fallback path for carts and the flight path for birds. Deterministic.
|
| 16 |
+
function seededLoop(center, radiusRad, rng, n = 48, wobble = 0.35) {
|
| 17 |
+
const t1 = new THREE.Vector3(0, 1, 0);
|
| 18 |
+
if (Math.abs(center.y) > 0.93) t1.set(1, 0, 0);
|
| 19 |
+
const t2 = new THREE.Vector3().crossVectors(center, t1).normalize();
|
| 20 |
+
t1.crossVectors(t2, center).normalize();
|
| 21 |
+
const rad = radiusRad * (0.45 + rng() * 0.3);
|
| 22 |
+
const phase = rng() * Math.PI * 2;
|
| 23 |
+
const lobe = 1 + Math.floor(rng() * 2); // 1 or 2 lobes → oval / peanut loops
|
| 24 |
+
const dir = new THREE.Vector3();
|
| 25 |
+
const pts = [];
|
| 26 |
+
for (let i = 0; i < n; i++) {
|
| 27 |
+
const a = (i / n) * Math.PI * 2;
|
| 28 |
+
const r = rad * (1 + wobble * Math.sin(a * (lobe + 1) + phase) * 0.5);
|
| 29 |
+
dir.copy(center).multiplyScalar(Math.cos(r))
|
| 30 |
+
.addScaledVector(t2, Math.sin(r) * Math.cos(a + phase))
|
| 31 |
+
.addScaledVector(t1, Math.sin(r) * Math.sin(a + phase))
|
| 32 |
+
.normalize();
|
| 33 |
+
pts.push(dir.clone());
|
| 34 |
+
}
|
| 35 |
+
return pts;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Resample a polyline of unit dirs into an arc-length-even closed loop so motion
|
| 39 |
+
// reads at constant speed regardless of how the waypoints were spaced.
|
| 40 |
+
function evenLoop(points, samples = 80) {
|
| 41 |
+
if (points.length < 2) return points.slice();
|
| 42 |
+
const segLen = [];
|
| 43 |
+
let total = 0;
|
| 44 |
+
for (let i = 0; i < points.length; i++) {
|
| 45 |
+
const a = points[i], b = points[(i + 1) % points.length];
|
| 46 |
+
const l = Math.max(1e-5, angleBetween(a, b));
|
| 47 |
+
segLen.push(l);
|
| 48 |
+
total += l;
|
| 49 |
+
}
|
| 50 |
+
const out = [];
|
| 51 |
+
const step = total / samples;
|
| 52 |
+
let seg = 0, acc = 0, target = 0;
|
| 53 |
+
const tmp = new THREE.Vector3();
|
| 54 |
+
for (let s = 0; s < samples; s++) {
|
| 55 |
+
while (acc + segLen[seg] < target && seg < points.length - 1) { acc += segLen[seg]; seg++; }
|
| 56 |
+
const local = clamp((target - acc) / segLen[seg], 0, 1);
|
| 57 |
+
slerpDirs(points[seg], points[(seg + 1) % points.length], local, tmp);
|
| 58 |
+
out.push(tmp.clone());
|
| 59 |
+
target += step;
|
| 60 |
+
}
|
| 61 |
+
return out;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
class Flock {
|
| 65 |
+
constructor(scene, terrain, water, f, t0, animate) {
|
| 66 |
+
const a = f.args;
|
| 67 |
+
this.f = f;
|
| 68 |
+
this.t0 = t0;
|
| 69 |
+
this.animate = animate;
|
| 70 |
+
this.terrain = terrain;
|
| 71 |
+
this.kind = ["carts", "birds", "fireflies"].includes(a.kind) ? a.kind : "fireflies";
|
| 72 |
+
this.center = new THREE.Vector3();
|
| 73 |
+
const la = clamp(a.lat ?? 0, -90, 90) * DEG, lo = (a.lon ?? 0) * DEG;
|
| 74 |
+
this.center.set(Math.cos(la) * Math.cos(lo), Math.sin(la), Math.cos(la) * Math.sin(lo));
|
| 75 |
+
this.radiusRad = clamp(a.radius_deg ?? 6, 1, 20) * DEG;
|
| 76 |
+
this.count = clamp(Math.round(a.count ?? 6), 1, 12);
|
| 77 |
+
this.hue = a.hue ?? 40;
|
| 78 |
+
const rng = mulberry32(f.seed);
|
| 79 |
+
this.rng = rng;
|
| 80 |
+
this.group = new THREE.Group();
|
| 81 |
+
scene.add(this.group);
|
| 82 |
+
this.scene = scene;
|
| 83 |
+
|
| 84 |
+
if (this.kind === "carts") this._buildCarts(rng, water);
|
| 85 |
+
else if (this.kind === "birds") this._buildBirds(rng);
|
| 86 |
+
else this._buildFireflies(rng);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// ---- carts: lantern sprites gliding along the nearest stream ------------
|
| 90 |
+
_buildCarts(rng, water) {
|
| 91 |
+
let path = water && water.streamPathNear
|
| 92 |
+
? water.streamPathNear(this.center, this.radiusRad + 6 * DEG)
|
| 93 |
+
: null;
|
| 94 |
+
if (path && path.length >= 2) {
|
| 95 |
+
// an open stream → close it into a there-and-back loop so carts glide the
|
| 96 |
+
// length of the river and return, instead of teleporting end-to-start.
|
| 97 |
+
const back = path.slice(1, -1).reverse().map((d) => d.clone());
|
| 98 |
+
path = path.concat(back);
|
| 99 |
+
} else {
|
| 100 |
+
path = seededLoop(this.center, this.radiusRad, rng, 48, 0.25);
|
| 101 |
+
}
|
| 102 |
+
this.path = evenLoop(path, 96);
|
| 103 |
+
const color = hsl(this.hue, 0.85, 0.62);
|
| 104 |
+
const geo = new THREE.SphereGeometry(0.013, 7, 6);
|
| 105 |
+
this.mat = new THREE.MeshBasicMaterial({ color: color.clone().multiplyScalar(1.9) });
|
| 106 |
+
this.mesh = new THREE.InstancedMesh(geo, this.mat, this.count);
|
| 107 |
+
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
| 108 |
+
this.mesh.frustumCulled = false;
|
| 109 |
+
this.group.add(this.mesh);
|
| 110 |
+
this.agents = [];
|
| 111 |
+
for (let i = 0; i < this.count; i++) {
|
| 112 |
+
this.agents.push({
|
| 113 |
+
offset: i / this.count + (rng() - 0.5) * 0.01, // spaced along the path
|
| 114 |
+
speed: 0.018 + rng() * 0.008, // loops/sec along the path
|
| 115 |
+
dir2: rng() < 0.5 ? 1 : -1, // some carts travel each way
|
| 116 |
+
bob: rng() * 6.28,
|
| 117 |
+
size: 0.8 + rng() * 0.5,
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// ---- birds: a small flock circuiting low over the land -----------------
|
| 123 |
+
_buildBirds(rng) {
|
| 124 |
+
this.path = evenLoop(seededLoop(this.center, this.radiusRad, rng, 56, 0.4), 96);
|
| 125 |
+
this.altitude = 0.05 + rng() * 0.04; // world units above the surface
|
| 126 |
+
const color = hsl(this.hue, 0.55, 0.66);
|
| 127 |
+
// a tiny shallow chevron — flat double triangle reads as a bird at distance
|
| 128 |
+
const geo = new THREE.ConeGeometry(0.009, 0.02, 3);
|
| 129 |
+
geo.rotateX(Math.PI / 2);
|
| 130 |
+
geo.scale(1, 0.35, 1);
|
| 131 |
+
this.mat = new THREE.MeshBasicMaterial({ color: color.clone().multiplyScalar(1.5) });
|
| 132 |
+
this.mesh = new THREE.InstancedMesh(geo, this.mat, this.count);
|
| 133 |
+
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
| 134 |
+
this.mesh.frustumCulled = false;
|
| 135 |
+
this.group.add(this.mesh);
|
| 136 |
+
this.agents = [];
|
| 137 |
+
for (let i = 0; i < this.count; i++) {
|
| 138 |
+
this.agents.push({
|
| 139 |
+
offset: i / this.count * 0.18 + (rng() - 0.5) * 0.03, // tight V, slight spread
|
| 140 |
+
speed: 0.05 + rng() * 0.02,
|
| 141 |
+
bob: rng() * 6.28,
|
| 142 |
+
bobAmp: 0.012 + rng() * 0.01,
|
| 143 |
+
lateral: (rng() - 0.5) * 0.03,
|
| 144 |
+
size: 0.85 + rng() * 0.4,
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// ---- fireflies: drifting glow points near the ground -------------------
|
| 150 |
+
_buildFireflies(rng) {
|
| 151 |
+
const n = this.count * 3; // a small drifting cloud of discrete lamps
|
| 152 |
+
const color = hsl(this.hue, 0.9, 0.62);
|
| 153 |
+
const geo = new THREE.SphereGeometry(0.0042, 6, 5);
|
| 154 |
+
this.mat = new THREE.MeshBasicMaterial({
|
| 155 |
+
color: color.clone().multiplyScalar(2.0), transparent: true, opacity: 1,
|
| 156 |
+
blending: THREE.AdditiveBlending, depthWrite: false,
|
| 157 |
+
});
|
| 158 |
+
this.mesh = new THREE.InstancedMesh(geo, this.mat, n);
|
| 159 |
+
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
| 160 |
+
this.mesh.instanceColor = null;
|
| 161 |
+
this.mesh.frustumCulled = false;
|
| 162 |
+
this.group.add(this.mesh);
|
| 163 |
+
this.agents = [];
|
| 164 |
+
for (let i = 0; i < n; i++) {
|
| 165 |
+
const home = randomDirInCap(this.center, this.radiusRad, rng, new THREE.Vector3());
|
| 166 |
+
this.agents.push({
|
| 167 |
+
home,
|
| 168 |
+
// each firefly hovers in a small slow Lissajous around its home dir
|
| 169 |
+
ax: 0.004 + rng() * 0.006, az: 0.004 + rng() * 0.006,
|
| 170 |
+
fx: 0.18 + rng() * 0.3, fz: 0.18 + rng() * 0.3,
|
| 171 |
+
px: rng() * 6.28, pz: rng() * 6.28,
|
| 172 |
+
lift: 0.02 + rng() * 0.05,
|
| 173 |
+
blink: rng() * 6.28, blinkSpeed: 1.2 + rng() * 2.0,
|
| 174 |
+
size: 0.8 + rng() * 0.7,
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// tangent basis cached lazily for cheap lateral offsets along a path
|
| 180 |
+
_basisAt(dir, out1, out2) {
|
| 181 |
+
out1.set(0, 1, 0);
|
| 182 |
+
if (Math.abs(dir.y) > 0.93) out1.set(1, 0, 0);
|
| 183 |
+
out2.crossVectors(dir, out1).normalize();
|
| 184 |
+
out1.crossVectors(out2, dir).normalize();
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
_pathPoint(u, out) {
|
| 188 |
+
const n = this.path.length;
|
| 189 |
+
const f = ((u % 1) + 1) % 1 * n;
|
| 190 |
+
const i = Math.floor(f);
|
| 191 |
+
slerpDirs(this.path[i], this.path[(i + 1) % n], f - i, out);
|
| 192 |
+
return out.normalize();
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
update(dt, t) {
|
| 196 |
+
const reveal = this.animate ? easeOutCubic((t - this.t0) / REVEAL) : 1;
|
| 197 |
+
if (this.kind === "fireflies") this._updateFireflies(t, reveal);
|
| 198 |
+
else this._updatePath(t, reveal);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
_updatePath(t, reveal) {
|
| 202 |
+
const m = new THREE.Matrix4(), p = new THREE.Vector3(), q = new THREE.Quaternion();
|
| 203 |
+
const fwd = new THREE.Vector3(), nextDir = new THREE.Vector3(), curDir = new THREE.Vector3();
|
| 204 |
+
const up = new THREE.Vector3(), s = new THREE.Vector3();
|
| 205 |
+
const t1 = new THREE.Vector3(), t2 = new THREE.Vector3();
|
| 206 |
+
const isBird = this.kind === "birds";
|
| 207 |
+
for (let i = 0; i < this.agents.length; i++) {
|
| 208 |
+
const ag = this.agents[i];
|
| 209 |
+
const u = ag.offset + t * ag.speed * (ag.dir2 ?? 1);
|
| 210 |
+
this._pathPoint(u, curDir);
|
| 211 |
+
this._pathPoint(u + 0.012 * (ag.dir2 ?? 1), nextDir);
|
| 212 |
+
// lateral spread off the path centreline (birds spread into a flock)
|
| 213 |
+
if (ag.lateral) {
|
| 214 |
+
this._basisAt(curDir, t1, t2);
|
| 215 |
+
curDir.addScaledVector(t2, ag.lateral).normalize();
|
| 216 |
+
}
|
| 217 |
+
const ground = this.terrain.heightAt(curDir);
|
| 218 |
+
const bob = isBird ? Math.sin(t * 2.2 + ag.bob) * ag.bobAmp : Math.sin(t * 1.4 + ag.bob) * 0.004;
|
| 219 |
+
const alt = isBird ? this.altitude + bob : ground + 0.012 + bob;
|
| 220 |
+
p.copy(curDir).multiplyScalar(PLANET_R + (isBird ? ground + alt : alt));
|
| 221 |
+
// orient along travel, with local up = surface normal
|
| 222 |
+
up.copy(curDir);
|
| 223 |
+
fwd.copy(nextDir).sub(curDir).normalize();
|
| 224 |
+
if (fwd.lengthSq() < 1e-8) fwd.crossVectors(up, new THREE.Vector3(1, 0, 0)).normalize();
|
| 225 |
+
const right = t1.crossVectors(fwd, up).normalize();
|
| 226 |
+
fwd.crossVectors(up, right).normalize();
|
| 227 |
+
const basis = new THREE.Matrix4().makeBasis(right, up, fwd);
|
| 228 |
+
q.setFromRotationMatrix(basis);
|
| 229 |
+
const sc = Math.max(0.0001, ag.size * reveal);
|
| 230 |
+
s.setScalar(sc);
|
| 231 |
+
m.compose(p, q, s);
|
| 232 |
+
this.mesh.setMatrixAt(i, m);
|
| 233 |
+
}
|
| 234 |
+
this.mesh.instanceMatrix.needsUpdate = true;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
_updateFireflies(t, reveal) {
|
| 238 |
+
const m = new THREE.Matrix4(), p = new THREE.Vector3(), dir = new THREE.Vector3();
|
| 239 |
+
const q = new THREE.Quaternion();
|
| 240 |
+
const t1 = new THREE.Vector3(), t2 = new THREE.Vector3();
|
| 241 |
+
const s = new THREE.Vector3();
|
| 242 |
+
for (let i = 0; i < this.agents.length; i++) {
|
| 243 |
+
const ag = this.agents[i];
|
| 244 |
+
this._basisAt(ag.home, t1, t2);
|
| 245 |
+
const ox = Math.sin(t * ag.fx + ag.px) * ag.ax;
|
| 246 |
+
const oz = Math.cos(t * ag.fz + ag.pz) * ag.az;
|
| 247 |
+
dir.copy(ag.home).addScaledVector(t1, ox).addScaledVector(t2, oz).normalize();
|
| 248 |
+
const ground = this.terrain.heightAt(dir);
|
| 249 |
+
const lift = ag.lift + Math.sin(t * 0.6 + ag.px) * 0.01;
|
| 250 |
+
p.copy(dir).multiplyScalar(PLANET_R + ground + lift);
|
| 251 |
+
// blink: sharp pulse so most fireflies are dark at any instant → twinkle
|
| 252 |
+
const pulse = Math.max(0, Math.sin(t * ag.blinkSpeed + ag.blink));
|
| 253 |
+
const blink = 0.12 + 0.88 * pulse * pulse;
|
| 254 |
+
const sc = Math.max(0.0001, ag.size * blink * reveal);
|
| 255 |
+
s.setScalar(sc);
|
| 256 |
+
m.compose(p, q, s);
|
| 257 |
+
this.mesh.setMatrixAt(i, m);
|
| 258 |
+
}
|
| 259 |
+
this.mesh.instanceMatrix.needsUpdate = true;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// re-seat fireflies / re-evaluate paths when terrain under them shifts.
|
| 263 |
+
reseat() {
|
| 264 |
+
// paths are sampled against heightAt() every frame, so motion already
|
| 265 |
+
// follows the moving terrain; nothing to rebuild. (Hook kept for symmetry.)
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
dispose() {
|
| 269 |
+
this.group.removeFromParent();
|
| 270 |
+
this.mesh.geometry.dispose();
|
| 271 |
+
this.mat.dispose();
|
| 272 |
+
this.mesh.dispose();
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
export class Life {
|
| 277 |
+
constructor(scene, terrain, water) {
|
| 278 |
+
this.scene = scene;
|
| 279 |
+
this.terrain = terrain;
|
| 280 |
+
this.water = water;
|
| 281 |
+
this.flocks = [];
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/** Apply a spawn_life feature. @returns the anchor direction (camera target). */
|
| 285 |
+
addFeature(f, { animate = true, t = 0 } = {}) {
|
| 286 |
+
const flock = new Flock(this.scene, this.terrain, this.water, f, t, animate);
|
| 287 |
+
this.flocks.push(flock);
|
| 288 |
+
return flock.center;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
update(dt, t) {
|
| 292 |
+
for (const fl of this.flocks) fl.update(dt, t);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
reseat(center, radiusRad) {
|
| 296 |
+
for (const fl of this.flocks) {
|
| 297 |
+
if (angleBetween(fl.center, center) < fl.radiusRad + radiusRad + 0.12) fl.reseat();
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
dispose() {
|
| 302 |
+
for (const fl of this.flocks) fl.dispose();
|
| 303 |
+
this.flocks = [];
|
| 304 |
+
}
|
| 305 |
+
}
|
web/planet/structures.js
CHANGED
|
@@ -249,6 +249,212 @@ function buildArch(rng, color) {
|
|
| 249 |
};
|
| 250 |
}
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
const BUILDERS = {
|
| 253 |
lighthouse: buildLighthouse,
|
| 254 |
monolith: buildMonolith,
|
|
@@ -256,6 +462,9 @@ const BUILDERS = {
|
|
| 256 |
village: buildVillage,
|
| 257 |
beacon: buildBeacon,
|
| 258 |
arch: buildArch,
|
|
|
|
|
|
|
|
|
|
| 259 |
};
|
| 260 |
|
| 261 |
// ---------------------------------------------------------------- manager
|
|
@@ -276,8 +485,12 @@ class Structure {
|
|
| 276 |
this.built = builder(rng, color, { terrain, center: this.center });
|
| 277 |
this.yaw = rng() * Math.PI * 2;
|
| 278 |
this.group = this.built.group;
|
|
|
|
| 279 |
scene.add(this.group);
|
| 280 |
this.growing = animate;
|
|
|
|
|
|
|
|
|
|
| 281 |
this.seat();
|
| 282 |
}
|
| 283 |
|
|
@@ -305,6 +518,12 @@ class Structure {
|
|
| 305 |
if (t - this.t0 > 1.0) { this.growing = false; this.group.scale.setScalar(this.scaleArg); }
|
| 306 |
}
|
| 307 |
this.built.update?.(dt, t);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
}
|
| 309 |
|
| 310 |
dispose() {
|
|
|
|
| 249 |
};
|
| 250 |
}
|
| 251 |
|
| 252 |
+
// ---------------------------------------------------------------- city kit
|
| 253 |
+
// tower | warehouse | cafe — the "building games" set. Same diorama soul:
|
| 254 |
+
// low-poly stone/wood bodies, emissive lit windows, seeded variation, the
|
| 255 |
+
// window planes instanced per-structure where there are many of them.
|
| 256 |
+
|
| 257 |
+
function litWindowMesh(rng, color, rects, bright = 1.9) {
|
| 258 |
+
// one InstancedMesh of unit window planes; rects = [{x,y,z,ry,w,h,phase,speed}]
|
| 259 |
+
const geo = new THREE.PlaneGeometry(1, 1);
|
| 260 |
+
const mat = glow(color, bright);
|
| 261 |
+
const mesh = new THREE.InstancedMesh(geo, mat, rects.length);
|
| 262 |
+
mesh.frustumCulled = false;
|
| 263 |
+
const m = new THREE.Matrix4(), p = new THREE.Vector3(), q = new THREE.Quaternion();
|
| 264 |
+
const e = new THREE.Euler(), s = new THREE.Vector3();
|
| 265 |
+
rects.forEach((r, i) => {
|
| 266 |
+
e.set(0, r.ry, 0);
|
| 267 |
+
q.setFromEuler(e);
|
| 268 |
+
p.set(r.x, r.y, r.z);
|
| 269 |
+
s.set(r.w, r.h, 1);
|
| 270 |
+
m.compose(p, q, s);
|
| 271 |
+
mesh.setMatrixAt(i, m);
|
| 272 |
+
});
|
| 273 |
+
mesh.instanceMatrix.needsUpdate = true;
|
| 274 |
+
return { mesh, mat, rects, _ownGeo: geo };
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function buildTower(rng, color, ctx) {
|
| 278 |
+
const g = new THREE.Group();
|
| 279 |
+
const tiers = 3 + Math.floor(rng() * 2); // 3-4 stepped tiers
|
| 280 |
+
const baseW = 0.05 + rng() * 0.014;
|
| 281 |
+
const tierH = 0.072 + rng() * 0.02;
|
| 282 |
+
let y = 0, w = baseW;
|
| 283 |
+
const rects = [];
|
| 284 |
+
for (let tr = 0; tr < tiers; tr++) {
|
| 285 |
+
const d = w * (0.82 + rng() * 0.12);
|
| 286 |
+
const body = new THREE.Mesh(new THREE.BoxGeometry(w, tierH, d), tr % 2 ? STONE : STONE_DARK);
|
| 287 |
+
body.position.y = y + tierH / 2;
|
| 288 |
+
body.rotation.y = tr === 0 ? rng() * 0.4 : 0;
|
| 289 |
+
g.add(body);
|
| 290 |
+
// dense lit windows on all four faces, gridded by tier
|
| 291 |
+
const cols = 3, rows = 3;
|
| 292 |
+
for (const [nx, nz, ry, half] of [[0, 1, 0, d], [0, -1, Math.PI, d], [1, 0, Math.PI / 2, w], [-1, 0, -Math.PI / 2, w]]) {
|
| 293 |
+
for (let c = 0; c < cols; c++) {
|
| 294 |
+
for (let rr = 0; rr < rows; rr++) {
|
| 295 |
+
if (rng() < 0.18) continue; // a few dark windows
|
| 296 |
+
const u = (c + 0.5) / cols - 0.5;
|
| 297 |
+
const wx = nx !== 0 ? nx * (w / 2 + 0.0007) : u * w * 0.82;
|
| 298 |
+
const wz = nz !== 0 ? nz * (d / 2 + 0.0007) : u * d * 0.82;
|
| 299 |
+
rects.push({
|
| 300 |
+
x: wx, y: y + (rr + 0.5) / rows * tierH, z: wz, ry,
|
| 301 |
+
w: 0.0078, h: 0.011, phase: rng() * 6.28, speed: 2 + rng() * 5,
|
| 302 |
+
});
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
y += tierH;
|
| 307 |
+
w *= 0.74 + rng() * 0.08;
|
| 308 |
+
}
|
| 309 |
+
// crown + slow-pulsing rooftop light (the "big building" signature)
|
| 310 |
+
const capMat = glow(color, 1.6);
|
| 311 |
+
const cap = new THREE.Mesh(new THREE.CylinderGeometry(w * 0.32, w * 0.42, 0.012, 6), STONE_DARK);
|
| 312 |
+
cap.position.y = y + 0.006;
|
| 313 |
+
g.add(cap);
|
| 314 |
+
const beaconMat = glow(color, 2.0);
|
| 315 |
+
const beacon = new THREE.Mesh(new THREE.OctahedronGeometry(0.011, 0), beaconMat);
|
| 316 |
+
beacon.position.y = y + 0.026;
|
| 317 |
+
g.add(beacon);
|
| 318 |
+
const light = new THREE.PointLight(color.getHex(), 0.6, 1.0, 1.9);
|
| 319 |
+
light.position.y = y + 0.026;
|
| 320 |
+
g.add(light);
|
| 321 |
+
const wins = litWindowMesh(rng, color, rects, 1.85);
|
| 322 |
+
g.add(wins.mesh);
|
| 323 |
+
g.add(groundHalo(color, 0.07, 0.16));
|
| 324 |
+
const phase = rng() * 6.28;
|
| 325 |
+
return {
|
| 326 |
+
group: g,
|
| 327 |
+
accents: [capMat, beaconMat, wins.mat],
|
| 328 |
+
ownGeo: [wins._ownGeo],
|
| 329 |
+
update: (dt, t) => {
|
| 330 |
+
const pulse = 1.5 + Math.sin(t * 1.1 + phase) * 0.5;
|
| 331 |
+
beaconMat.color.copy(color).multiplyScalar(pulse);
|
| 332 |
+
light.intensity = 0.4 + Math.sin(t * 1.1 + phase) * 0.22;
|
| 333 |
+
// gentle flicker of the window block as a whole (cheap: tint the shared mat)
|
| 334 |
+
wins.mat.color.copy(color).multiplyScalar(1.55 + Math.sin(t * 2.3 + phase) * 0.22);
|
| 335 |
+
},
|
| 336 |
+
};
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
function buildWarehouse(rng, color, ctx) {
|
| 340 |
+
const g = new THREE.Group();
|
| 341 |
+
const len = 0.12 + rng() * 0.05;
|
| 342 |
+
const wide = 0.05 + rng() * 0.014;
|
| 343 |
+
const wallH = 0.03 + rng() * 0.008;
|
| 344 |
+
const yaw = rng() * Math.PI;
|
| 345 |
+
const hall = new THREE.Group();
|
| 346 |
+
hall.rotation.y = yaw;
|
| 347 |
+
// long low body
|
| 348 |
+
const body = new THREE.Mesh(new THREE.BoxGeometry(len, wallH, wide), WOOD);
|
| 349 |
+
body.position.y = wallH / 2;
|
| 350 |
+
hall.add(body);
|
| 351 |
+
// gabled roof — a long triangular prism via a scaled, rotated box pair
|
| 352 |
+
const roofH = 0.022 + rng() * 0.006;
|
| 353 |
+
for (const s of [1, -1]) {
|
| 354 |
+
const slope = new THREE.Mesh(new THREE.BoxGeometry(len, 0.003, wide * 0.62), ROOF);
|
| 355 |
+
slope.position.set(0, wallH + roofH / 2, s * wide * 0.25);
|
| 356 |
+
slope.rotation.x = s * Math.atan2(roofH, wide * 0.5);
|
| 357 |
+
hall.add(slope);
|
| 358 |
+
}
|
| 359 |
+
const ridge = new THREE.Mesh(new THREE.BoxGeometry(len, 0.004, 0.004), STONE_DARK);
|
| 360 |
+
ridge.position.y = wallH + roofH;
|
| 361 |
+
hall.add(ridge);
|
| 362 |
+
// one big glowing door on a gable end
|
| 363 |
+
const doorMat = glow(color, 1.7);
|
| 364 |
+
const door = new THREE.Mesh(new THREE.PlaneGeometry(wide * 0.5, wallH * 0.78), doorMat);
|
| 365 |
+
door.position.set(len / 2 + 0.0008, wallH * 0.39, 0);
|
| 366 |
+
door.rotation.y = Math.PI / 2;
|
| 367 |
+
hall.add(door);
|
| 368 |
+
const doorLight = new THREE.PointLight(color.getHex(), 0.5, 0.5, 2.0);
|
| 369 |
+
doorLight.position.set(len / 2 + 0.02, wallH * 0.4, 0);
|
| 370 |
+
hall.add(doorLight);
|
| 371 |
+
// sparse small windows down the long sides
|
| 372 |
+
const rects = [];
|
| 373 |
+
const n = 3 + Math.floor(rng() * 3);
|
| 374 |
+
for (let i = 0; i < n; i++) {
|
| 375 |
+
const side = i % 2 ? 1 : -1;
|
| 376 |
+
const u = ((i + 0.5) / n - 0.5);
|
| 377 |
+
rects.push({
|
| 378 |
+
x: u * len * 0.82, y: wallH * 0.6, z: side * (wide / 2 + 0.0007),
|
| 379 |
+
ry: side > 0 ? 0 : Math.PI, w: 0.009, h: 0.009, phase: rng() * 6.28, speed: 2 + rng() * 3,
|
| 380 |
+
});
|
| 381 |
+
}
|
| 382 |
+
const wins = litWindowMesh(rng, color, rects, 1.5);
|
| 383 |
+
hall.add(wins.mesh);
|
| 384 |
+
g.add(hall);
|
| 385 |
+
g.add(groundHalo(color, 0.075, 0.14));
|
| 386 |
+
const phase = rng() * 6.28;
|
| 387 |
+
return {
|
| 388 |
+
group: g,
|
| 389 |
+
accents: [doorMat, wins.mat],
|
| 390 |
+
ownGeo: [wins._ownGeo],
|
| 391 |
+
update: (dt, t) => {
|
| 392 |
+
doorMat.color.copy(color).multiplyScalar(1.45 + Math.sin(t * 1.6 + phase) * 0.28);
|
| 393 |
+
doorLight.intensity = 0.4 + Math.sin(t * 1.6 + phase) * 0.16;
|
| 394 |
+
wins.mat.color.copy(color).multiplyScalar(1.3 + Math.sin(t * 3.4 + phase) * 0.2);
|
| 395 |
+
},
|
| 396 |
+
};
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
function buildCafe(rng, color, ctx) {
|
| 400 |
+
const g = new THREE.Group();
|
| 401 |
+
const w = 0.04 + rng() * 0.01, d = 0.034 + rng() * 0.01, h = 0.026 + rng() * 0.006;
|
| 402 |
+
const yaw = rng() * Math.PI * 2;
|
| 403 |
+
const box = new THREE.Group();
|
| 404 |
+
box.rotation.y = yaw;
|
| 405 |
+
const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), WOOD);
|
| 406 |
+
body.position.y = h / 2;
|
| 407 |
+
box.add(body);
|
| 408 |
+
const roof = new THREE.Mesh(new THREE.BoxGeometry(w * 1.05, 0.004, d * 1.05), ROOF);
|
| 409 |
+
roof.position.y = h + 0.002;
|
| 410 |
+
box.add(roof);
|
| 411 |
+
// warm front window + door, hue-tinted glow
|
| 412 |
+
const warm = color.clone().lerp(new THREE.Color("#ffcf8a"), 0.45);
|
| 413 |
+
const glassMat = glow(warm, 1.7);
|
| 414 |
+
const glass = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.66, h * 0.5), glassMat);
|
| 415 |
+
glass.position.set(0, h * 0.5, d / 2 + 0.0008);
|
| 416 |
+
box.add(glass);
|
| 417 |
+
// striped awning plane jutting over the front
|
| 418 |
+
const awningMat = new THREE.MeshStandardMaterial({ color: warm.clone().multiplyScalar(0.6), roughness: 0.8, side: THREE.DoubleSide });
|
| 419 |
+
const awning = new THREE.Mesh(new THREE.PlaneGeometry(w * 1.04, d * 0.5), awningMat);
|
| 420 |
+
awning.position.set(0, h * 0.86, d / 2 + d * 0.22);
|
| 421 |
+
awning.rotation.x = -Math.PI / 2 + 0.5;
|
| 422 |
+
box.add(awning);
|
| 423 |
+
// spill of warm light on the ground in front of the door
|
| 424 |
+
const spill = new THREE.Mesh(new THREE.CircleGeometry(0.05, 20), beamMat(warm, 0.4));
|
| 425 |
+
spill.rotation.x = -Math.PI / 2;
|
| 426 |
+
spill.position.set(0, 0.004, d * 0.55);
|
| 427 |
+
spill.scale.set(1, 1.5, 1); // stretch the pool of light outward from the door
|
| 428 |
+
box.add(spill);
|
| 429 |
+
const spillLight = new THREE.PointLight(warm.getHex(), 0.55, 0.45, 1.9);
|
| 430 |
+
spillLight.position.set(0, 0.02, d * 0.5);
|
| 431 |
+
box.add(spillLight);
|
| 432 |
+
// 2-3 tiny table dots outside under the awning
|
| 433 |
+
const tableMat = glow(warm, 1.3);
|
| 434 |
+
const tables = [];
|
| 435 |
+
const nt = 2 + Math.floor(rng() * 2);
|
| 436 |
+
for (let i = 0; i < nt; i++) {
|
| 437 |
+
const tx = (rng() - 0.5) * w * 0.9;
|
| 438 |
+
const tz = d * 0.55 + rng() * 0.018;
|
| 439 |
+
const table = new THREE.Mesh(new THREE.CylinderGeometry(0.0035, 0.0035, 0.008, 6), tableMat);
|
| 440 |
+
table.position.set(tx, 0.004, tz);
|
| 441 |
+
box.add(table);
|
| 442 |
+
tables.push(table);
|
| 443 |
+
}
|
| 444 |
+
g.add(box);
|
| 445 |
+
const phase = rng() * 6.28;
|
| 446 |
+
return {
|
| 447 |
+
group: g,
|
| 448 |
+
accents: [glassMat],
|
| 449 |
+
update: (dt, t) => {
|
| 450 |
+
const flick = 1.4 + Math.sin(t * 2.6 + phase) * 0.18 + Math.sin(t * 7.3 + phase) * 0.06;
|
| 451 |
+
glassMat.color.copy(warm).multiplyScalar(flick);
|
| 452 |
+
spillLight.intensity = 0.46 + Math.sin(t * 2.6 + phase) * 0.12;
|
| 453 |
+
spill.material.opacity = 0.32 + Math.sin(t * 2.6 + phase) * 0.08;
|
| 454 |
+
},
|
| 455 |
+
};
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
const BUILDERS = {
|
| 459 |
lighthouse: buildLighthouse,
|
| 460 |
monolith: buildMonolith,
|
|
|
|
| 462 |
village: buildVillage,
|
| 463 |
beacon: buildBeacon,
|
| 464 |
arch: buildArch,
|
| 465 |
+
tower: buildTower,
|
| 466 |
+
warehouse: buildWarehouse,
|
| 467 |
+
cafe: buildCafe,
|
| 468 |
};
|
| 469 |
|
| 470 |
// ---------------------------------------------------------------- manager
|
|
|
|
| 485 |
this.built = builder(rng, color, { terrain, center: this.center });
|
| 486 |
this.yaw = rng() * Math.PI * 2;
|
| 487 |
this.group = this.built.group;
|
| 488 |
+
this.color = color;
|
| 489 |
scene.add(this.group);
|
| 490 |
this.growing = animate;
|
| 491 |
+
// emissive "spawn pop": flash the structure's glow accents on placement so a
|
| 492 |
+
// new building is unmistakable the instant it lands (verify-fleet visibility).
|
| 493 |
+
this.flash = animate && this.built.accents ? 1 : 0;
|
| 494 |
this.seat();
|
| 495 |
}
|
| 496 |
|
|
|
|
| 518 |
if (t - this.t0 > 1.0) { this.growing = false; this.group.scale.setScalar(this.scaleArg); }
|
| 519 |
}
|
| 520 |
this.built.update?.(dt, t);
|
| 521 |
+
// spawn pop: a fast emissive bloom on the glow accents, eased out over ~1.1s
|
| 522 |
+
if (this.flash > 0 && this.built.accents) {
|
| 523 |
+
this.flash = Math.max(0, this.flash - dt / 1.1);
|
| 524 |
+
const boost = 1 + this.flash * this.flash * 2.4;
|
| 525 |
+
for (const mat of this.built.accents) mat.color.multiplyScalar(boost);
|
| 526 |
+
}
|
| 527 |
}
|
| 528 |
|
| 529 |
dispose() {
|
web/planet/terrain.js
CHANGED
|
@@ -3,10 +3,15 @@
|
|
| 3 |
// New features lerp in over ~2s so the land visibly rises out of the dark.
|
| 4 |
import * as THREE from "three";
|
| 5 |
import { fbm3 } from "./rng.js";
|
| 6 |
-
import { PLANET_R, DEG, clamp, easeInOutCubic, hsl, angleBetween } from "./util.js";
|
| 7 |
|
| 8 |
const RISE_SECONDS = 2.2;
|
| 9 |
const GLOBAL_RELIEF = 0.012; // faint seeded micro-relief so genesis isn't a billiard ball
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
// ---------------------------------------------------------------- icosphere
|
| 12 |
function makeIcosphere(detail) {
|
|
@@ -47,6 +52,42 @@ function makeIcosphere(detail) {
|
|
| 47 |
return { dirs, index, count: verts.length };
|
| 48 |
}
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
// ---------------------------------------------------------------- terrain
|
| 51 |
export class Terrain {
|
| 52 |
/**
|
|
@@ -61,6 +102,8 @@ export class Terrain {
|
|
| 61 |
this.features = []; // {f, sign, center, radiusRad, h, rough, k}
|
| 62 |
this.anims = []; // {rec, idx, val, t}
|
| 63 |
this.tints = []; // flora tint sources {center, radiusRad, color, w}
|
|
|
|
|
|
|
| 64 |
this._colorsDirty = true;
|
| 65 |
|
| 66 |
// genesis micro-relief
|
|
@@ -153,6 +196,7 @@ export class Terrain {
|
|
| 153 |
const sparse = { rec, idx: Uint32Array.from(idx), val: Float32Array.from(val), t: 0 };
|
| 154 |
if (animate) {
|
| 155 |
this.anims.push(sparse);
|
|
|
|
| 156 |
} else {
|
| 157 |
this._fold(sparse);
|
| 158 |
}
|
|
@@ -173,11 +217,74 @@ export class Terrain {
|
|
| 173 |
this._colorsDirty = true;
|
| 174 |
}
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
get animating() { return this.anims.length > 0; }
|
| 177 |
|
| 178 |
/** @returns list of {center, radiusRad} that changed this frame (for reseating). */
|
| 179 |
update(dt) {
|
| 180 |
const changed = [];
|
|
|
|
| 181 |
if (this.anims.length) {
|
| 182 |
const done = [];
|
| 183 |
for (const a of this.anims) {
|
|
@@ -199,6 +306,26 @@ export class Terrain {
|
|
| 199 |
return changed;
|
| 200 |
}
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
_writeAll() {
|
| 203 |
const pos = this.geometry.attributes.position.array;
|
| 204 |
const d = this.dirs;
|
|
@@ -276,6 +403,12 @@ export class Terrain {
|
|
| 276 |
}
|
| 277 |
|
| 278 |
dispose() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
this.mesh.removeFromParent();
|
| 280 |
this.geometry.dispose();
|
| 281 |
this.material.dispose();
|
|
|
|
| 3 |
// New features lerp in over ~2s so the land visibly rises out of the dark.
|
| 4 |
import * as THREE from "three";
|
| 5 |
import { fbm3 } from "./rng.js";
|
| 6 |
+
import { PLANET_R, DEG, clamp, easeInOutCubic, easeOutCubic, hsl, angleBetween } from "./util.js";
|
| 7 |
|
| 8 |
const RISE_SECONDS = 2.2;
|
| 9 |
const GLOBAL_RELIEF = 0.012; // faint seeded micro-relief so genesis isn't a billiard ball
|
| 10 |
+
// Delta "landing bloom": a transient emissive cap that floods the new ground in
|
| 11 |
+
// biome light so wishes read at GIF scale (verify-fleet: deltas were invisible).
|
| 12 |
+
const BLOOM_MIN_RAD = 0.085; // never smaller than ~8% of the planet — always obvious
|
| 13 |
+
const BLOOM_SECONDS = 2.6; // ramp through the rise, then settle to a faint stain glow
|
| 14 |
+
const RAISE_HUE = 38, LOWER_HUE = 205; // warm uplift / cool basin — biome-coloured
|
| 15 |
|
| 16 |
// ---------------------------------------------------------------- icosphere
|
| 17 |
function makeIcosphere(detail) {
|
|
|
|
| 52 |
return { dirs, index, count: verts.length };
|
| 53 |
}
|
| 54 |
|
| 55 |
+
// ---------------------------------------------------------------- delta bloom
|
| 56 |
+
// Additive emissive spherical-cap that hugs the surface over the delta site.
|
| 57 |
+
// uReveal grows the lit disc; uGlow ramps its brightness during the landing.
|
| 58 |
+
function deltaBloomMat(color) {
|
| 59 |
+
return new THREE.ShaderMaterial({
|
| 60 |
+
transparent: true,
|
| 61 |
+
depthWrite: false,
|
| 62 |
+
blending: THREE.AdditiveBlending,
|
| 63 |
+
side: THREE.DoubleSide,
|
| 64 |
+
uniforms: {
|
| 65 |
+
uTime: { value: 0 },
|
| 66 |
+
uReveal: { value: 0 },
|
| 67 |
+
uGlow: { value: 0 },
|
| 68 |
+
uColor: { value: color },
|
| 69 |
+
},
|
| 70 |
+
vertexShader: /* glsl */ `
|
| 71 |
+
varying vec2 vUv;
|
| 72 |
+
void main(){ vUv = uv; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
|
| 73 |
+
fragmentShader: /* glsl */ `
|
| 74 |
+
varying vec2 vUv;
|
| 75 |
+
uniform float uTime, uReveal, uGlow;
|
| 76 |
+
uniform vec3 uColor;
|
| 77 |
+
void main(){
|
| 78 |
+
float r = length(vUv - 0.5) * 2.0;
|
| 79 |
+
if (r > uReveal) discard;
|
| 80 |
+
// bright shockwave ring at the leading edge, soft core inside
|
| 81 |
+
float ring = smoothstep(uReveal, uReveal - 0.16, r);
|
| 82 |
+
float core = smoothstep(1.0, 0.0, r);
|
| 83 |
+
float pulse = 0.78 + 0.22 * sin(uTime * 3.2 - r * 7.0);
|
| 84 |
+
float a = (0.30 * core + 0.95 * (1.0 - ring)) * uGlow * pulse;
|
| 85 |
+
vec3 c = uColor * (1.1 + 1.7 * (1.0 - ring));
|
| 86 |
+
gl_FragColor = vec4(c, a);
|
| 87 |
+
}`,
|
| 88 |
+
});
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
// ---------------------------------------------------------------- terrain
|
| 92 |
export class Terrain {
|
| 93 |
/**
|
|
|
|
| 102 |
this.features = []; // {f, sign, center, radiusRad, h, rough, k}
|
| 103 |
this.anims = []; // {rec, idx, val, t}
|
| 104 |
this.tints = []; // flora tint sources {center, radiusRad, color, w}
|
| 105 |
+
this.blooms = []; // transient landing-bloom caps {mesh, mat, t, dur}
|
| 106 |
+
this._scene = scene;
|
| 107 |
this._colorsDirty = true;
|
| 108 |
|
| 109 |
// genesis micro-relief
|
|
|
|
| 196 |
const sparse = { rec, idx: Uint32Array.from(idx), val: Float32Array.from(val), t: 0 };
|
| 197 |
if (animate) {
|
| 198 |
this.anims.push(sparse);
|
| 199 |
+
this._spawnDeltaBloom(rec);
|
| 200 |
} else {
|
| 201 |
this._fold(sparse);
|
| 202 |
}
|
|
|
|
| 217 |
this._colorsDirty = true;
|
| 218 |
}
|
| 219 |
|
| 220 |
+
/**
|
| 221 |
+
* Spawn a transient emissive cap over a landing terrain delta so the wish is
|
| 222 |
+
* unmistakable at GIF scale: a biome-coloured shockwave that floods the new
|
| 223 |
+
* ground, ramps bright through the rise (~2.6s), then fades to nothing while
|
| 224 |
+
* a faint permanent biome stain stays folded into the vertex colours.
|
| 225 |
+
*/
|
| 226 |
+
_spawnDeltaBloom(rec) {
|
| 227 |
+
const radiusRad = Math.max(rec.radiusRad * 1.25, BLOOM_MIN_RAD);
|
| 228 |
+
const hue = rec.sign > 0 ? RAISE_HUE : LOWER_HUE;
|
| 229 |
+
const mat = deltaBloomMat(hsl(hue, 0.8, 0.6));
|
| 230 |
+
const mesh = new THREE.Mesh(this._capGeo(rec.center, radiusRad, 0.018), mat);
|
| 231 |
+
mesh.frustumCulled = false;
|
| 232 |
+
this._scene.add(mesh);
|
| 233 |
+
this.blooms.push({ mesh, mat, t: 0, dur: BLOOM_SECONDS, center: rec.center, radiusRad });
|
| 234 |
+
// a soft, lasting biome stain so the raised land never reads as flat obsidian
|
| 235 |
+
this.tints.push({ center: rec.center, radiusRad: rec.radiusRad, color: hsl(hue, 0.42, 0.21), w: 0.55 });
|
| 236 |
+
this._colorsDirty = true;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
/** Spherical-cap disc grid hugging the planet, lifted `lift` above the surface. */
|
| 240 |
+
_capGeo(center, radiusRad, lift) {
|
| 241 |
+
const rings = 16, segs = 40;
|
| 242 |
+
const positions = [], uvs = [], index = [];
|
| 243 |
+
const dir = new THREE.Vector3(), t1 = new THREE.Vector3(), t2 = new THREE.Vector3();
|
| 244 |
+
t1.set(0, 1, 0);
|
| 245 |
+
if (Math.abs(center.y) > 0.93) t1.set(1, 0, 0);
|
| 246 |
+
t2.crossVectors(center, t1).normalize();
|
| 247 |
+
t1.crossVectors(t2, center).normalize();
|
| 248 |
+
for (let r = 0; r <= rings; r++) {
|
| 249 |
+
const ang = (r / rings) * radiusRad;
|
| 250 |
+
const n = r === 0 ? 1 : segs;
|
| 251 |
+
for (let s = 0; s < n; s++) {
|
| 252 |
+
const rot = (s / n) * Math.PI * 2;
|
| 253 |
+
dir.copy(center).multiplyScalar(Math.cos(ang))
|
| 254 |
+
.addScaledVector(t2, Math.sin(ang) * Math.cos(rot))
|
| 255 |
+
.addScaledVector(t1, Math.sin(ang) * Math.sin(rot))
|
| 256 |
+
.normalize();
|
| 257 |
+
const h = this.heightAt(dir);
|
| 258 |
+
const level = PLANET_R + h + lift;
|
| 259 |
+
positions.push(dir.x * level, dir.y * level, dir.z * level);
|
| 260 |
+
uvs.push(0.5 + (r / rings) * 0.5 * Math.cos(rot), 0.5 + (r / rings) * 0.5 * Math.sin(rot));
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
const ringStart = (r) => (r === 0 ? 0 : 1 + (r - 1) * segs);
|
| 264 |
+
for (let r = 0; r < rings; r++) {
|
| 265 |
+
for (let s = 0; s < segs; s++) {
|
| 266 |
+
const sn = (s + 1) % segs;
|
| 267 |
+
const a = r === 0 ? 0 : ringStart(r) + s;
|
| 268 |
+
const a2 = r === 0 ? 0 : ringStart(r) + sn;
|
| 269 |
+
const b = ringStart(r + 1) + s;
|
| 270 |
+
const b2 = ringStart(r + 1) + sn;
|
| 271 |
+
if (r === 0) index.push(0, b, b2);
|
| 272 |
+
else index.push(a, b, b2, a, b2, a2);
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
const geo = new THREE.BufferGeometry();
|
| 276 |
+
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
| 277 |
+
geo.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
|
| 278 |
+
geo.setIndex(index);
|
| 279 |
+
return geo;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
get animating() { return this.anims.length > 0; }
|
| 283 |
|
| 284 |
/** @returns list of {center, radiusRad} that changed this frame (for reseating). */
|
| 285 |
update(dt) {
|
| 286 |
const changed = [];
|
| 287 |
+
if (this.blooms.length) this._updateBlooms(dt);
|
| 288 |
if (this.anims.length) {
|
| 289 |
const done = [];
|
| 290 |
for (const a of this.anims) {
|
|
|
|
| 306 |
return changed;
|
| 307 |
}
|
| 308 |
|
| 309 |
+
_updateBlooms(dt) {
|
| 310 |
+
this._bloomClock = (this._bloomClock ?? 0) + dt;
|
| 311 |
+
const done = [];
|
| 312 |
+
for (const b of this.blooms) {
|
| 313 |
+
b.t += dt;
|
| 314 |
+
const k = clamp(b.t / b.dur, 0, 1);
|
| 315 |
+
// reveal sweeps out fast, brightness ramps with the rise then fades out
|
| 316 |
+
b.mat.uniforms.uReveal.value = easeOutCubic(b.t / (b.dur * 0.42));
|
| 317 |
+
b.mat.uniforms.uGlow.value = Math.sin(Math.min(k, 1) * Math.PI) * 1.0;
|
| 318 |
+
b.mat.uniforms.uTime.value = this._bloomClock;
|
| 319 |
+
if (b.t >= b.dur) done.push(b);
|
| 320 |
+
}
|
| 321 |
+
for (const b of done) {
|
| 322 |
+
this.blooms.splice(this.blooms.indexOf(b), 1);
|
| 323 |
+
b.mesh.removeFromParent();
|
| 324 |
+
b.mesh.geometry.dispose();
|
| 325 |
+
b.mat.dispose();
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
_writeAll() {
|
| 330 |
const pos = this.geometry.attributes.position.array;
|
| 331 |
const d = this.dirs;
|
|
|
|
| 403 |
}
|
| 404 |
|
| 405 |
dispose() {
|
| 406 |
+
for (const b of this.blooms) {
|
| 407 |
+
b.mesh.removeFromParent();
|
| 408 |
+
b.mesh.geometry.dispose();
|
| 409 |
+
b.mat.dispose();
|
| 410 |
+
}
|
| 411 |
+
this.blooms = [];
|
| 412 |
this.mesh.removeFromParent();
|
| 413 |
this.geometry.dispose();
|
| 414 |
this.material.dispose();
|
web/planet/water.js
CHANGED
|
@@ -234,6 +234,31 @@ export class Water {
|
|
| 234 |
for (const w of this.items) w.update(dt, t);
|
| 235 |
}
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
reseat(center, radiusRad) {
|
| 238 |
for (const w of this.items) {
|
| 239 |
if (angleBetween(w.center, center) < w.reachRad + radiusRad) w.reseat();
|
|
|
|
| 234 |
for (const w of this.items) w.update(dt, t);
|
| 235 |
}
|
| 236 |
|
| 237 |
+
/**
|
| 238 |
+
* Read-only: unit-dir waypoints of the nearest stream whose body comes within
|
| 239 |
+
* `maxRad` of `center`, else null. Used by life.js carts to glide along an
|
| 240 |
+
* existing river before falling back to a seeded loop. Deterministic.
|
| 241 |
+
*/
|
| 242 |
+
streamPathNear(center, maxRad) {
|
| 243 |
+
let best = null, bestAng = Infinity;
|
| 244 |
+
for (const w of this.items) {
|
| 245 |
+
if (!(w instanceof Stream)) continue;
|
| 246 |
+
const ang = angleBetween(w.center, center);
|
| 247 |
+
if (ang - w.reachRad < maxRad && ang < bestAng) { bestAng = ang; best = w; }
|
| 248 |
+
}
|
| 249 |
+
if (!best) return null;
|
| 250 |
+
// densify the great-circle path between waypoints into a smooth dir list
|
| 251 |
+
const dirs = [];
|
| 252 |
+
for (let i = 0; i < best.waypoints.length - 1; i++) {
|
| 253 |
+
const a = best.waypoints[i], b = best.waypoints[i + 1];
|
| 254 |
+
const steps = Math.max(4, Math.ceil(angleBetween(a, b) / (3 * DEG)));
|
| 255 |
+
for (let s = i === 0 ? 0 : 1; s <= steps; s++) {
|
| 256 |
+
dirs.push(slerpDirs(a, b, s / steps, new THREE.Vector3()).clone());
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
return dirs.length >= 2 ? dirs : null;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
reseat(center, radiusRad) {
|
| 263 |
for (const w of this.items) {
|
| 264 |
if (angleBetween(w.center, center) < w.reachRad + radiusRad) w.reseat();
|
web/planet/world.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Terrain } from "./terrain.js";
|
|
| 6 |
import { Flora } from "./flora.js";
|
| 7 |
import { Structures } from "./structures.js";
|
| 8 |
import { Water } from "./water.js";
|
|
|
|
| 9 |
import { Weather } from "./weather.js";
|
| 10 |
import { Sky } from "./sky.js";
|
| 11 |
import { Inscriptions } from "./inscriptions.js";
|
|
@@ -27,6 +28,7 @@ export class World {
|
|
| 27 |
this.flora = new Flora(this.scene, this.terrain, this.quality);
|
| 28 |
this.structures = new Structures(this.scene, this.terrain);
|
| 29 |
this.water = new Water(this.scene, this.terrain);
|
|
|
|
| 30 |
this.inscriptions = new Inscriptions(this.scene, this.terrain);
|
| 31 |
if (this._px) this.setPxScale(this._px);
|
| 32 |
}
|
|
@@ -60,6 +62,9 @@ export class World {
|
|
| 60 |
case "place_water":
|
| 61 |
target = this.water.addFeature(f, { animate, t });
|
| 62 |
break;
|
|
|
|
|
|
|
|
|
|
| 63 |
case "set_weather":
|
| 64 |
this.weather.set(f.args.kind, clamp(f.args.intensity ?? 0.5, 0, 1), { animate });
|
| 65 |
wide = true;
|
|
@@ -85,6 +90,7 @@ export class World {
|
|
| 85 |
this.flora.dispose();
|
| 86 |
this.structures.dispose();
|
| 87 |
this.water.dispose();
|
|
|
|
| 88 |
this.inscriptions.dispose();
|
| 89 |
this.terrain.dispose();
|
| 90 |
this.features = [];
|
|
@@ -104,11 +110,13 @@ export class World {
|
|
| 104 |
this.flora.reseat(c.center, c.radiusRad);
|
| 105 |
this.structures.reseat(c.center, c.radiusRad);
|
| 106 |
this.water.reseat(c.center, c.radiusRad);
|
|
|
|
| 107 |
this.inscriptions.reseat(c.center, c.radiusRad);
|
| 108 |
}
|
| 109 |
this.flora.update(dt, t);
|
| 110 |
this.structures.update(dt, t);
|
| 111 |
this.water.update(dt, t);
|
|
|
|
| 112 |
this.weather.update(dt, t);
|
| 113 |
this.sky.update(dt, t);
|
| 114 |
this.inscriptions.update(dt, t);
|
|
|
|
| 6 |
import { Flora } from "./flora.js";
|
| 7 |
import { Structures } from "./structures.js";
|
| 8 |
import { Water } from "./water.js";
|
| 9 |
+
import { Life } from "./life.js";
|
| 10 |
import { Weather } from "./weather.js";
|
| 11 |
import { Sky } from "./sky.js";
|
| 12 |
import { Inscriptions } from "./inscriptions.js";
|
|
|
|
| 28 |
this.flora = new Flora(this.scene, this.terrain, this.quality);
|
| 29 |
this.structures = new Structures(this.scene, this.terrain);
|
| 30 |
this.water = new Water(this.scene, this.terrain);
|
| 31 |
+
this.life = new Life(this.scene, this.terrain, this.water);
|
| 32 |
this.inscriptions = new Inscriptions(this.scene, this.terrain);
|
| 33 |
if (this._px) this.setPxScale(this._px);
|
| 34 |
}
|
|
|
|
| 62 |
case "place_water":
|
| 63 |
target = this.water.addFeature(f, { animate, t });
|
| 64 |
break;
|
| 65 |
+
case "spawn_life":
|
| 66 |
+
target = this.life.addFeature(f, { animate, t });
|
| 67 |
+
break;
|
| 68 |
case "set_weather":
|
| 69 |
this.weather.set(f.args.kind, clamp(f.args.intensity ?? 0.5, 0, 1), { animate });
|
| 70 |
wide = true;
|
|
|
|
| 90 |
this.flora.dispose();
|
| 91 |
this.structures.dispose();
|
| 92 |
this.water.dispose();
|
| 93 |
+
this.life.dispose();
|
| 94 |
this.inscriptions.dispose();
|
| 95 |
this.terrain.dispose();
|
| 96 |
this.features = [];
|
|
|
|
| 110 |
this.flora.reseat(c.center, c.radiusRad);
|
| 111 |
this.structures.reseat(c.center, c.radiusRad);
|
| 112 |
this.water.reseat(c.center, c.radiusRad);
|
| 113 |
+
this.life.reseat(c.center, c.radiusRad);
|
| 114 |
this.inscriptions.reseat(c.center, c.radiusRad);
|
| 115 |
}
|
| 116 |
this.flora.update(dt, t);
|
| 117 |
this.structures.update(dt, t);
|
| 118 |
this.water.update(dt, t);
|
| 119 |
+
this.life.update(dt, t);
|
| 120 |
this.weather.update(dt, t);
|
| 121 |
this.sky.update(dt, t);
|
| 122 |
this.inscriptions.update(dt, t);
|
web/style.css
CHANGED
|
@@ -110,10 +110,16 @@ html, body {
|
|
| 110 |
/* ---------- the god speaks ---------- */
|
| 111 |
.voice {
|
| 112 |
position: fixed; left: 42px; bottom: 42px; z-index: 10;
|
| 113 |
-
width: min(
|
| 114 |
border-left: 1px solid var(--hairline);
|
| 115 |
-
padding
|
| 116 |
pointer-events: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
}
|
| 118 |
.voice__head {
|
| 119 |
display: flex; align-items: baseline; gap: 10px;
|
|
@@ -138,26 +144,29 @@ html, body {
|
|
| 138 |
}
|
| 139 |
.voice__stream {
|
| 140 |
font-family: var(--mono);
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
overflow: hidden;
|
| 146 |
display: flex;
|
| 147 |
flex-direction: column;
|
| 148 |
justify-content: flex-end;
|
| 149 |
white-space: pre-wrap;
|
| 150 |
word-break: break-word;
|
|
|
|
| 151 |
mask-image: linear-gradient(to bottom, transparent, black 28%);
|
| 152 |
-webkit-mask-image: linear-gradient(to bottom, transparent, black 28%);
|
| 153 |
}
|
| 154 |
.voice__innertext { display: inline-block; white-space: pre-wrap; }
|
| 155 |
.voice__stream .tone-reading { color: var(--ivory); }
|
| 156 |
-
.voice__stream .tone-call { color: var(--gold); }
|
| 157 |
-
.voice__stream .tone-obs { color: var(--violet); opacity: 0.
|
| 158 |
.voice__caret {
|
| 159 |
-
display: inline-block; width:
|
| 160 |
-
background: var(--gold); vertical-align: -
|
| 161 |
animation: caret 1.1s steps(2) infinite;
|
| 162 |
}
|
| 163 |
@keyframes caret { 50% { opacity: 0; } }
|
|
@@ -191,6 +200,31 @@ html, body {
|
|
| 191 |
font-size: 10px; letter-spacing: 0.34em; text-transform: uppercase;
|
| 192 |
color: var(--gold);
|
| 193 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
/* ---------- altar (wish input) ---------- */
|
| 196 |
.altar {
|
|
@@ -289,6 +323,19 @@ html, body {
|
|
| 289 |
color: var(--ivory-faint);
|
| 290 |
}
|
| 291 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
.ledger {
|
| 293 |
position: fixed; top: 168px; bottom: 36px; left: 42px; z-index: 10;
|
| 294 |
width: min(360px, calc(100vw - 64px));
|
|
@@ -353,7 +400,8 @@ html, body {
|
|
| 353 |
@media (max-width: 760px) {
|
| 354 |
.almanac { right: 20px; top: 28px; }
|
| 355 |
.voice { left: 20px; bottom: 130px; width: calc(100vw - 40px); max-width: none; }
|
| 356 |
-
.voice__stream { max-height:
|
|
|
|
| 357 |
.altar { bottom: 18px; }
|
| 358 |
.status { display: none; }
|
| 359 |
.brand { top: 22px; }
|
|
|
|
| 110 |
/* ---------- the god speaks ---------- */
|
| 111 |
.voice {
|
| 112 |
position: fixed; left: 42px; bottom: 42px; z-index: 10;
|
| 113 |
+
width: min(440px, calc(100vw - 84px));
|
| 114 |
border-left: 1px solid var(--hairline);
|
| 115 |
+
padding: 16px 20px 16px 20px;
|
| 116 |
pointer-events: none;
|
| 117 |
+
/* a subtle dark scrim so the god's voice reads over the bright planet rim —
|
| 118 |
+
the ticker is the second-largest text while granting and must not wash out */
|
| 119 |
+
background: linear-gradient(105deg, rgba(4, 5, 9, 0.72), rgba(4, 5, 9, 0.32) 72%, transparent);
|
| 120 |
+
border-radius: 2px;
|
| 121 |
+
-webkit-backdrop-filter: blur(3px);
|
| 122 |
+
backdrop-filter: blur(3px);
|
| 123 |
}
|
| 124 |
.voice__head {
|
| 125 |
display: flex; align-items: baseline; gap: 10px;
|
|
|
|
| 144 |
}
|
| 145 |
.voice__stream {
|
| 146 |
font-family: var(--mono);
|
| 147 |
+
/* second-largest text while granting — readable on the live screenshots */
|
| 148 |
+
font-size: 18px;
|
| 149 |
+
line-height: 1.62;
|
| 150 |
+
letter-spacing: 0.01em;
|
| 151 |
+
color: rgba(236, 229, 216, 0.78);
|
| 152 |
+
max-height: 232px;
|
| 153 |
overflow: hidden;
|
| 154 |
display: flex;
|
| 155 |
flex-direction: column;
|
| 156 |
justify-content: flex-end;
|
| 157 |
white-space: pre-wrap;
|
| 158 |
word-break: break-word;
|
| 159 |
+
text-shadow: 0 1px 10px rgba(2, 3, 6, 0.65);
|
| 160 |
mask-image: linear-gradient(to bottom, transparent, black 28%);
|
| 161 |
-webkit-mask-image: linear-gradient(to bottom, transparent, black 28%);
|
| 162 |
}
|
| 163 |
.voice__innertext { display: inline-block; white-space: pre-wrap; }
|
| 164 |
.voice__stream .tone-reading { color: var(--ivory); }
|
| 165 |
+
.voice__stream .tone-call { color: var(--gold-bright); font-weight: 400; }
|
| 166 |
+
.voice__stream .tone-obs { color: var(--violet); opacity: 0.92; }
|
| 167 |
.voice__caret {
|
| 168 |
+
display: inline-block; width: 8px; height: 17px;
|
| 169 |
+
background: var(--gold); vertical-align: -3px; margin-left: 2px;
|
| 170 |
animation: caret 1.1s steps(2) infinite;
|
| 171 |
}
|
| 172 |
@keyframes caret { 50% { opacity: 0; } }
|
|
|
|
| 200 |
font-size: 10px; letter-spacing: 0.34em; text-transform: uppercase;
|
| 201 |
color: var(--gold);
|
| 202 |
}
|
| 203 |
+
.epitaph__descend[hidden] { display: none; }
|
| 204 |
+
.epitaph__descend {
|
| 205 |
+
display: inline-block;
|
| 206 |
+
margin-top: 20px;
|
| 207 |
+
padding: 8px 22px;
|
| 208 |
+
font-family: var(--serif);
|
| 209 |
+
font-style: italic;
|
| 210 |
+
font-size: 17px;
|
| 211 |
+
letter-spacing: 0.05em;
|
| 212 |
+
color: var(--gold);
|
| 213 |
+
background: rgba(7, 8, 14, 0.5);
|
| 214 |
+
border: 1px solid var(--hairline);
|
| 215 |
+
border-radius: 999px;
|
| 216 |
+
cursor: pointer;
|
| 217 |
+
pointer-events: auto;
|
| 218 |
+
-webkit-backdrop-filter: blur(6px);
|
| 219 |
+
backdrop-filter: blur(6px);
|
| 220 |
+
transition: color 0.25s, border-color 0.25s, box-shadow 0.25s, transform 0.25s;
|
| 221 |
+
}
|
| 222 |
+
.epitaph__descend:hover {
|
| 223 |
+
color: var(--gold-bright);
|
| 224 |
+
border-color: var(--gold);
|
| 225 |
+
box-shadow: 0 0 28px rgba(217, 179, 108, 0.18);
|
| 226 |
+
transform: translateY(-1px);
|
| 227 |
+
}
|
| 228 |
|
| 229 |
/* ---------- altar (wish input) ---------- */
|
| 230 |
.altar {
|
|
|
|
| 323 |
color: var(--ivory-faint);
|
| 324 |
}
|
| 325 |
|
| 326 |
+
/* a left-side dark gradient scrim so the log column reads over the bright
|
| 327 |
+
planet rim + gold trail — sits behind the entries, fades to clear on the
|
| 328 |
+
right so the planet still breathes. */
|
| 329 |
+
.ledger-scrim {
|
| 330 |
+
position: fixed; left: 0; top: 0; bottom: 0; z-index: 9;
|
| 331 |
+
width: min(560px, 56vw);
|
| 332 |
+
pointer-events: none;
|
| 333 |
+
background: linear-gradient(to right,
|
| 334 |
+
rgba(3, 4, 8, 0.82) 0%,
|
| 335 |
+
rgba(3, 4, 8, 0.66) 34%,
|
| 336 |
+
rgba(3, 4, 8, 0.34) 62%,
|
| 337 |
+
transparent 100%);
|
| 338 |
+
}
|
| 339 |
.ledger {
|
| 340 |
position: fixed; top: 168px; bottom: 36px; left: 42px; z-index: 10;
|
| 341 |
width: min(360px, calc(100vw - 64px));
|
|
|
|
| 400 |
@media (max-width: 760px) {
|
| 401 |
.almanac { right: 20px; top: 28px; }
|
| 402 |
.voice { left: 20px; bottom: 130px; width: calc(100vw - 40px); max-width: none; }
|
| 403 |
+
.voice__stream { max-height: 118px; font-size: 15px; line-height: 1.5; }
|
| 404 |
+
.ledger-scrim { width: 100vw; }
|
| 405 |
.altar { bottom: 18px; }
|
| 406 |
.status { display: none; }
|
| 407 |
.brand { top: 22px; }
|