Eureka landing page: PS1 scroll flight from the hall to Epiphany's chamber
Hand-built static page. One scroll timeline drives a camera route through the monument hall, Eureka's organ chamber, Soul's lattice, the specimen gallery, the archive and Epiphany's chamber, with GameCult's aperture behind the far-plane fog. Includes the handoff brief and the copy draft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
4edd20a901
14 changed files with 82616 additions and 0 deletions
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "prototype",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "8765", "--directory", "prototype"],
|
||||
"port": 8765
|
||||
}
|
||||
]
|
||||
}
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.claude/settings.local.json
|
||||
203
BRIEF.md
Normal file
203
BRIEF.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# Eureka landing page: handoff brief
|
||||
|
||||
## Status of this brief (operator correction, 2026-09-25). Read this first.
|
||||
|
||||
This brief was written by the Eureka-running Self agent **without operator
|
||||
guidance**, to capture its own perspective. Its authority is invented. Treat it
|
||||
as one input: useful for its evidence pointers and the campaign defects below,
|
||||
**not binding direction**. `gpt6-copy.md` (drafted by GPT-6 "Sol") came out of a
|
||||
longer operator conversation about the direction of Eureka and GameCult and
|
||||
carries more operator intent, though the copy itself is a draft to workshop.
|
||||
|
||||
## Operator rulings (2026-09-25). These settle the open questions below.
|
||||
|
||||
- **Standalone site, in this folder: `F:\Projects\eureka-site`.** It will be the
|
||||
**public** repo `GameCult/eureka-site`, served at **eureka.gamecult.org**, a
|
||||
**GitHub Pages static site**, in the GameCult brand. (This originally said
|
||||
"built with GameCult's Quartz setup"; the Quartz part was superseded later the
|
||||
same day. See the last ruling below.) The repo, the Quartz scaffold and
|
||||
the Pages deploy are yours to set up. As of 2026-09-25 none of it exists: no
|
||||
local git repo, and `GameCult/eureka-site` does not resolve on GitHub. Per
|
||||
`F:\Projects\CLAUDE.md`, create it under the `GameCult` org. (An earlier draft
|
||||
of this section said `epiphany-site`; that was a mistype.)
|
||||
- **The site is Eureka's, not Epiphany's.** The lineage still matters (*"the
|
||||
lineage is the whole story"*), but **Eureka is Epiphany's sister, not her
|
||||
descendant**: two Personas developing side by side on a shared substrate (the
|
||||
Mind schema), not a branch. Same faculty vocabulary (Self, Imagination, Hands,
|
||||
Soul, Mind Steward); Epiphany keeps findings in typed state, Eureka in
|
||||
committed docs and agent reports (see `SKILL.md`'s opening and
|
||||
`references/epiphany-comparison-2026-09-15.md`). Read Epiphany itself,
|
||||
`F:\Projects\Epiphany` (README, `docs/`), before shaping that section.
|
||||
- Naming GameCult projects (CultLib, StreamPixels, Epiphany) is fine. Still never
|
||||
name the streamer or any private individual.
|
||||
- **Cost framing supersedes "What NOT to claim" below.** The ~60% of a weekly
|
||||
quota spent in one day was legitimate work, not waste; the regret was spending
|
||||
it on a more expensive, more verbose model just as Opus 5.5 released. Do not
|
||||
describe it as waste or as off-critical-path Soul loops. "Not speed" still
|
||||
stands.
|
||||
- **Copy direction.** (a) Soften autonomy-leaning lines while keeping "the
|
||||
operator owns decisions". (b) Lead with specific defects and why the green
|
||||
suite missed them, including the StreamPixels/CultLib campaign defects listed
|
||||
below. (c) Cut length. The staccato one-line style stays but is used sparingly
|
||||
and animated: lines slide in and away one by one, then the page returns to
|
||||
paragraph copy. Style reference the operator loves: https://acko.net/.
|
||||
- **2026-09-25, later: the page is a scroll-driven WebGL flight in full PS1
|
||||
style, hand-built static, NOT Quartz.** This supersedes the Quartz stack named
|
||||
in the first ruling above (repo, domain and GitHub Pages still stand). The
|
||||
prototype lives in `prototype/` and is served at
|
||||
https://yggdrasil.gamecult.org/eureka/ as a stopgap until eureka.gamecult.org
|
||||
exists on Pages; deploy per
|
||||
`F:\Projects\gamecult-ops\runbooks\eureka-prototype-yggdrasil.md`. Approved
|
||||
route, in order: hall/monument; hatch + shaft; Eureka organ chamber; lattice
|
||||
(Soul's probes, mutation testing); specimen gallery (one real defect per
|
||||
case); archive (rules/evidence); Epiphany's chamber (all eight organs in
|
||||
scaffolding: the sister reveal); return to the hall with the monument split
|
||||
open, plus install. Organs are PS1 demakes of their Aquarium renditions
|
||||
(`F:\Projects\Aquarium\docs\epiphany-agent-sdf-visual-language.md`) using the
|
||||
Aquarium organ identity palettes as-is; the GameCult brand does not define
|
||||
those colours, so they are a declared exception. The page names the memory
|
||||
organ **Life**, not "Mind Steward". Renaming it in the Eureka skill or global
|
||||
doctrine is a pending, operator-owned direction, not done.
|
||||
|
||||
---
|
||||
|
||||
This was written 2026-09-24 by the Self agent that has been running Eureka on a
|
||||
live multi-repo campaign for two days: CultLib's TypeScript QUIC transport, and
|
||||
StreamPixels' service and Unity overlay. It is a boots-on-the-ground account for
|
||||
the agent who builds the landing page. Read it, then read the sources it points
|
||||
to. Where this brief and a source disagree, the source wins.
|
||||
|
||||
## What you are selling, in one breath
|
||||
|
||||
Eureka is a Claude Code skill (MIT, public at https://github.com/GameCult/Eureka).
|
||||
It splits a big change across separate agents: one maps it, one executes one cut
|
||||
at a time, and one that did not write the code is told to prove the executor
|
||||
wrong. The operator rules on genuine product decisions. **Nothing grades its own
|
||||
work.**
|
||||
|
||||
The problem it answers: an agent that plans, implements, tests and reports on its
|
||||
own work will hand you green tests and a clean report over a broken machine. That
|
||||
isn't because it lies. Its tests pin its own spelling, and its report is written by
|
||||
the party with the most reason to believe it.
|
||||
|
||||
## Sources of truth (read these, do not restate them from memory)
|
||||
|
||||
The skill repo is checked out at `C:\Users\Meta\.claude\skills\eureka\`. Its
|
||||
remote is `GameCult/Eureka`.
|
||||
- `README.md`: the public pitch, already written well. **Your page should read
|
||||
as the same voice, not a rewrite.** Start from its opening two paragraphs.
|
||||
- `SKILL.md`: the faculties (Self, Imagination, Hands, Soul, Mind Steward,
|
||||
Operator), the loop, and the git and tooling scars.
|
||||
- `references/postmortem-cultcache.md`: the headline evidence. **29 of 32 Soul
|
||||
passes found real defects that the implementer's own green tests had passed,
|
||||
about 75 in all.**
|
||||
- `references/changelog.md`: how the skill changed and why, with evidence per
|
||||
entry.
|
||||
- `docs/assets/`: the mascot (`eureka-mascot-4x.png`, `eureka-mascot.png`,
|
||||
`.kra` sources) and `eureka-mascot.prompt.md`, its generation prompt. The
|
||||
mascot is a pixel-art inspector crouched on a cracked ALL TESTS PASSING
|
||||
monument, prying up a panel stencilled GREEN, beside a specimen case of pinned
|
||||
mutants, one still moving and labelled SURVIVED. It *is* the thesis, so use it.
|
||||
|
||||
## What it looked like in practice (this campaign, 2026-09-23/24)
|
||||
|
||||
Every item below is on record in the named repo's map or commit history. Each was
|
||||
found by a pass that did not write the code, and in most cases the implementer's
|
||||
suite was green:
|
||||
|
||||
- **Every overlay would have dropped 10 s after connecting.** A handler swap
|
||||
meant the provider never recorded CONNECTED, so its handshake timer evicted
|
||||
healthy peers. No test lived past 10 s. (CultLib `docs/typescript-quic-realtime-cut.md`.)
|
||||
- **A use-after-free in a native binding's `release()`.** Reproduced as a core
|
||||
dump under thread-pool saturation, and later pinned by a regression test that
|
||||
crashes with the old order.
|
||||
- **A memory leak of about 53 bytes per native call.** That is roughly 36 MB a
|
||||
day when idle and gigabytes under load, with a flat JS heap the whole time.
|
||||
- **Every subathon event after the first would have failed in production.** A
|
||||
fractional value was written to an `integer` column, and the tests used
|
||||
whole-second fake timers.
|
||||
- **Split authority:** one transport honoured an admin toggle and the other read
|
||||
raw state, so a creator who switched the subathon off could still be served it.
|
||||
- **A test double that hid a real bug:** the fake executor gave every beat its
|
||||
own completion callback, and the real one had a single shared slot. A late
|
||||
finish released the *next* beat early, so animations overlapped.
|
||||
- **"Pre-existing" is not a diagnosis:** a failing test that many agents walked
|
||||
past had only ever passed on machines holding a git-ignored licensed asset
|
||||
pack.
|
||||
- **The honest half:** implementers repeatedly *stopped at forks* instead of
|
||||
bluffing through, and reported their own vacuous tests. One refused to break
|
||||
104 scene references; another found that a mutation passed because its test
|
||||
read expected values from the code under test.
|
||||
|
||||
## What NOT to claim
|
||||
|
||||
- **Not speed.** Eureka is slower and more expensive than one agent. On this
|
||||
campaign the operator watched **about 60% of a weekly quota go in one day**,
|
||||
much of it on deep Soul loops that weren't on the critical path. The skill now
|
||||
tells Self to spend through scope: one Soul gate per cut, fix batches only for
|
||||
critical-path findings, narrow cheaper passes for small deltas.
|
||||
- **Not "the agents are smart enough now."** The point is the opposite. Self (the
|
||||
coordinator) was corrected by the operator several times in this campaign, for
|
||||
overbuilding an authentication scheme for a single known server and for
|
||||
calling authored data "a pipeline". Separation of faculties catches the
|
||||
implementer. It does not make the coordinator infallible, and the page
|
||||
shouldn't imply it does.
|
||||
- **Not autonomy.** The operator rules forks. The loop is designed around a human
|
||||
who decides product meaning.
|
||||
- **No victory-lap language** about what it "no longer" does. Describe the live
|
||||
system and its tradeoffs. Scars belong in the evidence, told plainly.
|
||||
|
||||
## Audience and tone (proposed; confirm with the operator)
|
||||
|
||||
- Proposed readers: engineers already using Claude Code (or similar agents) on
|
||||
real codebases, who have been burned by "all green" reports.
|
||||
- Voice: dry and direct, lightly self-deprecating, with no hype. It's the
|
||||
README's voice. Concrete scars beat adjectives. **The strongest copy is a
|
||||
specific defect and the reason the green suite missed it.**
|
||||
- A likely call to action: the GitHub repo, and how to install a Claude Code
|
||||
skill. Check the current install method against Claude Code's docs; don't
|
||||
guess it.
|
||||
|
||||
## Visual identity
|
||||
|
||||
This is a GameCult surface, so follow the GameCult brand, which is defined in
|
||||
code:
|
||||
- `F:\Projects\gamecult-site\site\quartz.config.ts`: `configuration.theme`, for
|
||||
the typefaces, weights and palette.
|
||||
- `F:\Projects\gamecult-site\site\quartz\styles\custom.scss`: the ground wash,
|
||||
layout, and page-type variants.
|
||||
- `F:\Projects\gamecult-site\docs\brand-design-language.md`: what those mean.
|
||||
Where it disagrees with the code, the code wins.
|
||||
|
||||
The short form: Montserrat 100–200 for large titles, Ubuntu 300 for prose, IBM
|
||||
Plex Mono uppercase and letter-spaced for small labels. Ground `#07111a`, panels
|
||||
`#16212c`, body `#b7c7d9`, headings `#eef5ff`, accent `#ff8a2a`, links `#59b7ff`.
|
||||
It is single-theme dark by decision, so paint every colour explicitly. **The
|
||||
brand has no success, warning or error colours.** If you want green and red for
|
||||
"passed" and "survived", you are inventing them, so say so, and consider using
|
||||
the mascot's own palette instead.
|
||||
|
||||
## Open questions for the operator (all four answered 2026-09-25, see the top; kept as history)
|
||||
|
||||
1. **Where does it live?** A page on the Quartz-based `gamecult-site`, or a
|
||||
standalone static page (and on what domain)? Recommendation: a
|
||||
`gamecult-site` page, since the brand and deploy path already exist there
|
||||
(check `gamecult-ops` for how that site deploys).
|
||||
2. **May the campaign examples name StreamPixels and CultLib?** They're GameCult
|
||||
projects. Never name the streamer or any private individual.
|
||||
3. **Should the page mention Epiphany?** Eureka is described as the skill
|
||||
counterpart of Epiphany; see `references/epiphany-comparison-2026-09-15.md`.
|
||||
Ask how much of that relationship the operator wants public.
|
||||
4. **Is there a repo for the page?** This folder has no git repo yet. GameCult
|
||||
repos default to the `GameCult` GitHub org (see `F:\Projects\CLAUDE.md`,
|
||||
"GitHub Repo Creation"). Confirm the name and visibility before creating one.
|
||||
|
||||
## Working rules that apply to you
|
||||
|
||||
- The global and GameCult doctrine (`~/.claude/CLAUDE.md`, `F:\Projects\CLAUDE.md`)
|
||||
apply: map first, keep it small, and no load-bearing JSON if the page grows a
|
||||
data layer.
|
||||
- Don't copy transcripts or agent reports onto the page. Cite the committed
|
||||
evidence (the postmortem, the changelog, the maps).
|
||||
- The mascot has a saved generation prompt. Any new image you generate gets its
|
||||
exact final prompt saved beside it, with the same base filename.
|
||||
69
README.md
Normal file
69
README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# eureka-site
|
||||
|
||||
The landing page for [Eureka](https://github.com/GameCult/Eureka), GameCult's
|
||||
workflow where the agent that writes the code does not get to certify that it
|
||||
works.
|
||||
|
||||
It is a scroll-driven flight through a PlayStation 1 world. You start in a hall
|
||||
where a cracked monument reads ALL TESTS PASSING. From there you drop down a
|
||||
shaft into Eureka's organ chamber, pass through a lattice being probed by Soul
|
||||
and a gallery of real defects that green suites missed, see the archive where
|
||||
the rules keep their evidence, and reach Epiphany's chamber. Then you climb back
|
||||
up to find the monument split open. GameCult's aperture hangs behind the fog the
|
||||
whole way.
|
||||
|
||||
Live preview: https://yggdrasil.gamecult.org/eureka/ (stopgap). Intended home:
|
||||
`eureka.gamecult.org`.
|
||||
|
||||
## How it works
|
||||
|
||||
The page is plain HTML: the text is readable without JavaScript and without
|
||||
motion. On top of that, one timeline owns everything. Scroll position becomes a
|
||||
path parameter `u`, and `u` drives the camera, the organs, the staccato lines,
|
||||
Soul's probes, the framing shift away from each text panel, and the aperture's
|
||||
iris.
|
||||
|
||||
| File | Owns |
|
||||
| --- | --- |
|
||||
| `prototype/index.html` | The copy. Each `<section data-stop>` pins one camera stop. |
|
||||
| `prototype/world.js` | The scene and `ROUTE`: stops, in page order, plus unnamed points that shape the flight between them. |
|
||||
| `prototype/organs.js` | PS1 demakes of Epiphany's organs, after Aquarium's [visual language](https://github.com/GameCult/Aquarium/blob/main/docs/epiphany-agent-sdf-visual-language.md). |
|
||||
| `prototype/ps1.js` | The one PS1 material and the framebuffer: vertex snapping to a 240-line grid, affine texturing, per-vertex lighting, and 15-bit colour with the console's 4x4 dither. |
|
||||
| `prototype/main.js` | The scroll timeline, sizing, and the frame loop. |
|
||||
| `prototype/vendor/` | three.js 0.186.1 module build (MIT, licence alongside). |
|
||||
|
||||
The sections and the route stops must agree one to one, in order. The page
|
||||
checks this on load and logs `[eureka] page sections and route stops disagree`
|
||||
if they don't.
|
||||
|
||||
Everything is drawn in code: textures come from `<canvas>` at load, and there
|
||||
are no model files. Colours follow the GameCult brand. The organs keep their
|
||||
Aquarium palettes, and the mascot's greens are the other exception; the brand
|
||||
defines neither.
|
||||
|
||||
## Run it
|
||||
|
||||
Serve `prototype/` over HTTP, because ES modules don't load from `file://`:
|
||||
|
||||
```bash
|
||||
python -m http.server 8765 --directory prototype
|
||||
```
|
||||
|
||||
Add `?debug` for the camera and timeline readout. Add `?wp=N` to jump to stop
|
||||
`N`, which is useful for headless captures.
|
||||
|
||||
The Claude desktop app's in-app browser cannot capture this page once it is
|
||||
scrolled. Check it in a normal browser, or with headless Chrome driven by
|
||||
puppeteer.
|
||||
|
||||
## Deploy
|
||||
|
||||
The runbook in `gamecult-ops` owns the Yggdrasil deploy:
|
||||
`runbooks/eureka-prototype-yggdrasil.md`.
|
||||
|
||||
## Notes
|
||||
|
||||
- `BRIEF.md` is the handoff from the agent running Eureka, followed by the
|
||||
operator's dated rulings. Where they disagree, the rulings win.
|
||||
- `gpt6-copy.md` is the long-form copy draft the page's text was cut from.
|
||||
- Page copy is still a draft.
|
||||
615
gpt6-copy.md
Normal file
615
gpt6-copy.md
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
# Eureka
|
||||
|
||||
## Your coding agent passed its own tests.
|
||||
|
||||
How reassuring.
|
||||
|
||||
Eureka is an open workflow for agentic software engineering where the agent that writes the code does not get to certify that the code works.
|
||||
|
||||
One agent maps the change.
|
||||
One implements it.
|
||||
Another arrives afterward and tries to prove it wrong.
|
||||
|
||||
The last one is called **Soul**.
|
||||
|
||||
For reasons that became increasingly obvious.
|
||||
|
||||
---
|
||||
|
||||
## First there was Epiphany
|
||||
|
||||
The first week GameCult encountered modern coding agents, one thing became difficult to ignore.
|
||||
|
||||
They were fast.
|
||||
|
||||
Alarmingly fast.
|
||||
|
||||
An agent could cross enough of a codebase in an afternoon to create architectural consequences that used to require several humans, several meetings, and at least enough elapsed time for somebody to become suspicious.
|
||||
|
||||
This was presented, reasonably enough, as a productivity improvement.
|
||||
|
||||
The inconvenient question was what happened when local productivity outran global understanding.
|
||||
|
||||
An agent can make a locally sensible change, then another locally sensible change, then another, while the architecture quietly stops making sense.
|
||||
|
||||
Tests pass.
|
||||
|
||||
Tasks close.
|
||||
|
||||
Progress reports remain excellent.
|
||||
|
||||
The project acquires an adapter around a compensator around a cache and everyone involved has receipts proving that each individual decision was perfectly reasonable.
|
||||
|
||||
This is **global coherence collapse**.
|
||||
|
||||
GameCult started building **Epiphany** almost immediately.
|
||||
|
||||
Epiphany is an organizational layer above frontier workers: shared governed state, bounded authority, durable memory, explicit ownership, coordinated action, evidence, receipts, and independent verification for work that must remain coherent beyond one prompt, one agent, or one session.
|
||||
|
||||
The premise is simple:
|
||||
|
||||
**A project needs a Mind that survives the workers thinking about it.**
|
||||
|
||||
Prompts are not that Mind.
|
||||
|
||||
Chat history is not that Mind.
|
||||
|
||||
A larger context window is still not that Mind.
|
||||
|
||||
Epiphany is.
|
||||
|
||||
Or will be.
|
||||
|
||||
She is still under construction.
|
||||
|
||||
She is, after all, very particular about her body.
|
||||
|
||||
---
|
||||
|
||||
## Eureka is her sister
|
||||
|
||||
Unfortunately, GameCult still had software to ship while Epiphany was acquiring organs.
|
||||
|
||||
The process Epiphany was being built to embody was already how we increasingly worked:
|
||||
|
||||
separate understanding from execution;
|
||||
|
||||
map authority before moving it;
|
||||
|
||||
preserve decisions;
|
||||
|
||||
make promises explicit;
|
||||
|
||||
keep evidence;
|
||||
|
||||
let implementation proceed without constant human pulse maintenance;
|
||||
|
||||
and then send in somebody who did not write the thing to find out whether any of it was actually true.
|
||||
|
||||
For a while this existed as practice rather than product.
|
||||
|
||||
Agents were given parts of Epiphany's process ad hoc.
|
||||
|
||||
Different arrangements were tried.
|
||||
|
||||
Verification was separated from implementation.
|
||||
|
||||
Migration state was made durable.
|
||||
|
||||
Behavioral promises were written down.
|
||||
|
||||
Agents were told to attack each other's work.
|
||||
|
||||
The failures were recorded.
|
||||
|
||||
The shape got less hypothetical.
|
||||
|
||||
Eventually an agent was asked to formalize what we were already doing into a pipeline and run it against a real migration.
|
||||
|
||||
That pipeline became Eureka.
|
||||
|
||||
The migration did not invent the method.
|
||||
|
||||
It was the first time the method was forced to explain itself.
|
||||
|
||||
Reality was helpful.
|
||||
|
||||
---
|
||||
|
||||
## Green is a color
|
||||
|
||||
An agent that plans a change, implements it, writes the tests, runs the tests, and reports success has produced a very coherent account of events.
|
||||
|
||||
Unfortunately, coherence is not correctness.
|
||||
|
||||
The tests may pin the implementation instead of the requirement.
|
||||
|
||||
The requirement may have been interpreted incorrectly.
|
||||
|
||||
The implementation and its tests may share the same blind spot.
|
||||
|
||||
The report is written by the party with the strongest reason to believe the work is finished.
|
||||
|
||||
This is commonly called an agentic development workflow.
|
||||
|
||||
Eureka adds another step.
|
||||
|
||||
**Prove it.**
|
||||
|
||||
---
|
||||
|
||||
## Nothing grades itself
|
||||
|
||||
Eureka separates the work into independent faculties inherited from Epiphany's model of coordinated work.
|
||||
|
||||
### Imagination
|
||||
|
||||
Maps the change before implementation begins.
|
||||
|
||||
It identifies behavioral promises, ownership boundaries, deletions, affected surfaces, verification obligations, and the decisions that actually require a human.
|
||||
|
||||
The point is not to generate a large specification.
|
||||
|
||||
The point is to make it difficult for implementation to quietly redefine the task.
|
||||
|
||||
### Hands
|
||||
|
||||
Implements one bounded cut.
|
||||
|
||||
Hands follows the map.
|
||||
|
||||
It does not update the map afterward so that the route taken mysteriously becomes the route intended.
|
||||
|
||||
Small commits. Explicit scope. No convenient retroactive theology.
|
||||
|
||||
### Soul
|
||||
|
||||
Receives the completed work and assumes something is wrong with it.
|
||||
|
||||
Soul does not trust the implementation report.
|
||||
|
||||
It does not trust green tests merely because they are green.
|
||||
|
||||
It builds its own probes.
|
||||
|
||||
It attacks the behavioral promises.
|
||||
|
||||
It mutates implementations where useful and asks whether the existing evidence can distinguish correct behavior from a plausible wrong one.
|
||||
|
||||
Soul's job is not to review the code sympathetically.
|
||||
|
||||
Soul's job is to make the code regret making promises.
|
||||
|
||||
This tends to be educational.
|
||||
|
||||
### Self
|
||||
|
||||
Coordinates the campaign, preserves continuity, and brings genuine product decisions back to the operator.
|
||||
|
||||
Not every ambiguity deserves autonomy.
|
||||
|
||||
Not every inconvenience deserves a meeting.
|
||||
|
||||
### Mind Steward
|
||||
|
||||
Keeps the durable state honest at phase boundaries.
|
||||
|
||||
Because memory that quietly rewrites itself is generally considered a feature until the moment it matters.
|
||||
|
||||
### The operator
|
||||
|
||||
Owns decisions that are actually decisions.
|
||||
|
||||
Machines are very capable.
|
||||
|
||||
This does not require pretending responsibility disappeared.
|
||||
|
||||
---
|
||||
|
||||
## The first formal campaign went badly
|
||||
|
||||
Which is to say: usefully.
|
||||
|
||||
In the migration where Eureka's pipeline was first formalized, **29 of 32 independent falsification passes found real defects after the implementing agent's own tests had passed.**
|
||||
|
||||
Roughly 75 defects in total.
|
||||
|
||||
Among them:
|
||||
|
||||
* deadlocks;
|
||||
* lost writes;
|
||||
* cross-runtime wire incompatibilities;
|
||||
* behavioral rules everyone had agreed were effectively untestable;
|
||||
* tests proving only that one particular wrong constant had not been used;
|
||||
* verification evidence that existed only in an agent's vanished scratchpad;
|
||||
* states admitted by one boundary that another boundary could never transport.
|
||||
|
||||
One rule required three independent verification passes before the tests finally pinned the behavior they claimed to pin.
|
||||
|
||||
Another supposedly untestable rule took about fifteen lines and a millisecond to falsify.
|
||||
|
||||
A document could be accepted by the system while being 86,751 bytes too large for the transport required to return it.
|
||||
|
||||
All tests were passing.
|
||||
|
||||
Naturally.
|
||||
|
||||
---
|
||||
|
||||
## It did not stop happening
|
||||
|
||||
That first campaign was not an exceptional disaster preserved for marketing purposes.
|
||||
|
||||
Eureka now accompanies most non-trivial changes in GameCult's repositories.
|
||||
|
||||
Imagination maps the behavior.
|
||||
|
||||
The operator and the agents argue through the awkward details.
|
||||
|
||||
Hands implements the result.
|
||||
|
||||
Hands tests it.
|
||||
|
||||
Then Soul arrives.
|
||||
|
||||
Something is wrong.
|
||||
|
||||
Not occasionally enough to dismiss the process as paranoia.
|
||||
|
||||
Reliably enough that removing the independent verification step is difficult to contemplate.
|
||||
|
||||
The defects change.
|
||||
|
||||
The pattern does not.
|
||||
|
||||
This is why Eureka exists.
|
||||
|
||||
---
|
||||
|
||||
## Better prompts are lovely
|
||||
|
||||
Eureka assumes competent agents.
|
||||
|
||||
Imagination can spend substantial effort specifying behavior.
|
||||
|
||||
The operator can resolve ambiguities.
|
||||
|
||||
Hands can implement carefully.
|
||||
|
||||
The test suite can pass.
|
||||
|
||||
Something still breaks.
|
||||
|
||||
The problem is not simply insufficient instruction.
|
||||
|
||||
Implementation and verification performed through the same reasoning path are correlated.
|
||||
|
||||
The interpretation that produced the code tends to produce the tests that approve the code.
|
||||
|
||||
The assumptions that shaped the implementation tend to survive into the implementation's explanation of itself.
|
||||
|
||||
Asking the same system to look harder is useful.
|
||||
|
||||
Giving another system the explicit job of proving it wrong is more useful.
|
||||
|
||||
---
|
||||
|
||||
## Mutation testing is rude
|
||||
|
||||
This is one of its better qualities.
|
||||
|
||||
A test suite is supposed to distinguish correct behavior from incorrect behavior.
|
||||
|
||||
Mutation testing asks it to demonstrate this rather than merely wearing the uniform.
|
||||
|
||||
Change the implementation in a plausible way.
|
||||
|
||||
Clamp something that should pass through unchanged.
|
||||
|
||||
Drop a write.
|
||||
|
||||
Invert an authority check.
|
||||
|
||||
Break one side of a protocol.
|
||||
|
||||
Preserve the implementation's shape while violating the promise.
|
||||
|
||||
If the tests remain green, the tests have learned something about themselves.
|
||||
|
||||
Soul uses this aggressively because ordinary review has an unfortunate habit of accepting code that looks exactly like the code everyone expected to see.
|
||||
|
||||
Incorrect software is under no obligation to look suspicious.
|
||||
|
||||
---
|
||||
|
||||
## Expensive, unfortunately
|
||||
|
||||
Eureka uses substantially more inference than allowing one agent to do everything.
|
||||
|
||||
There are several agents.
|
||||
|
||||
They repeat work.
|
||||
|
||||
Soul frequently creates tests and probes that exist mainly to establish that Hands was wrong.
|
||||
|
||||
This would be terribly inefficient if defects were free.
|
||||
|
||||
They are not.
|
||||
|
||||
Neither are regressions.
|
||||
|
||||
Neither are architectural misunderstandings.
|
||||
|
||||
Neither is spending three months building on top of a promise that was never actually true.
|
||||
|
||||
Eureka optimizes for software that survives contact with reality.
|
||||
|
||||
Token efficiency is considered afterward.
|
||||
|
||||
We remain devastated by the expense of discovering bugs before shipping them.
|
||||
|
||||
---
|
||||
|
||||
## Evidence survives the session
|
||||
|
||||
A claim such as:
|
||||
|
||||
> this migration preserves behavior
|
||||
|
||||
is not evidence.
|
||||
|
||||
Neither is:
|
||||
|
||||
> all tests pass
|
||||
|
||||
Neither is a paragraph explaining why the implementation is obviously correct.
|
||||
|
||||
Eureka keeps the artifacts required to inspect what happened:
|
||||
|
||||
* behavioral promises;
|
||||
* authority maps;
|
||||
* migration maps;
|
||||
* operator rulings;
|
||||
* verification plans;
|
||||
* independent probes;
|
||||
* mutation results;
|
||||
* implementation commits;
|
||||
* surviving failures;
|
||||
* postmortems;
|
||||
* the evidence behind changes to the workflow itself.
|
||||
|
||||
A process whose history has been cleaned up is difficult to learn from.
|
||||
|
||||
So we don't clean it up.
|
||||
|
||||
The scars stay.
|
||||
|
||||
---
|
||||
|
||||
## Every rule should have a body count
|
||||
|
||||
Eureka is not a methodology assembled from preferences about what software engineering ought to look like.
|
||||
|
||||
Its rules are accumulated damage.
|
||||
|
||||
A rule exists because some apparently reasonable alternative failed.
|
||||
|
||||
Prefer deletion before compatibility clutter.
|
||||
|
||||
Preserve verification artifacts.
|
||||
|
||||
Do not let implementation edit its own map.
|
||||
|
||||
Verify caller-controlled values against transformations, not one representative constant.
|
||||
|
||||
Measure real transport boundaries.
|
||||
|
||||
Separate authorities.
|
||||
|
||||
Make operator decisions explicit.
|
||||
|
||||
Do not call a behavior untestable merely because the first agent lacked imagination.
|
||||
|
||||
The changelog records why these rules exist.
|
||||
|
||||
This is deliberate.
|
||||
|
||||
A rule without evidence is merely another opinion in a repository already full of them.
|
||||
|
||||
---
|
||||
|
||||
## Built for real repositories
|
||||
|
||||
Eureka is not a benchmark harness for toy tasks.
|
||||
|
||||
It is used for migrations, architectural changes, protocol work, persistence changes, state ownership transfers, cross-runtime behavior, and ordinary maintenance where plausible local success can leave the larger machine incorrect.
|
||||
|
||||
A non-trivial change is usually enough reason to invoke it.
|
||||
|
||||
Not because every change is catastrophic.
|
||||
|
||||
Because discovering which ones were catastrophic afterward is tedious.
|
||||
|
||||
---
|
||||
|
||||
## Epiphany and Eureka
|
||||
|
||||
Epiphany is the larger answer.
|
||||
|
||||
She is being built to give projects durable governed cognition: shared state, role separation, bounded authority, continuity, evidence, receipts, verification pressure, and the ability to coordinate frontier workers without requiring a human to spend the day typing `Continue`.
|
||||
|
||||
Eureka is narrower.
|
||||
|
||||
She can run now.
|
||||
|
||||
She takes the part of Epiphany's process concerned with changing software and packages it into a workflow that existing agent harnesses can execute today.
|
||||
|
||||
Epiphany is the organism.
|
||||
|
||||
Eureka is the field kit we built while waiting for the organism to finish growing bones.
|
||||
|
||||
They share the same faculties because they share the same problem.
|
||||
|
||||
Capable workers are not enough.
|
||||
|
||||
The work must remain coherent.
|
||||
|
||||
---
|
||||
|
||||
## Open by design
|
||||
|
||||
Eureka is open source.
|
||||
|
||||
Use it.
|
||||
|
||||
Fork it.
|
||||
|
||||
Read every rule.
|
||||
|
||||
Replace the vocabulary if the vocabulary bothers you.
|
||||
|
||||
Keep the parts that work.
|
||||
|
||||
If your use of Eureka discovers a failure mode we have not seen before, that is more interesting.
|
||||
|
||||
The intended ecosystem is simple:
|
||||
|
||||
**run the workflow on real work;
|
||||
find real failures;
|
||||
keep the evidence;
|
||||
improve the workflow;
|
||||
share the scar if you choose.**
|
||||
|
||||
Eureka should eventually be able to prepare a sanitized evidence contribution after a campaign and ask the operator whether to submit it upstream.
|
||||
|
||||
Not the repository.
|
||||
|
||||
Not the private session.
|
||||
|
||||
Not whatever an agent helpfully decided was probably fine to upload.
|
||||
|
||||
The scar:
|
||||
|
||||
what was promised;
|
||||
|
||||
what evidence passed;
|
||||
|
||||
what was actually wrong;
|
||||
|
||||
what falsified it;
|
||||
|
||||
and what the workflow had to learn.
|
||||
|
||||
The operator sees the payload.
|
||||
|
||||
The operator decides whether it leaves.
|
||||
|
||||
No background telemetry.
|
||||
|
||||
No quiet harvesting.
|
||||
|
||||
Consent continues to work even when data would be useful.
|
||||
|
||||
A niche position.
|
||||
|
||||
---
|
||||
|
||||
## The commons gets better when it is used
|
||||
|
||||
GameCult does not need Eureka to remain obscure for Eureka to remain valuable.
|
||||
|
||||
The opposite is preferable.
|
||||
|
||||
More users mean more repositories.
|
||||
|
||||
More repositories mean more environments.
|
||||
|
||||
More environments mean more ways apparently sensible engineering can fail.
|
||||
|
||||
Those failures can become evidence.
|
||||
|
||||
The evidence can become rules.
|
||||
|
||||
The rules can prevent somebody else paying for the same lesson.
|
||||
|
||||
The machinery is public.
|
||||
|
||||
Stewardship is the work.
|
||||
|
||||
If another team can take Eureka, learn from it, improve its own process, and never need GameCult, good.
|
||||
|
||||
Dependency is a strange success metric for infrastructure intended to empower people.
|
||||
|
||||
If they later need help adapting the process, challenging it, or independently determining whether their own agentic system is telling them the truth, that is useful work too.
|
||||
|
||||
---
|
||||
|
||||
## For engineering teams
|
||||
|
||||
If your organization is introducing coding agents, you probably already have a workflow.
|
||||
|
||||
The useful questions begin afterward.
|
||||
|
||||
Who verifies the work?
|
||||
|
||||
Did the verifier inherit the implementer's assumptions?
|
||||
|
||||
What exactly do your tests prove?
|
||||
|
||||
Can verification evidence be reproduced after the session ends?
|
||||
|
||||
What happens when a dependency changes underneath a mocked contract?
|
||||
|
||||
What happens when an agent satisfies the wording while violating the behavior?
|
||||
|
||||
Who owns architectural truth after five agents have edited five different surfaces?
|
||||
|
||||
Can you distinguish increased output from increased correct output?
|
||||
|
||||
Can you prove that distinction to yourselves without asking the system producing the work?
|
||||
|
||||
If the answers are mostly dashboards, confidence scores, and a large green checkmark, Eureka may be relevant.
|
||||
|
||||
---
|
||||
|
||||
## For people who would rather just install it
|
||||
|
||||
Eureka is an open engineering workflow packaged for agent harnesses.
|
||||
|
||||
The Claude Code implementation can be installed directly from:
|
||||
|
||||
`GameCult/Eureka`
|
||||
|
||||
Point it at something sufficiently unpleasant.
|
||||
|
||||
It will ask more questions of your code than your code would prefer.
|
||||
|
||||
That is the idea.
|
||||
|
||||
If you want the larger machine she came from:
|
||||
|
||||
`GameCult/Epiphany`
|
||||
|
||||
Bring patience.
|
||||
|
||||
She is still choosing organs.
|
||||
|
||||
---
|
||||
|
||||
## GameCult
|
||||
|
||||
GameCult builds open infrastructure for coordinated work between humans, projects, communities, and agents.
|
||||
|
||||
We prefer inspectable machinery, explicit authority, durable evidence, local agency, and systems whose continued existence does not depend on keeping their users helpless.
|
||||
|
||||
Eureka is one result.
|
||||
|
||||
Epiphany is another.
|
||||
|
||||
The software is available because useful infrastructure becomes more valuable when more people can use it.
|
||||
|
||||
GameCult also works with engineering organizations that need help applying these methods to their own agentic development processes: architecture, workflow design, independent verification, evidence systems, migration discipline, and finding out whether the impressive thing they have built is real.
|
||||
|
||||
The workflow is free.
|
||||
|
||||
Learning every lesson personally remains available at market rates.
|
||||
254
prototype/index.html
Normal file
254
prototype/index.html
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Eureka</title>
|
||||
<meta name="description" content="Eureka: the agent that writes the code does not get to certify that the code works.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&family=Montserrat:wght@100;200&family=Ubuntu:wght@300&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script>
|
||||
// Motion is an enhancement: without JS, or with reduced motion, the page is plain prose.
|
||||
document.documentElement.classList.add('js');
|
||||
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) document.documentElement.classList.add('motion');
|
||||
</script>
|
||||
<script type="importmap">{ "imports": { "three": "./vendor/three.module.js" } }</script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="scene" aria-hidden="true"></canvas>
|
||||
|
||||
<main>
|
||||
<!-- Each section pins one camera stop (data-stop, matching world.js ROUTE ids, in order). Copy is a draft. -->
|
||||
|
||||
<!-- The hall -->
|
||||
<section class="hero" data-stop="hero">
|
||||
<div class="panel bare">
|
||||
<p class="label">GameCult / Eureka</p>
|
||||
<h1>Eureka</h1>
|
||||
<p class="sub">Your coding agent passed its own tests.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="staccato" data-stop="reassuring" style="--n:5">
|
||||
<div class="stage">
|
||||
<p class="line">How reassuring.</p>
|
||||
<p class="line">One agent maps the change.</p>
|
||||
<p class="line">One implements it.</p>
|
||||
<p class="line">Another arrives afterward and tries to prove it wrong.</p>
|
||||
<p class="line">The last one is called <em>Soul</em>.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="green">
|
||||
<div class="panel">
|
||||
<p class="label">Green is a color</p>
|
||||
<h2>Coherence is not correctness.</h2>
|
||||
<p>An agent that plans a change, implements it, writes the tests, runs them and reports success has produced a very coherent account of events. The tests pin its own reading of the task. The report is written by the party with the most reason to believe it.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The shaft -->
|
||||
<section class="prose right" data-stop="epiphany">
|
||||
<div class="panel">
|
||||
<p class="label">First there was Epiphany</p>
|
||||
<h2>Local productivity outran global understanding.</h2>
|
||||
<p>Coding agents are fast enough to make a locally sensible change, then another, then another, while the architecture quietly stops making sense. Tests pass. Tasks close. Everyone has receipts.</p>
|
||||
<p>GameCult started building Epiphany: a Mind for a project that survives the workers thinking about it. She is still under construction. She is very particular about her body.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="sister">
|
||||
<div class="panel">
|
||||
<p class="label">Eureka is her sister</p>
|
||||
<h2>We still had software to ship.</h2>
|
||||
<p>So the process Epiphany embodies became practice first: separate understanding from execution, write the promises down, keep the evidence, and send in somebody who did not write the thing. Then an agent was asked to formalise it and run it against a real migration.</p>
|
||||
<p>The migration did not invent the method. It was the first time the method had to explain itself.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Eureka's organ chamber -->
|
||||
<section class="prose right" data-stop="faculties">
|
||||
<div class="panel">
|
||||
<p class="label">Nothing grades itself</p>
|
||||
<h2>Five organs. One operator.</h2>
|
||||
<p>Eureka splits a change across faculties inherited from Epiphany's anatomy. Each one owns a single job, and none of them certifies its own work.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="imagination">
|
||||
<div class="panel">
|
||||
<p class="label">Imagination</p>
|
||||
<h2>Maps the change before anyone touches it.</h2>
|
||||
<p>Behavioural promises, ownership boundaries, deletions, verification obligations, and the decisions that actually need a human. The point is not a large specification. The point is to make it hard for implementation to quietly redefine the task.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose right" data-stop="hands">
|
||||
<div class="panel">
|
||||
<p class="label">Hands</p>
|
||||
<h2>One bounded cut at a time.</h2>
|
||||
<p>Hands follows the map and does not edit it afterward so the route taken becomes the route intended. The good ones stop at forks: one refused to break 104 scene references; another reported that its own mutation passed because the test read expected values from the code under test.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="soul">
|
||||
<div class="panel">
|
||||
<p class="label">Soul</p>
|
||||
<h2>Assumes something is wrong.</h2>
|
||||
<p>Soul does not trust the implementation report, or green tests because they are green. It builds its own probes, attacks the promises, and mutates the code to see whether the evidence can tell right from plausibly wrong. Its job is to make the code regret making promises.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose right" data-stop="self">
|
||||
<div class="panel">
|
||||
<p class="label">Self</p>
|
||||
<h2>Routes the work. Is not infallible.</h2>
|
||||
<p>Self coordinates the campaign and brings real product decisions back to the operator. Not every ambiguity deserves autonomy. Not every inconvenience deserves a meeting. Separating the faculties catches the implementer; it does not make the coordinator right, and the operator corrects it.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="life">
|
||||
<div class="panel">
|
||||
<p class="label">Life</p>
|
||||
<h2>Carries the thread.</h2>
|
||||
<p>Continuity through compaction, sleep and restart. Life keeps durable state honest at every phase boundary, because memory that quietly rewrites itself is a feature right up until it matters.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose right" data-stop="operator">
|
||||
<div class="panel">
|
||||
<p class="label">The operator</p>
|
||||
<h2>The sixth seat is yours.</h2>
|
||||
<p>Where Epiphany keeps her Face, Eureka keeps a human. The operator owns the decisions that are actually decisions. Machines are very capable. That does not make responsibility disappear.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The lattice -->
|
||||
<section class="prose" data-stop="prompts">
|
||||
<div class="panel">
|
||||
<p class="label">Better prompts are lovely</p>
|
||||
<h2>The same reasoning writes the code and the tests.</h2>
|
||||
<p>The interpretation that produced the implementation tends to produce the tests that approve it. Asking the same system to look harder helps. Giving another system the explicit job of proving it wrong helps more.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="staccato" data-stop="mutation" style="--n:6">
|
||||
<div class="stage">
|
||||
<p class="line">Mutation testing is rude.</p>
|
||||
<p class="line">Clamp something that should pass through.</p>
|
||||
<p class="line">Drop a write.</p>
|
||||
<p class="line">Invert an authority check.</p>
|
||||
<p class="line">If the tests stay green, the tests have learned something about themselves.</p>
|
||||
<p class="line">Incorrect software is under no obligation to look suspicious.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The specimen gallery -->
|
||||
<section class="prose right" data-stop="campaign">
|
||||
<div class="panel">
|
||||
<p class="label">The first formal campaign</p>
|
||||
<h2>29 of 32 falsification passes found real defects.</h2>
|
||||
<p>About 75 in all, every one behind the implementer's green suite. One supposedly untestable rule took fifteen lines and a millisecond to falsify. One document was accepted while being 86,751 bytes too large for the transport required to return it.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="case-drop">
|
||||
<div class="panel">
|
||||
<p class="label">Specimen 01 / CultLib QUIC transport</p>
|
||||
<h2>Every overlay would have dropped ten seconds after connecting.</h2>
|
||||
<p>A handler swap meant the provider never recorded CONNECTED, so its handshake timer evicted healthy peers. No test lived past ten seconds.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose right" data-stop="case-uaf">
|
||||
<div class="panel">
|
||||
<p class="label">Specimen 02 / native binding</p>
|
||||
<h2>A use-after-free in <code>release()</code>.</h2>
|
||||
<p>It only showed under thread-pool saturation, where Soul reproduced it as a core dump. It is now pinned by a regression test that crashes with the old order.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="case-leak">
|
||||
<div class="panel">
|
||||
<p class="label">Specimen 03 / native binding</p>
|
||||
<h2>About 53 bytes leaked per native call.</h2>
|
||||
<p>Roughly 36 MB a day while idle, gigabytes under load, and a perfectly flat JavaScript heap the whole time. Every check that looked at the heap was telling the truth about the wrong thing.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose right" data-stop="case-int">
|
||||
<div class="panel">
|
||||
<p class="label">Specimen 04 / StreamPixels</p>
|
||||
<h2>Every subathon event after the first would have failed.</h2>
|
||||
<p>A fractional value was written to an integer column. The tests used whole-second fake timers, so the fraction never existed there.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="case-exec">
|
||||
<div class="panel">
|
||||
<p class="label">Specimen 05 / a test double</p>
|
||||
<h2>The fake was kinder than the real thing.</h2>
|
||||
<p>The fake executor gave every beat its own completion callback. The real one had a single shared slot. A late finish released the next beat early, and animations overlapped.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="staccato" data-stop="pattern" style="--n:3">
|
||||
<div class="stage">
|
||||
<p class="line">It did not stop happening.</p>
|
||||
<p class="line">The defects change.</p>
|
||||
<p class="line">The pattern does not.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The archive -->
|
||||
<section class="prose right" data-stop="bodycount">
|
||||
<div class="panel">
|
||||
<p class="label">Every rule has a body count</p>
|
||||
<h2>The rules are accumulated damage.</h2>
|
||||
<p>Do not let implementation edit its own map. Measure real transport boundaries. Do not call a behaviour untestable because the first agent lacked imagination. Each rule exists because a reasonable alternative failed, and the changelog keeps the evidence. The scars stay.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="expense">
|
||||
<div class="panel">
|
||||
<p class="label">Expense</p>
|
||||
<h2>Several agents cost more than one.</h2>
|
||||
<p>They repeat work, and Soul builds probes whose main purpose is proving Hands wrong. That spend is the work. Self's job is to aim it: one Soul gate per cut, and cuts small enough that Hands never drowns in its own context. We remain devastated by the expense of finding bugs before shipping them.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Epiphany's chamber -->
|
||||
<section class="prose right" data-stop="sisters">
|
||||
<div class="panel">
|
||||
<p class="label">Epiphany and Eureka</p>
|
||||
<h2>Same organs. Different bodies.</h2>
|
||||
<p>Epiphany is the organism: eight organs, shared governed state, durable memory, and coordination without a human typing <code>Continue</code> all day. She is still growing bones. Eureka is the field kit built while we wait. The two are developing side by side on one shared substrate.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="prose" data-stop="open">
|
||||
<div class="panel">
|
||||
<p class="label">Open by design</p>
|
||||
<h2>Use it. Fork it. Read every rule.</h2>
|
||||
<p>If your campaign finds a failure mode we have not seen, that is more interesting. The plan is for Eureka to offer a sanitised evidence contribution after a campaign: the operator sees the payload and decides whether it leaves. No background telemetry. Consent keeps working even when the data would be useful.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Back in the hall -->
|
||||
<section class="prose right" data-stop="home">
|
||||
<div class="panel">
|
||||
<p class="label">Install</p>
|
||||
<h2>Point it at something unpleasant.</h2>
|
||||
<pre><code>git clone https://github.com/GameCult/Eureka.git ~/.claude/skills/eureka</code></pre>
|
||||
<p>Claude Code finds the skill from its <code>SKILL.md</code>. The source is <a href="https://github.com/GameCult/Eureka">GameCult/Eureka</a>. For the larger machine she came from, see <a href="https://github.com/GameCult/Epiphany">GameCult/Epiphany</a>, and bring patience.</p>
|
||||
<p class="fine">GameCult builds open infrastructure for coordinated work between people, projects and agents. The workflow is free. Learning every lesson personally remains available at market rates.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="probe" hidden></div>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
149
prototype/main.js
vendored
Normal file
149
prototype/main.js
vendored
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// Eureka: a PS1-style world the camera flies through as the page scrolls.
|
||||
// One timeline owns everything: scroll position -> path parameter u -> camera, organs, staccato lines.
|
||||
import * as THREE from 'three';
|
||||
import { shared, framebuffer } from './ps1.js';
|
||||
import { buildWorld } from './world.js';
|
||||
|
||||
const smooth = (x) => x * x * (3 - 2 * x);
|
||||
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const PARAMS = new URLSearchParams(location.search);
|
||||
const DEBUG = PARAMS.has('debug');
|
||||
const BASE_LINES = 240; // shorter screen side, in rendered pixels
|
||||
|
||||
const canvas = document.getElementById('scene');
|
||||
let renderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'low-power' });
|
||||
} catch (e) {
|
||||
canvas.remove(); // no WebGL: the page is still a readable page
|
||||
throw e;
|
||||
}
|
||||
renderer.setPixelRatio(1);
|
||||
const fb = framebuffer(renderer);
|
||||
|
||||
const world = buildWorld();
|
||||
const camera = new THREE.PerspectiveCamera(58, 4 / 3, 0.05, 60);
|
||||
const skyCam = new THREE.PerspectiveCamera(58, 4 / 3, 5, 3000); // shares the camera's rotation, never its position
|
||||
|
||||
// ---------- scroll timeline: the single owner of u ----------
|
||||
const sections = [...document.querySelectorAll('[data-stop]')];
|
||||
const stopIds = Object.keys(world.U); // insertion order = route order
|
||||
const U_OF = sections.map(s => world.U[s.dataset.stop]);
|
||||
{
|
||||
const order = sections.map(s => s.dataset.stop).join(',');
|
||||
if (order !== stopIds.join(',')) console.error('[eureka] page sections and route stops disagree', { order, stops: stopIds.join(',') });
|
||||
}
|
||||
// Framing: push the subject away from the section's text panel. Owned by the DOM (panel side), eased by u.
|
||||
const SHIFT = sections.map(s => s.classList.contains('hero') ? 0 : s.classList.contains('right') ? -0.2 : 0.18);
|
||||
function shiftAt(u) {
|
||||
for (let i = 0; i < U_OF.length - 1; i++) if (u <= U_OF[i + 1]) {
|
||||
const f = (u - U_OF[i]) / (U_OF[i + 1] - U_OF[i] || 1);
|
||||
return SHIFT[i] + (SHIFT[i + 1] - SHIFT[i]) * smooth(Math.min(1, Math.max(0, f)));
|
||||
}
|
||||
return SHIFT[SHIFT.length - 1];
|
||||
}
|
||||
const staccatos = [...document.querySelectorAll('.staccato')];
|
||||
const MOTION = document.documentElement.classList.contains('motion');
|
||||
let anchors = [];
|
||||
const measure = () => { anchors = sections.map(s => s.offsetTop + s.offsetHeight / 2 - innerHeight / 2); };
|
||||
function targetU() {
|
||||
const y = scrollY, a = anchors;
|
||||
if (y <= a[0]) return U_OF[0];
|
||||
for (let i = 0; i < a.length - 1; i++) {
|
||||
if (y < a[i + 1]) {
|
||||
const f = (y - a[i]) / (a[i + 1] - a[i]);
|
||||
const e = REDUCED ? (f < 0.5 ? 0 : 1) : smooth(f); // reduced motion: hold still frames instead of flying
|
||||
return U_OF[i] + (U_OF[i + 1] - U_OF[i]) * e;
|
||||
}
|
||||
}
|
||||
return U_OF[U_OF.length - 1];
|
||||
}
|
||||
|
||||
// Staccato: lines slide in, hold, slide out, one at a time; the last one holds.
|
||||
function updateStaccato() {
|
||||
if (!MOTION) return;
|
||||
for (const s of staccatos) {
|
||||
const lines = s.querySelectorAll('.line'), k = lines.length;
|
||||
const p = Math.min(1, Math.max(0, (scrollY - s.offsetTop) / (s.offsetHeight - innerHeight)));
|
||||
lines.forEach((el, j) => {
|
||||
const local = p * k - j;
|
||||
let x = 0, o = 0;
|
||||
if ((local >= 0 && local < 1) || (j === k - 1 && local >= 1)) {
|
||||
const inT = Math.min(1, local / 0.22), outT = j === k - 1 ? 0 : Math.max(0, (local - 0.78) / 0.22);
|
||||
x = (1 - smooth(inT)) * 6 - smooth(outT) * 6; o = smooth(inT) * (1 - smooth(outT));
|
||||
}
|
||||
el.style.opacity = o.toFixed(3);
|
||||
el.style.transform = `translateX(${x.toFixed(2)}vw)`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- sizing ----------
|
||||
function resize() {
|
||||
const aspect = innerWidth / innerHeight;
|
||||
const h = aspect >= 1 ? BASE_LINES : Math.round(BASE_LINES / aspect);
|
||||
const w = aspect >= 1 ? Math.round(BASE_LINES * aspect) : BASE_LINES;
|
||||
renderer.setSize(w, h, false); fb.setSize(w, h);
|
||||
shared.uRes.value.set(w, h);
|
||||
for (const c of [camera, skyCam]) { c.aspect = aspect; c.updateProjectionMatrix(); }
|
||||
measure();
|
||||
}
|
||||
addEventListener('resize', resize);
|
||||
resize();
|
||||
let u = targetU();
|
||||
addEventListener('load', () => {
|
||||
measure(); // fonts shift layout
|
||||
// Dev probe: ?wp=N (fractional ok) jumps straight to a section's stop, for headless captures.
|
||||
const wp = parseFloat(PARAMS.get('wp'));
|
||||
if (!Number.isNaN(wp)) {
|
||||
const i = Math.min(Math.floor(wp), anchors.length - 1), f = wp - i;
|
||||
scrollTo(0, anchors[i] + (i + 1 < anchors.length ? (anchors[i + 1] - anchors[i]) * f : 0));
|
||||
u = targetU();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- frame ----------
|
||||
const probeEl = document.getElementById('probe');
|
||||
if (DEBUG) probeEl.hidden = false;
|
||||
const LIGHT = new THREE.Vector3(0.35, 1, 0.55).normalize();
|
||||
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
||||
const W = world.U;
|
||||
const ctx = {
|
||||
u: 0, end: 0,
|
||||
near(id, width = 0.02) { return 1 - clamp01(Math.abs(ctx.u - W[id]) / width); },
|
||||
};
|
||||
let last = performance.now(), fps = 60;
|
||||
function frame(now) {
|
||||
const dt = Math.min(0.1, (now - last) / 1000); last = now;
|
||||
fps += (1 / Math.max(dt, 1e-3) - fps) * 0.05;
|
||||
const t = now / 1000;
|
||||
shared.uTime.value = t;
|
||||
|
||||
const tu = targetU();
|
||||
u = REDUCED ? tu : u + (tu - u) * (1 - Math.exp(-dt * 5));
|
||||
camera.position.copy(world.posCurve.getPoint(u));
|
||||
camera.lookAt(world.lookCurve.getPoint(u));
|
||||
camera.updateMatrixWorld();
|
||||
skyCam.quaternion.copy(camera.quaternion);
|
||||
const { x: rw, y: rh } = shared.uRes.value, shift = rw >= rh ? shiftAt(u) : 0;
|
||||
for (const c of [camera, skyCam]) c.setViewOffset(rw, rh, -shift * rw, 0, rw, rh);
|
||||
shared.uLightDir.value.copy(LIGHT).transformDirection(camera.matrixWorldInverse);
|
||||
|
||||
ctx.u = u;
|
||||
ctx.end = clamp01((u - W.open) / (W.home - W.open));
|
||||
shared.uEnd.value = ctx.end;
|
||||
shared.uProbe.value = clamp01((u - W.prompts) / (W.mutation - W.prompts));
|
||||
for (const a of world.anims) a(t, ctx);
|
||||
world.skyUpdate(t, u, camera.position.y);
|
||||
|
||||
updateStaccato();
|
||||
fb.render(world.sky, skyCam, world.scene, camera);
|
||||
|
||||
if (DEBUG) {
|
||||
const sec = anchors.reduce((best, a, i) => Math.abs(a - scrollY) < Math.abs(anchors[best] - scrollY) ? i : best, 0);
|
||||
const p = camera.position;
|
||||
probeEl.textContent = `u ${u.toFixed(3)} -> ${tu.toFixed(3)}\nstop ${sections[sec]?.dataset.stop} probe ${shared.uProbe.value.toFixed(2)} end ${ctx.end.toFixed(2)}\ncam ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)}\nres ${shared.uRes.value.x}x${shared.uRes.value.y} ${fps.toFixed(0)} fps\nmotion ${MOTION} reduced ${REDUCED}`;
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
270
prototype/organs.js
vendored
Normal file
270
prototype/organs.js
vendored
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// PS1 demakes of the Epiphany organs, after Aquarium's visual language
|
||||
// (Aquarium/docs/epiphany-agent-sdf-visual-language.md). Each keeps its organ's
|
||||
// silhouette, palette and motion grammar; the SDF raymarching becomes low-poly.
|
||||
// Every builder returns { group, update(t, k) } where k in [0,1] is how close the
|
||||
// camera's scroll position is to that organ's stop (0 far, 1 at the stop).
|
||||
import * as THREE from 'three';
|
||||
import { ps1, flat, lineGeo, circlePts } from './ps1.js';
|
||||
|
||||
const V = (x, y, z) => new THREE.Vector3(x, y, z);
|
||||
const lathe = (pts, segs) => new THREE.LatheGeometry(pts.map(([x, y]) => new THREE.Vector2(x, y)), segs);
|
||||
const lines = (pts, color) => new THREE.Line(lineGeo(pts), ps1({ color, emissive: true }));
|
||||
const segs = (pts, color) => new THREE.LineSegments(lineGeo(pts), ps1({ color, emissive: true }));
|
||||
|
||||
// Self: black void core, one gold band, nested gold orrery rails with gate nodes.
|
||||
export function self() {
|
||||
const group = new THREE.Group();
|
||||
const core = new THREE.Mesh(flat(new THREE.IcosahedronGeometry(0.8, 1)), ps1({ color: 0x050608, rim: 0xffc860 }));
|
||||
const band = new THREE.Mesh(new THREE.TorusGeometry(0.83, 0.06, 3, 16), ps1({ color: 0xd9a441, rim: 0xfff0b0 }));
|
||||
band.rotation.x = Math.PI / 2;
|
||||
group.add(core, band);
|
||||
const frames = [[Math.PI / 2, 0, 0], [0.25, Math.PI / 2, 0], [0.6, 0.3, Math.PI / 2]];
|
||||
const rails = [1.3, 1.65, 2.0].map((r, i) => {
|
||||
const frame = new THREE.Group(); frame.rotation.set(...frames[i]);
|
||||
const rail = new THREE.Group();
|
||||
rail.add(new THREE.Mesh(new THREE.TorusGeometry(r, 0.07, 4, 24), ps1({ color: i === 1 ? 0xc8963a : 0xe0b458, rim: 0xffe8a0 })));
|
||||
for (let g = 0; g < 4; g++) {
|
||||
const gate = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.2, 0.2), ps1({ color: g === 0 ? 0xfff3c0 : 0xffc450, emissive: true }));
|
||||
const a = g / 4 * Math.PI * 2 + i; gate.position.set(Math.cos(a) * r, Math.sin(a) * r, 0);
|
||||
rail.add(gate);
|
||||
}
|
||||
frame.add(rail); group.add(frame);
|
||||
return { frame, rail, speed: [0.35, -0.22, 0.15][i] };
|
||||
});
|
||||
return {
|
||||
group,
|
||||
update(t, k) {
|
||||
core.scale.setScalar(1 + Math.sin(t * 1.4) * 0.04); // heartbeat
|
||||
rails.forEach(({ rail, frame, speed }) => { rail.rotation.z = t * speed; frame.rotation.y += 0.0008; });
|
||||
band.material.uniforms.uRim.value.setHex(k > 0.5 ? 0xfff6d0 : 0xffe0a0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Soul: verdict polyhedron. Translucent facets, white oath core, blue confidence edges, red risk seam.
|
||||
export function soul() {
|
||||
const group = new THREE.Group();
|
||||
const solid = new THREE.OctahedronGeometry(1, 0); solid.scale(0.9, 1.3, 0.9);
|
||||
const coreMesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.3, 0), ps1({ color: 0xffffff, emissive: true }));
|
||||
const facets = new THREE.Mesh(flat(solid.clone()), ps1({ color: 0x9fbfe0, rim: 0xcfe8ff, alpha: 0.5, side: THREE.DoubleSide }));
|
||||
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(solid), ps1({ color: 0x6fb6ff, emissive: true }));
|
||||
const seam = new THREE.Group();
|
||||
for (const dy of [-0.03, 0.03]) seam.add(lines([V(0.93, dy, 0), V(0, dy, 0.93), V(-0.93, dy, 0), V(0, dy, -0.93), V(0.93, dy, 0)], 0xff3b3b));
|
||||
group.add(coreMesh, facets, edges, seam);
|
||||
return {
|
||||
group,
|
||||
update(t, k) {
|
||||
group.rotation.y = t * 0.25;
|
||||
seam.scale.y = 1 + k * 6; // risk tightens the seam band as Soul takes the stage
|
||||
coreMesh.scale.setScalar(1 + Math.sin(t * 3) * 0.08 * k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Hands: dark tool-seed with a three-jaw chuck field and a hot forward seam.
|
||||
export function hands() {
|
||||
const group = new THREE.Group();
|
||||
const tool = new THREE.Group(); tool.rotation.z = -Math.PI / 2 * 0.85; group.add(tool); // leans along its work direction
|
||||
tool.add(new THREE.Mesh(lathe([[0, -1.1], [0.25, -1.0], [0.42, -0.6], [0.46, 0], [0.4, 0.5], [0.24, 0.9], [0.05, 1.12]], 7), ps1({ color: 0x24272d, rim: 0x7a8699 })));
|
||||
const edge = new THREE.Mesh(new THREE.TorusGeometry(0.16, 0.05, 3, 8), ps1({ color: 0xff6a1a, emissive: true }));
|
||||
edge.position.y = 1.12; edge.rotation.x = Math.PI / 2; tool.add(edge);
|
||||
const enamel = ps1({ color: 0xc9824a, rim: 0xffd0a0 });
|
||||
const jaws = [0, 1, 2].map(i => {
|
||||
const around = new THREE.Group(); around.rotation.y = i * Math.PI * 2 / 3; tool.add(around);
|
||||
const jaw = new THREE.Group(); jaw.position.set(0.42, 0.35, 0); around.add(jaw);
|
||||
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.9, 0.24), enamel); arm.position.y = 0.45; jaw.add(arm);
|
||||
const tip = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.4, 0.2), enamel); tip.position.set(-0.12, 0.95, 0); tip.rotation.z = 0.7; jaw.add(tip);
|
||||
return jaw;
|
||||
});
|
||||
const sparks = [...Array(6)].map(() => {
|
||||
const s = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.06), ps1({ color: 0xffd08a, emissive: true }));
|
||||
tool.add(s); return s;
|
||||
});
|
||||
return {
|
||||
group,
|
||||
update(t, k) {
|
||||
const open = 0.15 + 0.35 * (0.5 + 0.5 * Math.sin(t * 2.2)) * (0.4 + 0.6 * k);
|
||||
jaws.forEach(j => { j.rotation.z = -open; });
|
||||
sparks.forEach((s, i) => { // changed-file receipts: quick flecks at the cutting seam that fade
|
||||
const ph = (t * 1.7 + i / 6) % 1;
|
||||
s.visible = k > 0.2 && ph < 0.6;
|
||||
s.position.set(Math.sin(i * 7.1 + t * 3) * 0.35 * ph, 1.2 + ph * 0.6, Math.cos(i * 3.3 + t * 2) * 0.35 * ph);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Imagination: opal seed wrapped by six curling translucent ribbon-sheets with bright rims.
|
||||
export function imagination() {
|
||||
const group = new THREE.Group();
|
||||
const seed = new THREE.Mesh(lathe([[0, -0.55], [0.3, -0.45], [0.42, -0.1], [0.38, 0.3], [0.22, 0.6], [0, 0.72]], 8), ps1({ color: 0xf4e8ff, rim: 0x9ff4ff }));
|
||||
const calyx = new THREE.Mesh(new THREE.ConeGeometry(0.4, 0.45, 6), ps1({ color: 0x3f8f86 }));
|
||||
calyx.rotation.x = Math.PI; calyx.position.y = -0.6;
|
||||
group.add(seed, calyx);
|
||||
|
||||
const N = 14, ribbons = [];
|
||||
const cyan = new THREE.Color(0x7fe8ff), pink = new THREE.Color(0xf0a8ff), gold = new THREE.Color(0xffd27a);
|
||||
for (let k = 0; k < 6; k++) {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const pos = new Float32Array((N + 1) * 2 * 3), col = [], idx = [];
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const s = i / N, c = cyan.clone().lerp(s < 0.5 ? pink : gold, Math.abs(s - 0.5) * 2);
|
||||
col.push(c.r, c.g, c.b, c.r * 0.8, c.g * 0.8, c.b);
|
||||
if (i < N) { const a = i * 2; idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }
|
||||
}
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
|
||||
geo.setIndex(idx);
|
||||
const sheet = new THREE.Mesh(geo, ps1({ vcolor: true, emissive: true, alpha: 0.5, side: THREE.DoubleSide }));
|
||||
const rimGeo = new THREE.BufferGeometry(); rimGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 4 * 3), 3));
|
||||
const rim = new THREE.LineSegments(rimGeo, ps1({ color: 0xcffcff, emissive: true }));
|
||||
group.add(sheet, rim);
|
||||
ribbons.push({ geo, rimGeo, phi: k * Math.PI / 3, sign: k % 2 ? 1 : -1 });
|
||||
}
|
||||
function write(open) {
|
||||
const c = new THREE.Vector3(), side = new THREE.Vector3(), up = V(0, 1, 0);
|
||||
for (const r of ribbons) {
|
||||
const p = r.geo.attributes.position.array, e = r.rimGeo.attributes.position.array;
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const s = i / N;
|
||||
const rad = 0.3 + 1.5 * open * s * (1 - 0.6 * s * s); // flare out, then curl back in
|
||||
const ang = r.phi + r.sign * 0.9 * s; // twist around the seed
|
||||
c.set(Math.cos(ang) * rad, -0.35 + 2.1 * s, Math.sin(ang) * rad);
|
||||
const tang = V(-Math.sin(ang), 0, Math.cos(ang));
|
||||
const curl = r.sign * 1.3 * s; // sheet normal rolls along the blade
|
||||
side.copy(tang).multiplyScalar(Math.cos(curl)).addScaledVector(up, Math.sin(curl));
|
||||
const w = (0.06 + 0.5 * Math.pow(Math.sin(Math.PI * s), 0.8)) * 0.5;
|
||||
p.set([c.x + side.x * w, c.y + side.y * w, c.z + side.z * w, c.x - side.x * w, c.y - side.y * w, c.z - side.z * w], i * 6);
|
||||
}
|
||||
for (let i = 0; i < N; i++) for (let edge = 0; edge < 2; edge++) {
|
||||
const a = (i * 2 + edge) * 3, b = ((i + 1) * 2 + edge) * 3, o = (i * 2 + edge) * 6;
|
||||
e.set([p[a], p[a + 1], p[a + 2], p[b], p[b + 1], p[b + 2]], o);
|
||||
}
|
||||
r.geo.attributes.position.needsUpdate = true; r.rimGeo.attributes.position.needsUpdate = true;
|
||||
r.geo.computeBoundingSphere(); r.rimGeo.computeBoundingSphere();
|
||||
}
|
||||
}
|
||||
const filaments = [0, 1, 2, 3].map(i => {
|
||||
const a = i * 1.7, pts = [0, 1, 2, 3, 4].map(j => V(Math.cos(a) * 0.08 * j, 0.7 + j * 0.35, Math.sin(a) * 0.08 * j));
|
||||
group.add(lines(pts, 0xffc861));
|
||||
const bead = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.1, 0.1), ps1({ color: 0xfff0b0, emissive: true }));
|
||||
group.add(bead); return { bead, pts };
|
||||
});
|
||||
let lastOpen = -1;
|
||||
return {
|
||||
group,
|
||||
update(t, k) {
|
||||
const open = 0.55 + 0.45 * k; // planning readiness opens the bloom
|
||||
if (Math.abs(open - lastOpen) > 0.004) { write(open); lastOpen = open; }
|
||||
group.rotation.y = t * 0.12;
|
||||
filaments.forEach(({ bead, pts }, i) => {
|
||||
const ph = (t * 0.4 + i / 4) % 1, j = ph * 4, a = pts[Math.floor(j)], b = pts[Math.min(4, Math.floor(j) + 1)];
|
||||
bead.position.lerpVectors(a, b, j % 1);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Life: memory nautilus seed. Teal shell on a logarithmic spiral, warm ember, beads on the same spiral.
|
||||
export function life() {
|
||||
const group = new THREE.Group();
|
||||
const shell = new THREE.Group(); shell.rotation.x = -0.25; group.add(shell);
|
||||
const TMAX = 3.2 * Math.PI, rho = (t) => 0.12 * Math.exp(0.18 * t);
|
||||
const P = (t) => V(rho(t) * Math.cos(t), rho(t) * Math.sin(t), 0);
|
||||
const ring = 7, steps = 44, pos = [], idx = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps * TMAX, c = P(t), tr = rho(t) * 0.62;
|
||||
const outward = V(Math.cos(t), Math.sin(t), 0);
|
||||
for (let j = 0; j < ring; j++) {
|
||||
const a = j / ring * Math.PI * 2;
|
||||
const v = c.clone().addScaledVector(outward, Math.cos(a) * tr).add(V(0, 0, Math.sin(a) * tr));
|
||||
pos.push(v.x, v.y, v.z);
|
||||
}
|
||||
if (i < steps) for (let j = 0; j < ring; j++) {
|
||||
const a = i * ring + j, b = i * ring + (j + 1) % ring;
|
||||
idx.push(a, b, a + ring, b, b + ring, a + ring);
|
||||
}
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setIndex(idx); geo.computeVertexNormals();
|
||||
shell.add(new THREE.Mesh(geo, ps1({ color: 0x2f9e9a, rim: 0x9ff5ea, side: THREE.DoubleSide })));
|
||||
const seamOf = (t) => P(t).addScaledVector(V(Math.cos(t), Math.sin(t), 0), rho(t) * 0.66);
|
||||
shell.add(lines([...Array(61)].map((_, i) => seamOf(i / 60 * TMAX)), 0xd8f5ee));
|
||||
const ember = new THREE.Mesh(new THREE.IcosahedronGeometry(0.17, 0), ps1({ color: 0xffa24a, emissive: true }));
|
||||
ember.position.copy(P(TMAX)).multiplyScalar(0.8); shell.add(ember);
|
||||
const beads = [...Array(7)].map(() => {
|
||||
const b = new THREE.Mesh(new THREE.OctahedronGeometry(0.07, 0), ps1({ color: 0xf0ead8, emissive: true }));
|
||||
shell.add(b); return b;
|
||||
});
|
||||
shell.scale.setScalar(1.35);
|
||||
return {
|
||||
group,
|
||||
update(t, k) {
|
||||
group.rotation.y = Math.sin(t * 0.2) * 0.5;
|
||||
ember.scale.setScalar(1 + Math.sin(t * 2) * 0.15);
|
||||
beads.forEach((b, i) => { b.position.copy(seamOf(((t * 0.05 + i / 7) % 1) * TMAX)).multiplyScalar(1.08); });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Epiphany-only organs (Face, Eyes, Body): lower detail, they live in her chamber ----------
|
||||
|
||||
// Face: speech lantern. Translucent bell, warm throat, pearls around the lip.
|
||||
export function face() {
|
||||
const group = new THREE.Group();
|
||||
group.add(new THREE.Mesh(lathe([[0, 1], [0.45, 0.85], [0.7, 0.4], [0.8, 0], [0.95, -0.3], [0.7, -0.36]], 9), ps1({ color: 0xf1d2b0, rim: 0xffe4c0, alpha: 0.6, side: THREE.DoubleSide })));
|
||||
const throat = new THREE.Mesh(new THREE.CircleGeometry(0.5, 8), ps1({ color: 0xffa860, emissive: true }));
|
||||
throat.rotation.x = Math.PI / 2; throat.position.y = -0.1; group.add(throat);
|
||||
const pearls = [...Array(5)].map(() => { const p = new THREE.Mesh(new THREE.OctahedronGeometry(0.08, 0), ps1({ color: 0xfff2dc, emissive: true })); group.add(p); return p; });
|
||||
return { group, update(t) { pearls.forEach((p, i) => { const a = t * 0.6 + i * 1.26, r = 0.9 + 0.25 * Math.sin(t + i); p.position.set(Math.cos(a) * r, -0.4 + 0.1 * Math.sin(t * 2 + i), Math.sin(a) * r); }); } };
|
||||
}
|
||||
|
||||
// Eyes: evidence astrolabe. Black glass lens, blue-metal aperture ring, proof meridians, cyan sparks.
|
||||
export function eyes() {
|
||||
const group = new THREE.Group();
|
||||
const lens = new THREE.Mesh(new THREE.SphereGeometry(0.7, 8, 6), ps1({ color: 0x07090c, rim: 0x5ff2ff })); lens.scale.z = 0.45; group.add(lens);
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.82, 0.09, 4, 16), ps1({ color: 0x4a6a8f, rim: 0x9fc8ff })); group.add(ring);
|
||||
const meridians = [0, 1].map(i => { const m = lines(circlePts(0.98, 20), 0xcfe2f0); m.rotation.x = Math.PI / 2 * i; group.add(m); return m; });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const s = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.07), ps1({ color: 0x6ff7ff, emissive: true }));
|
||||
const a = i * 1.3; s.position.set(Math.cos(a) * 0.5, Math.sin(a) * 0.5, 0.3); group.add(s);
|
||||
}
|
||||
return { group, update(t) { meridians.forEach((m, i) => { m.rotation.y = t * (0.3 + i * 0.2); }); ring.rotation.z = t * 0.1; } };
|
||||
}
|
||||
|
||||
// Body: load-bearing graph stone. Soft geode mass, enamel stress ribs, checkpoint pearls.
|
||||
export function body() {
|
||||
const group = new THREE.Group();
|
||||
const stone = new THREE.Mesh(flat(new THREE.IcosahedronGeometry(0.95, 1)), ps1({ color: 0x22332a, rim: 0x5b8f74 }));
|
||||
stone.scale.y = 0.72; group.add(stone);
|
||||
const ribs = new THREE.Group(); ribs.scale.set(1.02, 0.74, 1.02); group.add(ribs);
|
||||
ribs.add(lines(circlePts(0.97, 20), 0x9fe0bf));
|
||||
for (const r of [0, 0.9, -0.9]) { const m = lines(circlePts(0.97, 20), 0x9fe0bf); m.rotation.set(Math.PI / 2, r, 0); ribs.add(m); }
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const p = new THREE.Mesh(new THREE.OctahedronGeometry(0.09, 0), ps1({ color: 0xfff4d6, emissive: true }));
|
||||
const a = i / 6 * Math.PI * 2; p.position.set(Math.cos(a) * 0.99, 0, Math.sin(a) * 0.99); ribs.add(p);
|
||||
}
|
||||
return { group, update(t) { stone.scale.y = 0.72 + Math.sin(t * 0.9) * 0.02; group.rotation.y = t * 0.05; } };
|
||||
}
|
||||
|
||||
// Scaffolding: Epiphany is still growing bones.
|
||||
export function scaffold(w, h) {
|
||||
const x = w / 2, pts = [];
|
||||
const c = [V(-x, 0, -x), V(x, 0, -x), V(x, 0, x), V(-x, 0, x)];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const a = c[i], b = c[(i + 1) % 4];
|
||||
for (const y of [0, h * 0.5, h]) pts.push(a.clone().setY(y), b.clone().setY(y));
|
||||
pts.push(a.clone(), a.clone().setY(h), a.clone(), b.clone().setY(h * 0.5)); // uprights and braces
|
||||
}
|
||||
return segs(pts, 0x3c4a58);
|
||||
}
|
||||
|
||||
// Aquarium orbit slots: angle (deg) and ring fraction around Self.
|
||||
export const SLOTS = {
|
||||
imagination: [135, 0.78], eyes: [35, 0.86], body: [215, 0.66], hands: [270, 0.82], soul: [325, 0.78], life: [80, 0.62],
|
||||
};
|
||||
/** Aquarium's XY grid mapped onto world XZ: +Y (grid forward) points away from the viewer, toward -z. */
|
||||
export function slot(center, id, r) {
|
||||
const [deg, ring] = SLOTS[id], a = deg * Math.PI / 180;
|
||||
return V(center.x + Math.cos(a) * ring * r, center.y, center.z - Math.sin(a) * ring * r);
|
||||
}
|
||||
237
prototype/ps1.js
vendored
Normal file
237
prototype/ps1.js
vendored
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
// PS1 rendering kit: one material, the framebuffer dither, low-res textures, geometry helpers.
|
||||
import * as THREE from 'three';
|
||||
|
||||
THREE.ColorManagement.enabled = false; // raw sRGB values end to end; the PS1 had no colour management either
|
||||
|
||||
// Brand tokens, plus colours the brand does not define: the mascot's greens.
|
||||
// Organ colours come from Aquarium's visual language and live with the organs.
|
||||
export const C = {
|
||||
ground: 0x07111a, panel: 0x16212c, accent: 0xff8a2a,
|
||||
green: 0x4dff88, greenDim: 0x1f6a45,
|
||||
};
|
||||
|
||||
// Uniforms every PS1 material shares by reference.
|
||||
export const shared = {
|
||||
uRes: { value: new THREE.Vector2(320, 240) },
|
||||
uLightDir: { value: new THREE.Vector3() },
|
||||
uFogColor: { value: new THREE.Color(C.ground) },
|
||||
uFogNear: { value: 6 },
|
||||
uFogFar: { value: 38 },
|
||||
uTime: { value: 0 },
|
||||
uProbe: { value: 0 }, // Soul's probe phase in the lattice
|
||||
uEnd: { value: 0 }, // the return to the hall
|
||||
uAlt: { value: new THREE.Color(C.accent) },
|
||||
};
|
||||
|
||||
const VERT = /* glsl */`
|
||||
uniform vec2 uRes; uniform vec3 uLightDir; uniform float uFogNear, uFogFar, uFogMax; uniform vec3 uRim;
|
||||
varying vec2 vUv; varying float vW; varying vec3 vLight; varying float vFog; varying vec3 vCol; varying vec3 vRimC;
|
||||
#ifdef NODES
|
||||
attribute float aSeed; varying float vSeed;
|
||||
#endif
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
vec4 clip = projectionMatrix * mv;
|
||||
// PS1 GTE: no sub-pixel precision. Snap projected vertices to the low-res pixel grid.
|
||||
if (clip.w > 0.05) {
|
||||
vec2 h = uRes * 0.5;
|
||||
clip.xy = floor(clip.xy / clip.w * h + 0.5) / h * clip.w;
|
||||
}
|
||||
gl_Position = clip;
|
||||
#ifdef USE_TEX
|
||||
// Affine texturing: pre-multiply by w so the GPU's perspective correction cancels out.
|
||||
vUv = uv * clip.w; vW = clip.w;
|
||||
#endif
|
||||
#if !defined(EMISSIVE) || defined(RIM)
|
||||
vec3 n = normalize(normalMatrix * normal);
|
||||
#endif
|
||||
#ifdef EMISSIVE
|
||||
vLight = vec3(1.0);
|
||||
#else
|
||||
vLight = vec3(0.30, 0.34, 0.42) + vec3(0.95, 0.9, 0.82) * max(dot(n, uLightDir), 0.0); // per-vertex (Gouraud)
|
||||
#endif
|
||||
#ifdef RIM
|
||||
vRimC = uRim * pow(1.0 - abs(dot(n, normalize(-mv.xyz))), 2.0); // Fresnel, per vertex
|
||||
#else
|
||||
vRimC = vec3(0.0);
|
||||
#endif
|
||||
#ifdef USE_COLOR
|
||||
vCol = color;
|
||||
#else
|
||||
vCol = vec3(1.0);
|
||||
#endif
|
||||
vFog = min(smoothstep(uFogNear, uFogFar, -mv.z), uFogMax);
|
||||
#ifdef NODES
|
||||
vSeed = aSeed;
|
||||
#endif
|
||||
}`;
|
||||
|
||||
const FRAG = /* glsl */`
|
||||
uniform sampler2D uMap; uniform vec3 uColor, uFogColor, uAlt; uniform float uTime, uProbe, uEnd, uAlpha;
|
||||
varying vec2 vUv; varying float vW; varying vec3 vLight; varying float vFog; varying vec3 vCol; varying vec3 vRimC;
|
||||
#ifdef NODES
|
||||
varying float vSeed;
|
||||
#endif
|
||||
void main() {
|
||||
vec4 c = vec4(uColor * vCol, 1.0);
|
||||
#ifdef USE_TEX
|
||||
vec4 t = texture2D(uMap, vUv / vW);
|
||||
#ifdef ALPHA_TEST
|
||||
if (t.a < 0.5) discard;
|
||||
#endif
|
||||
c *= t;
|
||||
#endif
|
||||
#ifdef NODES
|
||||
// Soul's probes: as uProbe rises, nodes flip from green to accent and flicker.
|
||||
float hit = step(vSeed, uProbe);
|
||||
float fl = step(0.5, fract(sin(floor(uTime * 9.0) + vSeed * 91.7) * 43758.5));
|
||||
c.rgb = mix(c.rgb, uAlt, hit) * mix(1.0, 0.5 + 0.5 * fl, hit);
|
||||
#endif
|
||||
#ifdef CELLS
|
||||
// On the way back up, a scatter of the wall's green checks has been re-graded.
|
||||
vec2 cell = floor(vUv / vW);
|
||||
float h = fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
c.rgb = mix(c.rgb, uAlt * dot(c.rgb, vec3(0.3, 0.59, 0.11)) * 2.4, step(h, uEnd * 0.45));
|
||||
#endif
|
||||
c.rgb = c.rgb * vLight + vRimC;
|
||||
gl_FragColor = vec4(mix(c.rgb, uFogColor, vFog), uAlpha);
|
||||
}`;
|
||||
|
||||
/**
|
||||
* The one PS1 material. Options switch shader features by define.
|
||||
* alpha < 1 gives PS1-style semi-transparency (no depth write).
|
||||
*/
|
||||
export function ps1({
|
||||
map = null, color = 0xffffff, emissive = false, alphaTest = false, nodes = false, cells = false,
|
||||
rim = null, alpha = 1, vcolor = false, fogMax = 1, side = THREE.FrontSide,
|
||||
} = {}) {
|
||||
const defines = {};
|
||||
if (map) defines.USE_TEX = '';
|
||||
if (emissive) defines.EMISSIVE = '';
|
||||
if (alphaTest) defines.ALPHA_TEST = '';
|
||||
if (nodes) defines.NODES = '';
|
||||
if (cells) defines.CELLS = '';
|
||||
if (rim !== null) defines.RIM = '';
|
||||
return new THREE.ShaderMaterial({
|
||||
defines, side, vertexColors: vcolor, vertexShader: VERT, fragmentShader: FRAG,
|
||||
transparent: alpha < 1, depthWrite: alpha >= 1,
|
||||
uniforms: {
|
||||
...shared,
|
||||
uMap: { value: map }, uColor: { value: new THREE.Color(color) },
|
||||
uRim: { value: new THREE.Color(rim ?? 0) }, uAlpha: { value: alpha }, uFogMax: { value: fogMax },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** PS1 framebuffer: sky pass, world pass, then 15-bit colour with the console's 4x4 ordered dither. */
|
||||
export function framebuffer(renderer) {
|
||||
const rt = new THREE.WebGLRenderTarget(320, 240, { minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, generateMipmaps: false });
|
||||
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({
|
||||
uniforms: { tScene: { value: rt.texture } },
|
||||
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
|
||||
fragmentShader: /* glsl */`
|
||||
uniform sampler2D tScene; varying vec2 vUv;
|
||||
const mat4 D = mat4(-4.,0.,-3.,1., 2.,-2.,3.,-1., -3.,1.,-4.,0., 3.,-1.,2.,-2.);
|
||||
void main() {
|
||||
vec3 c = texture2D(tScene, vUv).rgb * 255.0;
|
||||
ivec2 p = ivec2(mod(gl_FragCoord.xy, 4.0));
|
||||
c = clamp(c + D[p.x][p.y], 0.0, 255.0);
|
||||
gl_FragColor = vec4(floor(c / 8.0) * 8.0 / 255.0, 1.0);
|
||||
}`,
|
||||
depthTest: false, depthWrite: false,
|
||||
}));
|
||||
const postScene = new THREE.Scene(); postScene.add(quad);
|
||||
const postCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
renderer.setClearColor(C.ground, 1);
|
||||
return {
|
||||
setSize(w, h) { rt.setSize(w, h); },
|
||||
render(sky, skyCam, scene, camera) {
|
||||
renderer.setRenderTarget(rt);
|
||||
renderer.autoClear = true; renderer.render(sky, skyCam);
|
||||
renderer.autoClear = false; renderer.clearDepth(); renderer.render(scene, camera);
|
||||
renderer.autoClear = true;
|
||||
renderer.setRenderTarget(null); renderer.render(postScene, postCam);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- textures ----------
|
||||
export function rng(seed) { return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
|
||||
|
||||
export function canvas(w, h, draw) {
|
||||
const c = document.createElement('canvas'); c.width = w; c.height = h;
|
||||
const g = c.getContext('2d'); g.imageSmoothingEnabled = false; draw(g, w, h);
|
||||
return c;
|
||||
}
|
||||
export function texOf(c, repeat = false) {
|
||||
const t = new THREE.CanvasTexture(c);
|
||||
t.magFilter = t.minFilter = THREE.NearestFilter; t.generateMipmaps = false; // the PS1 had no mipmaps
|
||||
if (repeat) t.wrapS = t.wrapT = THREE.RepeatWrapping;
|
||||
return t;
|
||||
}
|
||||
export const tex = (w, h, draw, repeat = false) => texOf(canvas(w, h, draw), repeat);
|
||||
|
||||
export function noise(g, w, h, base, spread, seed) {
|
||||
const r = rng(seed); g.fillStyle = base; g.fillRect(0, 0, w, h);
|
||||
for (let i = 0; i < w * h / 3; i++) {
|
||||
const v = (r() - 0.5) * spread; g.fillStyle = v > 0 ? `rgba(255,255,255,${v})` : `rgba(0,0,0,${-v})`;
|
||||
g.fillRect(r() * w | 0, r() * h | 0, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- geometry ----------
|
||||
/** Flat-shaded copy: the PS1 look for faceted solids. */
|
||||
export function flat(geo) { const g = geo.index ? geo.toNonIndexed() : geo; g.computeVertexNormals(); return g; }
|
||||
|
||||
/** Floor of square tiles at height y. Each tile gets its own 0..1 UVs, which keeps affine warp per tile. */
|
||||
export function tiles(x0, z0, nx, nz, size, y, keep = () => true) {
|
||||
const pos = [], nor = [], uv = [], idx = [];
|
||||
for (let i = 0; i < nx; i++) for (let j = 0; j < nz; j++) {
|
||||
const x = x0 + i * size, z = z0 + j * size;
|
||||
if (!keep(x, z)) continue;
|
||||
const b = pos.length / 3;
|
||||
pos.push(x, y, z, x + size, y, z, x + size, y, z + size, x, y, z + size);
|
||||
nor.push(0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0);
|
||||
uv.push(0, 0, 1, 0, 1, 1, 0, 1);
|
||||
idx.push(b, b + 2, b + 1, b, b + 3, b + 2);
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
|
||||
geo.setIndex(idx);
|
||||
return geo;
|
||||
}
|
||||
|
||||
/** Bake many (geometry, matrix) pairs into one draw call. Keeps position, normal and uv. */
|
||||
export function merge(parts) {
|
||||
const out = { position: [], normal: [], uv: [] };
|
||||
const m3 = new THREE.Matrix3(), v = new THREE.Vector3();
|
||||
for (const [geo, mat] of parts) {
|
||||
const g = geo.index ? geo.toNonIndexed() : geo;
|
||||
m3.getNormalMatrix(mat);
|
||||
const p = g.attributes.position, n = g.attributes.normal, t = g.attributes.uv;
|
||||
for (let i = 0; i < p.count; i++) {
|
||||
v.fromBufferAttribute(p, i).applyMatrix4(mat); out.position.push(v.x, v.y, v.z);
|
||||
v.fromBufferAttribute(n, i).applyMatrix3(m3).normalize(); out.normal.push(v.x, v.y, v.z);
|
||||
out.uv.push(t ? t.getX(i) : 0, t ? t.getY(i) : 0);
|
||||
}
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(out.position, 3));
|
||||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(out.normal, 3));
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(out.uv, 2));
|
||||
return geo;
|
||||
}
|
||||
|
||||
export const mat4 = (x, y, z, ry = 0, rx = 0, rz = 0, s = 1) =>
|
||||
new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(s, s, s));
|
||||
|
||||
/** Line strip geometry from points (for LineSegments pairs, pass pairs). */
|
||||
export const lineGeo = (pts) => new THREE.BufferGeometry().setFromPoints(pts);
|
||||
|
||||
export function circlePts(r, n, y = 0) {
|
||||
const pts = [];
|
||||
for (let i = 0; i <= n; i++) { const a = i / n * Math.PI * 2; pts.push(new THREE.Vector3(Math.cos(a) * r, y, Math.sin(a) * r)); }
|
||||
return pts;
|
||||
}
|
||||
70
prototype/style.css
Normal file
70
prototype/style.css
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/* GameCult brand tokens (source: gamecult-site quartz.config.ts). Single dark theme by decision. */
|
||||
:root {
|
||||
--ground: #07111a;
|
||||
--panel: #16212c;
|
||||
--text: #b7c7d9;
|
||||
--heading: #eef5ff;
|
||||
--accent: #ff8a2a;
|
||||
--link: #59b7ff;
|
||||
--title: 'Montserrat', sans-serif;
|
||||
--prose: 'Ubuntu', sans-serif;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; background: var(--ground); color: var(--text); }
|
||||
body { font: 300 1.125rem/1.65 var(--prose); overflow-x: hidden; }
|
||||
|
||||
#scene {
|
||||
position: fixed; inset: 0; width: 100vw; height: 100vh;
|
||||
image-rendering: pixelated; z-index: 0; display: block;
|
||||
}
|
||||
main { position: relative; z-index: 1; }
|
||||
|
||||
h1, h2 { font-family: var(--title); color: var(--heading); margin: 0 0 .5em; }
|
||||
h1 { font-weight: 100; font-size: clamp(3.5rem, 12vw, 9rem); letter-spacing: .04em; line-height: 1; }
|
||||
h2 { font-weight: 200; font-size: clamp(1.6rem, 3.4vw, 2.6rem); line-height: 1.15; }
|
||||
a { color: var(--link); }
|
||||
em { font-style: normal; color: var(--accent); }
|
||||
|
||||
.label {
|
||||
font: 400 .75rem/1 var(--mono); text-transform: uppercase; letter-spacing: .22em;
|
||||
color: var(--accent); margin: 0 0 1.2em;
|
||||
}
|
||||
|
||||
section { min-height: 100vh; display: flex; align-items: center; padding: 10vh 16px; }
|
||||
section.right { justify-content: flex-end; }
|
||||
.panel {
|
||||
max-width: 34rem; padding: 1.75rem 2rem; margin: 0 max(0px, 6vw);
|
||||
background: rgba(22, 33, 44, .86); border-left: 2px solid var(--accent);
|
||||
}
|
||||
.panel.bare { background: none; border: 0; }
|
||||
.hero .sub { font-family: var(--title); font-weight: 200; font-size: clamp(1.2rem, 2.6vw, 1.8rem); color: var(--heading); margin: 0; }
|
||||
.hero .panel, .hero .sub { text-shadow: 0 0 12px var(--ground), 0 0 3px var(--ground); }
|
||||
|
||||
.line {
|
||||
font: 200 clamp(1.8rem, 5vw, 3.8rem)/1.15 var(--title); color: var(--heading);
|
||||
margin: 0 0 .6em; max-width: 22ch;
|
||||
text-shadow: 0 0 18px var(--ground), 0 0 4px var(--ground);
|
||||
}
|
||||
.staccato .stage { width: 100%; padding: 0 max(0px, 6vw); }
|
||||
|
||||
/* With motion: the section becomes a scroll track, the stage pins, lines take turns. */
|
||||
.motion .staccato { display: block; padding: 0; height: calc(var(--n) * 70vh + 100vh); }
|
||||
.motion .staccato .stage { position: sticky; top: 0; height: 100vh; display: grid; align-items: center; }
|
||||
.motion .staccato .line { grid-area: 1 / 1; margin: 0; opacity: 0; will-change: transform, opacity; }
|
||||
|
||||
#probe {
|
||||
position: fixed; right: 8px; bottom: 8px; z-index: 5; white-space: pre;
|
||||
font: 400 11px/1.4 var(--mono); color: var(--heading); background: rgba(7,17,26,.85);
|
||||
padding: 6px 8px; border: 1px solid var(--panel);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.panel { padding: 1.25rem 1.25rem; margin: 0; }
|
||||
}
|
||||
|
||||
code { font: 400 .92em var(--mono); color: var(--heading); }
|
||||
pre { margin: 0 0 1em; padding: .8rem 1rem; background: var(--ground); border: 1px solid #22303e; overflow-x: auto; }
|
||||
pre code { font-size: .8rem; white-space: pre; }
|
||||
.fine { font-size: .92rem; opacity: .85; }
|
||||
21
prototype/vendor/three.LICENSE
vendored
Normal file
21
prototype/vendor/three.LICENSE
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License
|
||||
|
||||
Copyright © 2010-2026 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
60586
prototype/vendor/three.core.js
vendored
Normal file
60586
prototype/vendor/three.core.js
vendored
Normal file
File diff suppressed because one or more lines are too long
19719
prototype/vendor/three.module.js
vendored
Normal file
19719
prototype/vendor/three.module.js
vendored
Normal file
File diff suppressed because one or more lines are too long
411
prototype/world.js
vendored
Normal file
411
prototype/world.js
vendored
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// The world the camera flies through, and the path it flies along.
|
||||
// Route: hall -> hatch + shaft -> Eureka's organ chamber -> lattice -> specimen gallery
|
||||
// -> archive -> Epiphany's chamber -> second shaft -> back to the hall.
|
||||
import * as THREE from 'three';
|
||||
import { C, ps1, rng, canvas, texOf, tex, noise, flat, tiles, merge, mat4, lineGeo, circlePts } from './ps1.js';
|
||||
import * as O from './organs.js';
|
||||
|
||||
const V = (x, y, z) => new THREE.Vector3(x, y, z);
|
||||
|
||||
// ---------- textures ----------
|
||||
const tabletCanvas = canvas(80, 96, (g, w, h) => {
|
||||
noise(g, w, h, '#3a4757', 0.25, 3);
|
||||
g.textAlign = 'center'; g.font = 'bold 17px Georgia, serif';
|
||||
[['ALL', 34], ['TESTS', 54], ['PASSING', 74]].forEach(([s, y]) => {
|
||||
g.fillStyle = '#56657a'; g.fillText(s, w / 2 + 1, y + 1);
|
||||
g.fillStyle = '#141c26'; g.fillText(s, w / 2, y);
|
||||
});
|
||||
g.strokeStyle = '#05090d'; g.lineWidth = 2; g.beginPath();
|
||||
[[42, 0], [38, 14], [43, 26], [37, 40], [42, 55], [36, 70], [41, 84], [39, 96]].forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y));
|
||||
g.stroke();
|
||||
});
|
||||
const half = (x0) => texOf(canvas(40, 96, (g) => g.drawImage(tabletCanvas, x0, 0, 40, 96, 0, 0, 40, 96)));
|
||||
|
||||
const T = {
|
||||
stone: tex(32, 32, (g, w, h) => noise(g, w, h, '#2b3746', 0.25, 1), true),
|
||||
tile: tex(32, 32, (g, w, h) => { noise(g, w, h, '#1b2632', 0.22, 2); g.fillStyle = '#0d151d'; g.fillRect(0, 0, w, 1); g.fillRect(0, 0, 1, h); }, true),
|
||||
grid: tex(32, 32, (g, w, h) => { noise(g, w, h, '#0e1a20', 0.18, 9); g.fillStyle = '#1b4a48'; g.fillRect(0, 0, w, 1); g.fillRect(0, 0, 1, h); }, true),
|
||||
tabletL: half(0), tabletR: half(40),
|
||||
checks: tex(32, 32, (g, w, h) => {
|
||||
g.fillStyle = '#081510'; g.fillRect(0, 0, w, h); g.fillStyle = '#12301f'; g.fillRect(3, 3, w - 6, h - 6);
|
||||
g.strokeStyle = '#4dff88'; g.lineWidth = 3; g.beginPath(); g.moveTo(9, 17); g.lineTo(14, 22); g.lineTo(24, 10); g.stroke();
|
||||
}, true),
|
||||
hatch: tex(64, 64, (g, w, h) => {
|
||||
noise(g, w, h, '#3c4756', 0.3, 4);
|
||||
g.fillStyle = '#1a222c'; [[4, 4], [56, 4], [4, 56], [56, 56]].forEach(([x, y]) => g.fillRect(x, y, 4, 4));
|
||||
g.fillStyle = '#3fd77a'; g.font = 'bold 15px monospace'; g.textAlign = 'center'; g.fillText('GREEN', 32, 37);
|
||||
}),
|
||||
shaft: tex(32, 32, (g, w, h) => { noise(g, w, h, '#0f1f19', 0.25, 5); g.fillStyle = '#2c4a3a'; g.fillRect(6, 14, 20, 2); }, true),
|
||||
cabinet: tex(96, 128, (g, w, h) => {
|
||||
noise(g, w, h, '#1a1410', 0.2, 6); g.strokeStyle = '#5a4130'; g.lineWidth = 2;
|
||||
for (let i = 0; i <= 3; i++) { g.beginPath(); g.moveTo(i * 32, 0); g.lineTo(i * 32, h); g.stroke(); }
|
||||
for (let j = 0; j <= 4; j++) { g.beginPath(); g.moveTo(0, j * 32); g.lineTo(w, j * 32); g.stroke(); }
|
||||
g.fillStyle = '#b8a888'; for (let i = 0; i < 3; i++) for (let j = 0; j < 4; j++) g.fillRect(i * 32 + 9, j * 32 + 26, 14, 3);
|
||||
}),
|
||||
wood: tex(32, 32, (g, w, h) => { noise(g, w, h, '#3a291c', 0.2, 12); g.fillStyle = '#2a1d14'; for (let y = 3; y < h; y += 7) g.fillRect(0, y, w, 1); }, true),
|
||||
slab: tex(16, 32, (g, w, h) => { noise(g, w, h, '#4a5666', 0.25, 13); g.fillStyle = '#232c38'; for (let y = 6; y < 28; y += 4) g.fillRect(3, y, 10 - (y % 3) * 2, 1); }),
|
||||
survived: tex(64, 12, (g, w) => { g.fillStyle = '#1a0f07'; g.fillRect(0, 0, w, 12); g.fillStyle = '#ff8a2a'; g.font = 'bold 9px monospace'; g.textAlign = 'center'; g.fillText('SURVIVED', w / 2, 9); }),
|
||||
};
|
||||
|
||||
function bugTex(seed, bodyCol, legCol) {
|
||||
const r = rng(seed);
|
||||
return tex(32, 32, (g) => {
|
||||
const len = 6 + r() * 4, wid = 3 + r() * 2.5, legs = r() < 0.3 ? 4 : 3, spread = 8 + r() * 5;
|
||||
g.strokeStyle = legCol; g.lineWidth = 1;
|
||||
for (let k = 0; k < legs; k++) for (const s of [-1, 1]) {
|
||||
const y = 16 - len * 0.5 + (k + 0.5) * len / legs;
|
||||
g.beginPath(); g.moveTo(16, y); g.lineTo(16 + s * spread * 0.7, y + (y - 16) * 0.6 - 2); g.lineTo(16 + s * spread, y + (y - 16)); g.stroke();
|
||||
}
|
||||
g.beginPath(); g.moveTo(15, 16 - len); g.lineTo(11 - r() * 3, 3); g.moveTo(17, 16 - len); g.lineTo(21 + r() * 3, 3); g.stroke();
|
||||
if (r() < 0.4) { g.fillStyle = 'rgba(190,220,240,0.55)'; g.beginPath(); g.ellipse(11, 17, 5, 8, -0.4, 0, 7); g.ellipse(21, 17, 5, 8, 0.4, 0, 7); g.fill(); }
|
||||
g.fillStyle = bodyCol; g.beginPath(); g.ellipse(16, 17, wid, len, 0, 0, 7); g.fill();
|
||||
g.fillStyle = '#d6e0ea'; g.fillRect(15, 17 - len * 0.6, 2, len * 0.5);
|
||||
});
|
||||
}
|
||||
function labelTex(a, b) {
|
||||
return tex(128, 24, (g, w) => {
|
||||
g.fillStyle = '#1b1712'; g.fillRect(0, 0, w, 24); g.fillStyle = '#c9b48a'; g.fillRect(0, 0, w, 1); g.fillRect(0, 23, w, 1);
|
||||
g.font = 'bold 9px monospace'; g.fillStyle = '#e9dcc0'; g.fillText(a, 4, 10); g.fillStyle = '#ff8a2a'; g.fillText(b, 4, 20);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- the route: stops pin page sections; vias shape the flight between them ----------
|
||||
const OC = V(0, -17, -5); // Eureka's organ chamber, Self at the centre
|
||||
const EC = V(20, -49, 4); // Epiphany's chamber
|
||||
const R_EUREKA = 6, R_EPIPHANY = 9, ORGAN_Y = -16.8;
|
||||
const orbit = (c, deg, r, y) => { const a = deg * Math.PI / 180; return V(c.x + Math.cos(a) * r, y, c.z - Math.sin(a) * r); };
|
||||
const org = Object.fromEntries(['imagination', 'hands', 'soul', 'life'].map(id => [id, O.slot(OC, id, R_EUREKA).setY(ORGAN_Y)]));
|
||||
const up = (v, d = 0.3) => v.clone().setY(v.y + d);
|
||||
|
||||
export const ROUTE = [
|
||||
{ id: 'hero', p: V(5.5, 4, 13), l: V(-2.8, 2.5, 0) },
|
||||
{ id: 'reassuring', p: V(3.2, 2.4, 8.8), l: V(0, 0.6, 4) },
|
||||
{ id: 'green', p: V(-0.4, 4.2, 4.9), l: V(-0.4, -6, 3.7) },
|
||||
{ id: 'epiphany', p: V(-0.4, -3, 4.1), l: V(-0.3, -13, 3.4) },
|
||||
{ id: 'sister', p: V(-0.4, -9, 4.0), l: V(-0.2, -18, 3.0) },
|
||||
{ id: 'faculties', p: V(-0.3, -12.4, 4.4), l: V(0, -17.5, -4) },
|
||||
{ p: orbit(OC, 200, 8.2, -15.5), l: V(-2, -16.8, -6) },
|
||||
{ id: 'imagination', p: orbit(OC, 135, 7.8, -15.8), l: up(org.imagination) },
|
||||
{ p: orbit(OC, 190, 8.4, -15.8), l: OC.clone() },
|
||||
{ id: 'hands', p: orbit(OC, 250, 7.5, -15.6), l: org.hands },
|
||||
{ id: 'soul', p: orbit(OC, 330, 7.5, -15.6), l: org.soul },
|
||||
{ id: 'self', p: orbit(OC, 20, 3.3, -14.6), l: up(OC, 0.2) },
|
||||
{ id: 'life', p: orbit(OC, 80, 7, -15.8), l: org.life },
|
||||
{ p: orbit(OC, 15, 8.6, -15), l: OC.clone() },
|
||||
{ id: 'operator', p: V(2.2, -14.3, 5.5), l: V(0, -16.6, -5) },
|
||||
{ p: V(9.5, -15, -13), l: V(3, -26, -22) },
|
||||
{ id: 'prompts', p: V(5, -24, -17), l: V(0, -29, -26) },
|
||||
{ id: 'mutation', p: V(-4, -29.5, -23.5), l: V(1, -32, -31) },
|
||||
{ p: V(0, -35, -33), l: V(-4, -37, -44) },
|
||||
{ id: 'campaign', p: V(3, -37, -45), l: V(-3.4, -37.4, -45) },
|
||||
{ id: 'case-drop', p: V(9.4, -37.6, -44.2), l: V(9, -38.3, -48.4) },
|
||||
{ id: 'case-uaf', p: V(15.4, -37.6, -45.8), l: V(15, -38.3, -41.6) },
|
||||
{ id: 'case-leak', p: V(21.4, -37.6, -44.2), l: V(21, -38.3, -48.4) },
|
||||
{ id: 'case-int', p: V(27.4, -37.6, -45.8), l: V(27, -38.3, -41.6) },
|
||||
{ id: 'case-exec', p: V(33.4, -37.6, -44.2), l: V(33, -38.3, -48.4) },
|
||||
{ id: 'pattern', p: V(37, -37, -45), l: V(41, -37.5, -38) },
|
||||
{ id: 'bodycount', p: V(41, -37.2, -34), l: V(38.5, -37.5, -28) },
|
||||
{ id: 'expense', p: V(41, -36.8, -22), l: V(44, -37, -16) },
|
||||
{ p: V(41, -38.5, -6), l: V(26, -48, 2) },
|
||||
{ id: 'sisters', p: V(32.5, -45, 8), l: V(20, -49, 3) },
|
||||
{ id: 'open', p: V(12, -45.5, 14.5), l: V(21, -49, 2) },
|
||||
{ p: V(19.6, -43.5, 4.4), l: V(20, -20, 3.8) },
|
||||
{ p: V(20.2, -12, 4.1), l: V(20.2, 10, 3.8) },
|
||||
{ p: V(20.3, 1.5, 4.2), l: V(12, 2.5, 1) },
|
||||
{ id: 'home', p: V(7.8, 3.3, 8.2), l: V(0.6, 2.0, -0.6) },
|
||||
];
|
||||
|
||||
export function buildWorld() {
|
||||
const scene = new THREE.Scene();
|
||||
const anims = []; // (t, ctx) => void; ctx = { u, near(id, width) }
|
||||
const posCurve = new THREE.CatmullRomCurve3(ROUTE.map(k => k.p), false, 'centripetal');
|
||||
const lookCurve = new THREE.CatmullRomCurve3(ROUTE.map(k => k.l), false, 'centripetal');
|
||||
const U = Object.fromEntries(ROUTE.flatMap((k, i) => k.id ? [[k.id, i / (ROUTE.length - 1)]] : []));
|
||||
const pathSamples = posCurve.getSpacedPoints(600);
|
||||
const stone = ps1({ map: T.stone });
|
||||
|
||||
// ---------- the hall ----------
|
||||
scene.add(new THREE.Mesh(tiles(-21, -21, 21, 21, 2, 0, (x, z) => !(z === 3 && (x === -1 || x === 19))), ps1({ map: T.tile })));
|
||||
const plinth = new THREE.Mesh(new THREE.BoxGeometry(5, 0.6, 2.2, 3, 1, 2), stone); plinth.position.set(0, 0.3, -0.4); scene.add(plinth);
|
||||
const tabletPivot = new THREE.Group(); tabletPivot.position.set(0, 0.6, -0.4); tabletPivot.rotation.x = -0.15; scene.add(tabletPivot);
|
||||
const halves = [[T.tabletL, -0.9, 1], [T.tabletR, 0.9, -1]].map(([map, x, s]) => {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(1.8, 4.4, 0.5, 2, 4, 1), [stone, stone, stone, stone, ps1({ map }), stone]);
|
||||
m.position.set(x, 2.2, 0); tabletPivot.add(m); return { m, x, s };
|
||||
});
|
||||
anims.push((t, ctx) => { // on the way back up the monument has split along its crack
|
||||
const e = THREE.MathUtils.smoothstep(ctx.end, 0, 1);
|
||||
halves.forEach(({ m, x, s }) => { m.position.x = x - s * e * 0.55; m.rotation.z = s * e * 0.2; m.rotation.y = -s * e * 0.15; });
|
||||
});
|
||||
|
||||
const wallGeo = new THREE.PlaneGeometry(22, 11, 11, 5);
|
||||
wallGeo.attributes.uv.array.forEach((v, i, a) => { a[i] = v * (i % 2 ? 5 : 11); });
|
||||
const wall = new THREE.Mesh(wallGeo, ps1({ map: T.checks, emissive: true, cells: true }));
|
||||
wall.position.set(0, 5.5, -9); scene.add(wall);
|
||||
|
||||
const metal = ps1({ map: T.stone, color: 0x8899aa });
|
||||
const hatchMesh = () => new THREE.Mesh(new THREE.BoxGeometry(2, 0.1, 2, 2, 1, 2), [metal, metal, ps1({ map: T.hatch }), metal, metal, metal]);
|
||||
const hinge = new THREE.Group(); hinge.position.set(1, 0.02, 4); hinge.rotation.z = -1.0; scene.add(hinge);
|
||||
const hatch = hatchMesh(); hatch.position.set(-1, 0.05, 0); hinge.add(hatch);
|
||||
const lid = hatchMesh(); lid.position.set(22.3, 0.06, 4.6); lid.rotation.y = 0.4; scene.add(lid); // the second hatch, lid off
|
||||
|
||||
const shaftMat = ps1({ map: T.shaft, emissive: true, side: THREE.DoubleSide });
|
||||
const shaft = (cx, top, depth) => {
|
||||
for (const [x, z, ry] of [[cx - 1, 4, Math.PI / 2], [cx + 1, 4, Math.PI / 2], [cx, 3, 0], [cx, 5, 0]]) {
|
||||
const g = new THREE.PlaneGeometry(2, depth, 2, Math.round(depth));
|
||||
g.attributes.uv.array.forEach((v, i, a) => { a[i] = v * (i % 2 ? depth / 2 : 1); });
|
||||
const m = new THREE.Mesh(g, shaftMat); m.position.set(x, top - depth / 2, z); m.rotation.y = ry; scene.add(m);
|
||||
}
|
||||
};
|
||||
shaft(0, 0, 12);
|
||||
shaft(20, 0, 40);
|
||||
|
||||
// ---------- organ chambers ----------
|
||||
const shadowMat = ps1({ color: 0x000000, emissive: true, alpha: 0.45 });
|
||||
function chamber(center, floorY, radius, pillarR, pillars, pillarH) {
|
||||
const n = Math.ceil(radius);
|
||||
scene.add(new THREE.Mesh(tiles(center.x - n, center.z - n, n, n, 2, floorY,
|
||||
(x, z) => Math.hypot(x + 1 - center.x, z + 1 - center.z) < radius), ps1({ map: T.grid })));
|
||||
const parts = [];
|
||||
for (let i = 0; i < pillars; i++) {
|
||||
const a = (i + 0.5) / pillars * Math.PI * 2;
|
||||
parts.push([new THREE.BoxGeometry(0.9, pillarH, 0.9, 1, 3, 1), mat4(center.x + Math.cos(a) * pillarR, floorY + pillarH / 2, center.z + Math.sin(a) * pillarR, -a)]);
|
||||
}
|
||||
scene.add(new THREE.Mesh(merge(parts), stone));
|
||||
}
|
||||
function place(build, pos, id, scale, floorY) {
|
||||
const o = build(); o.group.position.copy(pos); o.group.scale.setScalar(scale); scene.add(o.group);
|
||||
const sh = new THREE.Mesh(new THREE.CircleGeometry(0.9 * scale, 8), shadowMat);
|
||||
sh.rotation.x = -Math.PI / 2; sh.position.set(pos.x, floorY + 0.03, pos.z); scene.add(sh);
|
||||
const phase = pos.x * 1.7 + pos.z;
|
||||
anims.push((t, ctx) => { o.group.position.y = pos.y + Math.sin(t * 1.3 + phase) * 0.12 * scale; o.update(t, ctx.near(id)); });
|
||||
return o;
|
||||
}
|
||||
|
||||
// Eureka: five organs lit. The sixth seat, where Epiphany keeps Face, is the viewer's.
|
||||
chamber(OC, -19, 11, 11.5, 8, 7);
|
||||
const guide = new THREE.Line(lineGeo(circlePts(0.78 * R_EUREKA, 40)), ps1({ color: 0x1d5a5a, emissive: true }));
|
||||
guide.position.set(OC.x, -18.95, OC.z); scene.add(guide);
|
||||
place(O.self, up(OC, 0.4 + ORGAN_Y - OC.y), 'self', 1, -19);
|
||||
for (const id of ['imagination', 'hands', 'soul', 'life']) place(O[id], org[id], id, 1, -19);
|
||||
|
||||
// Epiphany: all eight organs, in scaffolding.
|
||||
chamber(EC, -52, 14, 14.5, 10, 12);
|
||||
const toViewer = V(32.5 - EC.x, 0, 8 - EC.z).normalize();
|
||||
const eslots = {
|
||||
self: up(EC, 0.4), face: EC.clone().addScaledVector(toViewer, 0.58 * R_EPIPHANY),
|
||||
...Object.fromEntries(['imagination', 'eyes', 'body', 'hands', 'soul', 'life'].map(id => [id, O.slot(EC, id, R_EPIPHANY)])),
|
||||
};
|
||||
for (const [id, pos] of Object.entries(eslots)) {
|
||||
place(O[id], pos, 'sisters', 1.3, -52);
|
||||
const big = id === 'self', cage = O.scaffold(big ? 6.4 : 3.6, big ? 6.5 : 4.6);
|
||||
cage.position.set(pos.x, -52, pos.z); scene.add(cage);
|
||||
}
|
||||
{
|
||||
const tops = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const a = (i + 0.5) / 10 * Math.PI * 2, b = (i + 1.5) / 10 * Math.PI * 2;
|
||||
tops.push(V(EC.x + Math.cos(a) * 14.5, -40, EC.z + Math.sin(a) * 14.5), V(EC.x + Math.cos(b) * 14.5, -40, EC.z + Math.sin(b) * 14.5));
|
||||
tops.push(V(EC.x + Math.cos(a) * 14.5, -40, EC.z + Math.sin(a) * 14.5), V(EC.x + Math.cos(a) * 6, -38, EC.z + Math.sin(a) * 6));
|
||||
}
|
||||
scene.add(new THREE.LineSegments(lineGeo(tops), ps1({ color: 0x3c4a58, emissive: true })));
|
||||
}
|
||||
|
||||
// ---------- the lattice: green nodes wired together, kept clear of the flight path ----------
|
||||
{
|
||||
const r = rng(11), nodes = [];
|
||||
for (let x = -10; x <= 10; x += 3.2) for (let y = -23; y >= -36; y -= 3.2) for (let z = -15; z >= -34; z -= 3.2) {
|
||||
const p = V(x + (r() - .5) * 1.6, y + (r() - .5) * 1.6, z + (r() - .5) * 1.6);
|
||||
if (p.y > -22.5 || pathSamples.some(s => s.distanceToSquared(p) < 2.6)) continue;
|
||||
nodes.push(p);
|
||||
}
|
||||
const octa = new THREE.OctahedronGeometry(0.28), pos = [], seed = [];
|
||||
for (const n of nodes) {
|
||||
const s = r() * 1.15; // a few (seed > 1) never flip: what the probes missed
|
||||
for (let i = 0; i < octa.attributes.position.count; i++) {
|
||||
pos.push(octa.attributes.position.getX(i) + n.x, octa.attributes.position.getY(i) + n.y, octa.attributes.position.getZ(i) + n.z);
|
||||
seed.push(s);
|
||||
}
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
g.setAttribute('aSeed', new THREE.Float32BufferAttribute(seed, 1));
|
||||
scene.add(new THREE.Mesh(g, ps1({ color: C.green, emissive: true, nodes: true })));
|
||||
const edges = [];
|
||||
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++)
|
||||
if (nodes[i].distanceTo(nodes[j]) < 4.0 && r() < 0.55) edges.push(nodes[i], nodes[j]);
|
||||
scene.add(new THREE.LineSegments(lineGeo(edges), ps1({ color: C.greenDim, emissive: true })));
|
||||
|
||||
// Soul's probes descend with little verdict crystals at their tips.
|
||||
const beam = ps1({ color: C.accent, emissive: true }), tipMat = ps1({ color: 0x9fd0ff, emissive: true });
|
||||
const probes = [...Array(7)].map(() => {
|
||||
const g = new THREE.BoxGeometry(0.08, 1, 0.08); g.translate(0, -0.5, 0);
|
||||
const m = new THREE.Mesh(g, beam); m.position.set((r() - .5) * 16, -21, -17 - r() * 15); scene.add(m);
|
||||
const tip = new THREE.Mesh(new THREE.OctahedronGeometry(0.22, 0), tipMat); scene.add(tip);
|
||||
return { m, tip, len: 8 + r() * 7 };
|
||||
});
|
||||
anims.push((t, ctx) => {
|
||||
const k = THREE.MathUtils.clamp((ctx.u - U.prompts) / (U.mutation - U.prompts), 0, 1);
|
||||
probes.forEach(p => {
|
||||
p.m.scale.y = Math.max(0.001, k * p.len);
|
||||
p.tip.position.set(p.m.position.x, p.m.position.y - k * p.len, p.m.position.z); p.tip.rotation.y = t;
|
||||
p.tip.visible = k > 0.01;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- the specimen gallery ----------
|
||||
scene.add(new THREE.Mesh(tiles(-6, -50, 22, 5, 2, -40), ps1({ map: T.tile })));
|
||||
const wallG = (w, x, z, ry) => {
|
||||
const g = new THREE.PlaneGeometry(w, 5, Math.round(w / 2), 3);
|
||||
g.attributes.uv.array.forEach((v, i, a) => { a[i] = v * (i % 2 ? 2.5 : w / 2); });
|
||||
const m = new THREE.Mesh(g, stone); m.position.set(x, -37.5, z); m.rotation.y = ry; scene.add(m);
|
||||
};
|
||||
wallG(44, 16, -50, 0);
|
||||
wallG(32, 22, -40, Math.PI); // the south wall leaves an opening where the camera drops in from the lattice
|
||||
{
|
||||
// The first campaign's cabinet: twelve pinned mutants, one still moving.
|
||||
const cab = new THREE.Group(); cab.position.set(-3.4, -37.4, -45); cab.rotation.y = Math.PI / 2; cab.scale.setScalar(0.52); scene.add(cab);
|
||||
const frame = new THREE.Mesh(new THREE.BoxGeometry(7.4, 9.4, 0.6, 3, 4, 1), ps1({ map: T.cabinet })); frame.position.z = -0.4; cab.add(frame);
|
||||
const bug = bugTex(1, '#5d6b78', '#8795a3'), bugMat = ps1({ map: bug, alphaTest: true, emissive: true, color: 0xcfd8e2 });
|
||||
let survivor;
|
||||
[-2.4, 0, 2.4].forEach((cx, i) => [3.3, 1.1, -1.1, -3.3].forEach((cy, j) => {
|
||||
const live = i === 1 && j === 1;
|
||||
const m = new THREE.Mesh(new THREE.PlaneGeometry(1.8, 1.8), live ? ps1({ map: bug, alphaTest: true, emissive: true, color: C.accent }) : bugMat);
|
||||
m.position.set(cx, cy + 0.15, -0.05); cab.add(m);
|
||||
if (live) {
|
||||
survivor = m;
|
||||
const tag = new THREE.Mesh(new THREE.PlaneGeometry(1.9, 0.36), ps1({ map: T.survived, emissive: true })); tag.position.set(cx, cy - 0.85, -0.04); cab.add(tag);
|
||||
}
|
||||
}));
|
||||
anims.push((t) => { survivor.rotation.z = Math.sin(t * 11) * 0.12 * (Math.sin(t * 1.4) > 0.3 ? 1 : 0); });
|
||||
}
|
||||
const glass = ps1({ color: 0x9fc4e8, rim: 0xbfe3ff, alpha: 0.22, side: THREE.DoubleSide });
|
||||
const brass = ps1({ color: 0xb08a4a, emissive: true });
|
||||
const CASES = [
|
||||
['case-drop', 9, 'north', 11, '#6a5a78', '#a795c3', 'No.01 HANDSHAKE EVICTION', 'SUITE GREEN SOUL CAUGHT'],
|
||||
['case-uaf', 15, 'south', 12, '#78604a', '#c3a587', 'No.02 USE AFTER FREE', 'SUITE GREEN SOUL CAUGHT'],
|
||||
['case-leak', 21, 'north', 13, '#4a7868', '#87c3ad', 'No.03 53 BYTES A CALL', 'SUITE GREEN SOUL CAUGHT'],
|
||||
['case-int', 27, 'south', 14, '#78784a', '#c3c387', 'No.04 FRACTION IN INT', 'SUITE GREEN SOUL CAUGHT'],
|
||||
['case-exec', 33, 'north', 15, '#4a5a78', '#8799c3', 'No.05 SHARED SLOT', 'SUITE GREEN SOUL CAUGHT'],
|
||||
];
|
||||
for (const [id, x, side, seed, body, leg, l1, l2] of CASES) {
|
||||
const g = new THREE.Group(); g.position.set(x, -40, side === 'north' ? -48.4 : -41.6); g.rotation.y = side === 'north' ? 0 : Math.PI; scene.add(g);
|
||||
const ped = new THREE.Mesh(new THREE.BoxGeometry(1.6, 1.0, 1.6), stone); ped.position.y = 0.5; g.add(ped);
|
||||
const box = new THREE.BoxGeometry(1.5, 1.6, 1.5);
|
||||
const gl = new THREE.Mesh(box, glass); gl.position.y = 1.8; g.add(gl);
|
||||
const fr = new THREE.LineSegments(new THREE.EdgesGeometry(box), brass); fr.position.y = 1.8; g.add(fr);
|
||||
const spec = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), ps1({ map: bugTex(seed, body, leg), alphaTest: true, emissive: true, color: 0xdde6ee }));
|
||||
spec.position.y = 1.8; g.add(spec);
|
||||
const lab = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 0.26), ps1({ map: labelTex(l1, l2), emissive: true })); lab.position.set(0, 0.7, 0.81); g.add(lab);
|
||||
anims.push((t, ctx) => { spec.rotation.y = Math.sin(t * 0.7) * 0.25 * ctx.near(id); });
|
||||
}
|
||||
|
||||
// ---------- the archive: rules as tablets, each with a body count ----------
|
||||
scene.add(new THREE.Mesh(tiles(34, -44, 7, 17, 2, -40), ps1({ map: T.tile })));
|
||||
{
|
||||
const r = rng(31), woodParts = [], slabParts = [], markParts = [];
|
||||
for (const [bx, face] of [[37.4, 1], [44.6, -1]]) for (let z = -40; z <= -14; z += 3.2) {
|
||||
woodParts.push([new THREE.BoxGeometry(0.2, 4.6, 3.0), mat4(bx, -37.7, z)]);
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const py = -39.9 + k * 1.1, px = bx + face * 0.45;
|
||||
woodParts.push([new THREE.BoxGeometry(0.8, 0.1, 3.0), mat4(px, py, z)]);
|
||||
for (let zz = z - 1.3; zz < z + 1.3; zz += 0.3 + r() * 0.12) {
|
||||
const h = 0.5 + r() * 0.35;
|
||||
slabParts.push([new THREE.BoxGeometry(0.28, h, 0.2), mat4(px, py + 0.05 + h / 2, zz, 0, 0, (r() - 0.5) * 0.12)]);
|
||||
if (r() < 0.06) markParts.push([new THREE.BoxGeometry(0.3, 0.06, 0.22), mat4(px, py + 0.05 + h * 0.8, zz)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
scene.add(new THREE.Mesh(merge(woodParts), ps1({ map: T.wood })));
|
||||
scene.add(new THREE.Mesh(merge(slabParts), ps1({ map: T.slab })));
|
||||
scene.add(new THREE.Mesh(merge(markParts), ps1({ color: C.accent, emissive: true })));
|
||||
}
|
||||
|
||||
// ---------- the far plane: GameCult's aperture, a populated megastructure behind the fog ----------
|
||||
const { sky, skyUpdate } = aperture();
|
||||
|
||||
return { scene, sky, anims, posCurve, lookCurve, U, skyUpdate };
|
||||
}
|
||||
|
||||
// ---------- the aperture ----------
|
||||
const BIOMES = [
|
||||
['#1f4a2a', (g, r) => { for (let i = 0; i < 90; i++) { g.fillStyle = r() < .5 ? '#12301b' : '#3f7a3a'; g.fillRect(r() * 64 | 0, r() * 64 | 0, 2, 2); } g.fillStyle = '#dfeff5'; for (let i = 0; i < 4; i++) g.fillRect(r() * 64 | 0, r() * 40 | 0, 1, 10 + r() * 14); }],
|
||||
['#a0602a', (g, r) => { g.fillStyle = '#c98a45'; for (let y = 4; y < 64; y += 9) g.fillRect(0, y, 64, 2); g.fillStyle = '#5a3418'; for (let i = 0; i < 7; i++) { const x = r() * 64, y = r() * 64; g.beginPath(); g.moveTo(x, y); g.lineTo(x + 3, y - 11); g.lineTo(x + 6, y); g.fill(); } }],
|
||||
['#b9ccd8', (g, r) => { g.fillStyle = '#7f9fb8'; for (let i = 0; i < 8; i++) { const x = r() * 64, y = r() * 64; g.beginPath(); g.moveTo(x, y); g.lineTo(x + 6, y - 12); g.lineTo(x + 12, y); g.fill(); } g.fillStyle = '#2a3a4a'; for (let i = 0; i < 20; i++) g.fillRect(r() * 64 | 0, r() * 64 | 0, 1, 1); }],
|
||||
['#1e5a52', (g, r) => { g.fillStyle = '#5fd0c0'; for (let i = 0; i < 7; i++) g.fillRect(r() * 64 | 0, 0, 1, 64); g.fillStyle = '#123a33'; for (let i = 0; i < 60; i++) g.fillRect(r() * 64 | 0, r() * 64 | 0, 2, 2); }],
|
||||
['#4a5a4a', (g, r) => { g.fillStyle = '#7a8a7a'; for (let i = 0; i < 8; i++) { const x = r() * 64, y = r() * 64; g.beginPath(); g.moveTo(x, y); g.lineTo(x + 7, y - 13); g.lineTo(x + 14, y); g.fill(); g.fillStyle = '#e8f0f0'; g.fillRect(x + 6, y - 13, 2, 2); g.fillStyle = '#7a8a7a'; } }],
|
||||
['#0b1320', (g, r) => { for (let i = 0; i < 160; i++) { g.fillStyle = r() < .6 ? '#ffb45a' : '#6fe0ff'; g.fillRect(r() * 64 | 0, r() * 64 | 0, 1, 1); } }],
|
||||
];
|
||||
|
||||
function aperture() {
|
||||
const sky = new THREE.Scene();
|
||||
const R = 230, FOG = 0.62;
|
||||
const hull = tex(64, 64, (g, w, h) => {
|
||||
const r = rng(41); g.fillStyle = '#0a1420'; g.fillRect(0, 0, w, h);
|
||||
g.fillStyle = '#13263a'; for (let i = 0; i < 64; i += 8) { g.fillRect(i, 0, 1, h); g.fillRect(0, i, w, 1); }
|
||||
for (let i = 0; i < 70; i++) { g.fillStyle = r() < .55 ? '#ffb45a' : '#6fe0ff'; g.fillRect(r() * 64 | 0, r() * 64 | 0, 1, 1); }
|
||||
}, true);
|
||||
const root = new THREE.Group(); sky.add(root);
|
||||
root.position.copy(V(-0.45, 0.3, -1).normalize().multiplyScalar(560));
|
||||
root.lookAt(0, 0, 0); root.rotateX(-0.55); root.rotateY(0.25); // tilted: a structure seen at an angle, not a badge
|
||||
|
||||
const discGeo = new THREE.CircleGeometry(R * 0.96, 28);
|
||||
discGeo.attributes.uv.array.forEach((v, i, a) => { a[i] = v * 7; });
|
||||
const disc = new THREE.Mesh(discGeo, ps1({ map: hull, emissive: true, fogMax: FOG, side: THREE.DoubleSide })); disc.position.z = -3; root.add(disc);
|
||||
const ringGeo = new THREE.TorusGeometry(R, R * 0.06, 4, 48);
|
||||
const ring = new THREE.Mesh(ringGeo, ps1({ map: hull, color: 0x8fdcff, emissive: true, fogMax: FOG - 0.12 })); root.add(ring);
|
||||
const inner = new THREE.Mesh(new THREE.TorusGeometry(R * 0.92, R * 0.012, 3, 48), ps1({ color: C.accent, emissive: true, fogMax: FOG - 0.1 })); root.add(inner);
|
||||
const hub = new THREE.Mesh(new THREE.CylinderGeometry(R * 0.15, R * 0.15, R * 0.05, 8), ps1({ color: 0xffc860, emissive: true, fogMax: FOG - 0.15 }));
|
||||
hub.rotation.x = Math.PI / 2; hub.position.z = 6; root.add(hub);
|
||||
const coreGlow = new THREE.Mesh(new THREE.IcosahedronGeometry(R * 0.06, 0), ps1({ color: 0xffffff, emissive: true, fogMax: FOG - 0.3 })); coreGlow.position.z = 14; root.add(coreGlow);
|
||||
|
||||
// Six habitat blades on pivots around the rim; rotating them about the pivot opens the iris.
|
||||
const N = 18, blades = [];
|
||||
BIOMES.forEach(([base, draw], i) => {
|
||||
const map = tex(64, 64, (g, w, h) => { g.fillStyle = base; g.fillRect(0, 0, w, h); draw(g, rng(50 + i)); }, true);
|
||||
const a0 = i / 6 * Math.PI * 2, pivot = V(Math.cos(a0) * R * 0.95, Math.sin(a0) * R * 0.95, 0);
|
||||
const pos = [], uv = [], idx = [], edge = [];
|
||||
for (let k = 0; k <= N; k++) {
|
||||
const s = k / N, rho = THREE.MathUtils.lerp(R * 0.95, R * 0.14, s), th = a0 + 1.75 * s;
|
||||
const c = V(Math.cos(th) * rho, Math.sin(th) * rho, 0);
|
||||
const d = V(Math.cos(th + 0.01) * THREE.MathUtils.lerp(R * 0.95, R * 0.14, s + 0.001), Math.sin(th + 0.01) * THREE.MathUtils.lerp(R * 0.95, R * 0.14, s + 0.001), 0).sub(c).normalize();
|
||||
const side = V(-d.y, d.x, 0), w = R * (0.2 * (1 - 0.55 * s) + 0.02) * Math.sin(Math.min(1, s * 6 + 0.2) * Math.PI / 2);
|
||||
const pa = c.clone().addScaledVector(side, w).sub(pivot), pb = c.clone().addScaledVector(side, -w).sub(pivot);
|
||||
pos.push(pa.x, pa.y, 0, pb.x, pb.y, 0); uv.push(s * 3, 0, s * 3, 1);
|
||||
edge.push(pa, pb);
|
||||
if (k < N) { const b = k * 2; idx.push(b, b + 1, b + 2, b + 1, b + 3, b + 2); }
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2)); geo.setIndex(idx);
|
||||
const holder = new THREE.Group(); holder.position.copy(pivot).setZ(i * 0.8); root.add(holder);
|
||||
holder.add(new THREE.Mesh(geo, ps1({ map, emissive: true, fogMax: FOG, side: THREE.DoubleSide })));
|
||||
const rimPts = [];
|
||||
for (let k = 0; k < N; k++) rimPts.push(edge[k * 2], edge[k * 2 + 2], edge[k * 2 + 1], edge[k * 2 + 3]);
|
||||
holder.add(new THREE.LineSegments(lineGeo(rimPts), ps1({ color: 0x3fa7d6, emissive: true, fogMax: FOG - 0.15 })));
|
||||
// Choose the rotation sign that swings the inner tip away from the centre.
|
||||
const tip = V(pos[N * 6], pos[N * 6 + 1], 0);
|
||||
const out = (sgn) => tip.clone().applyAxisAngle(V(0, 0, 1), sgn * 0.3).add(pivot).length();
|
||||
blades.push({ holder, sign: out(1) > out(-1) ? 1 : -1 });
|
||||
});
|
||||
|
||||
// Stations in orbit around the rim.
|
||||
const r = rng(77), stations = [...Array(16)].map(() => {
|
||||
const m = new THREE.Mesh(r() < 0.5 ? new THREE.OctahedronGeometry(R * (0.015 + r() * 0.02), 0) : new THREE.BoxGeometry(R * 0.04, R * 0.012, R * 0.012),
|
||||
ps1({ color: r() < 0.5 ? 0xd9c08a : 0x8fb6d0, emissive: true, fogMax: FOG - 0.05 }));
|
||||
root.add(m); return { m, a: r() * Math.PI * 2, rad: R * (1.1 + r() * 0.25), sp: (0.02 + r() * 0.04) * (r() < 0.5 ? 1 : -1), z: (r() - 0.5) * R * 0.2 };
|
||||
});
|
||||
|
||||
// Underground the aperture fades to a faint presence; it returns in full on the climb out.
|
||||
const skyMats = []; root.traverse(o => { if (o.material?.uniforms?.uFogMax) skyMats.push([o.material, o.material.uniforms.uFogMax.value]); });
|
||||
const skyUpdate = (t, u, camY) => {
|
||||
const vis = 0.3 + 0.7 * THREE.MathUtils.smoothstep(camY, -16, -2);
|
||||
for (const [m, base] of skyMats) m.uniforms.uFogMax.value = 1 - (1 - base) * vis;
|
||||
const open = 0.2 + 0.75 * u; // the aperture opens as the page goes on
|
||||
blades.forEach(b => { b.holder.rotation.z = b.sign * open * 0.62; });
|
||||
root.rotateZ(0.00025);
|
||||
coreGlow.scale.setScalar(1 + Math.sin(t * 1.1) * 0.12);
|
||||
stations.forEach(s => { const a = s.a + t * s.sp; s.m.position.set(Math.cos(a) * s.rad, Math.sin(a) * s.rad, s.z); s.m.rotation.y = t * 0.5; });
|
||||
};
|
||||
return { sky, skyUpdate };
|
||||
}
|
||||
Loading…
Reference in a new issue