generated from Labyricorn/labyricorn-project-template
Post Step 4 Completion
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
> **Local copy notice.** This directory contains locally corrected copies of
|
||||
> the upstream reference exhibits. The corrections are documented in
|
||||
> `../PROVENANCE.md`. The text below is the original upstream README; its
|
||||
> verification claims describe the upstream state, not this corrected copy.
|
||||
> Re-copying from the upstream source without reapplying corrections will
|
||||
> reintroduce known defects.
|
||||
|
||||
# XZBT Exhibit Contract 5.2 — Reference Exhibits
|
||||
|
||||
Three unrelated exhibits and one generic host, all speaking XZBT Exhibit
|
||||
Contract 5.2. The point of the set is not the exhibits; it is that a single
|
||||
unmodified host drives all three, and that the contract layer is shared
|
||||
rather than reimplemented per exhibit.
|
||||
|
||||
```
|
||||
reference-exhibits/
|
||||
shared/
|
||||
contract-core.js generic contract engine (no domain knowledge)
|
||||
host-transport.js same-origin postMessage transport (optional)
|
||||
exhibit-shell.js generic DOM helpers (optional)
|
||||
exhibit-shell.css generic page chrome (optional)
|
||||
aquarium/ exhibit 1 — reef tank
|
||||
planetarium/ exhibit 2 — sky dome
|
||||
haunted-house/ exhibit 3 — the one with a real capability lifecycle
|
||||
host-harness/ a generic host that knows nothing about any of them
|
||||
tests/ Node test suites + a static server for manual checks
|
||||
```
|
||||
|
||||
## Running it
|
||||
|
||||
No build step, no dependencies, no package manager.
|
||||
|
||||
**Standalone.** Open any exhibit's `index.html` directly in a browser. It
|
||||
works from `file://` with the network off. The header reads `standalone` and
|
||||
nothing is ever posted anywhere.
|
||||
|
||||
**With a host.** The harness uses `postMessage` with an origin check, and
|
||||
`file://` documents report origin `null`, so serve over HTTP:
|
||||
|
||||
```
|
||||
node tests/serve.js
|
||||
```
|
||||
|
||||
Then open `http://localhost:8731/host-harness/index.html`, pick an exhibit,
|
||||
and press Connect. The harness performs the handshake, reads `describe`, and
|
||||
builds its entire control surface from the descriptors the exhibit published.
|
||||
|
||||
**Tests.**
|
||||
|
||||
```
|
||||
node tests/run-all.js
|
||||
```
|
||||
|
||||
137 checks, no dependencies. See *Verification* below for what they do and
|
||||
do not cover.
|
||||
|
||||
## The layering
|
||||
|
||||
Each exhibit is split into four files, and the split is the whole argument:
|
||||
|
||||
| File | Knows about | Does not know about |
|
||||
| --- | --- | --- |
|
||||
| `exhibit.js` | the domain, the canvas, the native controls | the contract, target IDs, events |
|
||||
| `contract-adapter.js` | the contract, the target catalog | rendering, DOM |
|
||||
| `boot.js` | composition only | everything else |
|
||||
| `index.html` | markup and script order | behaviour |
|
||||
|
||||
`exhibit.js` is a complete, working exhibit on its own. Delete
|
||||
`contract-adapter.js`, `host-transport.js`, and `boot.js`'s last three lines
|
||||
and you still have a functioning aquarium. That is deliberate: the contract is
|
||||
a layer added on top of a working exhibit, not a scaffold the exhibit is built
|
||||
inside of.
|
||||
|
||||
The shared `contract-core.js` is domain-free. It knows the *shape* of the
|
||||
contract — envelopes, target kinds, revisions, sequences, error codes,
|
||||
capability lifecycle — and nothing about fish, stars, or ghosts. Each adapter
|
||||
supplies three things and gets a conforming surface:
|
||||
|
||||
1. a target catalog (canonical dotted IDs → descriptors)
|
||||
2. a setter table (target ID → absolute, idempotent setter)
|
||||
3. an action table (target ID → real impulse implementation)
|
||||
|
||||
## The three exhibits
|
||||
|
||||
| | Aquarium | Planetarium | Haunted House |
|
||||
| --- | --- | --- | --- |
|
||||
| Targets | 12 | 14 | 13 |
|
||||
| Capabilities | `render` | `render` | `render`, `audio` |
|
||||
| Notable | step-validated ranges | action with a typed argument | a genuinely gated action |
|
||||
|
||||
The Haunted House is the interesting one. Its audio is synthesized with the
|
||||
Web Audio API, and browsers refuse to start an audio context until the user
|
||||
has interacted with the page. So `audio` really does begin `available`, really
|
||||
does pass through `loading` to `ready` on the first gesture, and really does
|
||||
report `error` if the context cannot be created. The adapter mirrors that real
|
||||
state into the capability registry; it never invents a transition to make the
|
||||
contract look busy. `action.moan` and `action.slam-door` declare
|
||||
`requires: ['audio']` and return `CAPABILITY_UNAVAILABLE` until the engine is
|
||||
actually ready — while staying discoverable in `describe` the whole time.
|
||||
|
||||
## Contract decisions worth stating
|
||||
|
||||
**One mutation chokepoint.** Every persistent-state change — native UI,
|
||||
hotkey, or host `set` — goes through `applyMutation`. One call is one
|
||||
transaction: commit, increment `stateRevision` at most once, then emit the
|
||||
resulting events carrying that revision. A host `set` and a slider drag are
|
||||
the same transaction with a different `source`.
|
||||
|
||||
**No silent clamping.** An out-of-range value is rejected with
|
||||
`INVALID_VALUE`, not quietly pinned to the boundary. A value off the declared
|
||||
`step` is rejected too. A host that sends nonsense should be told.
|
||||
|
||||
**No-ops do not move the revision.** Setters are absolute and idempotent and
|
||||
report whether anything actually changed. Setting a value to what it already
|
||||
is returns `changed: false` and leaves `stateRevision` alone, so a host can
|
||||
poll without inflating history.
|
||||
|
||||
**`stateRevision` survives reconnects; `sequence` does not.** A new session
|
||||
resets the event sequence but deliberately leaves `stateRevision` intact, so a
|
||||
reconnecting host can tell whether it missed anything.
|
||||
|
||||
**Source is assigned at the trusted boundary.** A `source` field inside an
|
||||
incoming message is ignored. The transport stamps `host`; the UI path stamps
|
||||
`ui`.
|
||||
|
||||
**Event shape is identical on every path.** `correlationId` is always present
|
||||
(null when the change did not originate from a request) so a host can rely on
|
||||
one schema regardless of what caused the change.
|
||||
|
||||
**Capabilities are honest.** An exhibit that cannot fail declares `ready`
|
||||
once. An exhibit with a real lifecycle reports it. Nothing fakes a transition.
|
||||
|
||||
**Text is data.** Every string that reaches the DOM goes through
|
||||
`textContent`. Nothing in this project assigns `innerHTML`, so a string
|
||||
arriving from a host or a scenario can never become markup or script.
|
||||
|
||||
## Verification
|
||||
|
||||
### Automated — `node tests/run-all.js`
|
||||
|
||||
**`conformance.test.js` (126 checks).** Loads the real `contract-core.js` and
|
||||
the real `contract-adapter.js` from each exhibit into a sandbox, with a small
|
||||
stub standing in for the exhibit's own services. The adapter under test is the
|
||||
genuine article; only the domain object behind it is a stub. Covers handshake
|
||||
and session validation, `describe` shape, canonical target IDs, `state.get`
|
||||
filtering, mutation semantics (commit, revision, no-op, absolute, rejection),
|
||||
action semantics, event envelopes and monotonic sequence, session reset,
|
||||
source trust, UI/host parity, malformed input, the capability lifecycle, and
|
||||
action arguments. A final cross-exhibit group runs one generic request
|
||||
sequence against all three.
|
||||
|
||||
**`transport.test.js` (11 checks).** Covers the postMessage layer against a
|
||||
fake window: origin and source validation, version filtering, and — the reason
|
||||
the file exists — that events are *pushed* to a connected host, not just
|
||||
responses.
|
||||
|
||||
### Manual — browser
|
||||
|
||||
Verified in a real browser against a local server:
|
||||
|
||||
- All three exhibits render (canvas confirmed painted, not blank) and load
|
||||
with a clean console.
|
||||
- Every native control on all three exhibits was exercised; each routes
|
||||
through the canonical path and produces the expected event with
|
||||
`source: 'ui'`.
|
||||
- The generic host connected to all three exhibits unmodified, built its UI
|
||||
from their descriptors, and drove them: host `set` → exhibit commits → event
|
||||
pushed back → host revision, sequence, and readout all update.
|
||||
- The Haunted House capability lifecycle was observed end to end from a real
|
||||
button click: `available → loading → ready`, with two
|
||||
`capability.changed` events, after which the gated action succeeded.
|
||||
- Standalone: each exhibit opened from `file://` with the server stopped.
|
||||
Renders, contract works, native UI works, and
|
||||
`performance.getEntriesByType('resource')` shows zero external resources.
|
||||
|
||||
### Not covered
|
||||
|
||||
- No automated browser tests. Rendering and the browser's audio policy are
|
||||
verified by hand, as above.
|
||||
- The exhibits are reference implementations, not production art. The
|
||||
simulation is simple by design; the contract is the subject.
|
||||
|
||||
## Bugs found and fixed during verification
|
||||
|
||||
Recorded because they are the kind that pass a unit suite and fail a user:
|
||||
|
||||
1. **Target IDs were not canonical.** `action.cleanGlass`,
|
||||
`telemetry.fishCount`, `sky.magnitudeLimit` and others used camelCase
|
||||
segments. The catalog validator rejected them at construction. All IDs are
|
||||
now lowercase-hyphenated (`action.clean-glass`, `telemetry.fish-count`,
|
||||
`sky.magnitude-limit`).
|
||||
2. **The transport never forwarded events.** It sent responses only, so a host
|
||||
could issue a `set`, receive an acknowledgement, and never learn that
|
||||
anything had changed. The exhibit looked correct in isolation and the
|
||||
conformance suite passed; only the host/exhibit link was broken. The
|
||||
transport now forwards the core's events to a connected host, and
|
||||
`transport.test.js` pins it down.
|
||||
3. **The harness raced its own navigation.** `connect()` called
|
||||
`disconnect()` first, which queued an `about:blank` load that then beat the
|
||||
real navigation, leaving an empty frame.
|
||||
4. **The harness page never loaded `exhibit-shell.js`.** `harness.js` uses
|
||||
`XZBTShell` for element lookup, so it threw on load and the page was inert.
|
||||
5. **`correlationId` was conditionally present.** It was omitted for
|
||||
UI-originated changes and included for host-originated ones, so the event
|
||||
shape differed by path. It is now always present, null when not applicable.
|
||||
|
||||
## Adding a fourth exhibit
|
||||
|
||||
1. Write `exhibit.js` — a working exhibit with absolute, idempotent setters
|
||||
that return `{ changed }`, a `read(targetId)`, and real actions. No
|
||||
contract awareness.
|
||||
2. Write `contract-adapter.js` — declare the catalog, bind setters, readers,
|
||||
and actions, and route the UI through `core.applyMutation` /
|
||||
`core.invokeAction`.
|
||||
3. Write `boot.js` and `index.html` following an existing exhibit.
|
||||
4. Add the exhibit to `EXHIBITS` in `tests/conformance.test.js` and to
|
||||
`ADAPTER_EXPORTS`. The shared conformance group then runs against it
|
||||
automatically.
|
||||
5. Add it to the harness dropdown. No harness code changes — that is the test.
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Aquarium — composition root.
|
||||
*
|
||||
* The last file to load, and the only one that knows about all the others.
|
||||
* It builds the exhibit, builds the UI, then hands both to the contract
|
||||
* adapter. If the adapter were removed, the first two lines would still
|
||||
* produce a complete, working aquarium.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Shell = window.XZBTShell;
|
||||
|
||||
var announcer = new Shell.Announcer({
|
||||
node: Shell.el('announcement'),
|
||||
idleText: 'Tank settled \u2014 awaiting instruction.',
|
||||
maxLength: 120
|
||||
});
|
||||
|
||||
var exhibit = new window.AquariumExhibit({
|
||||
canvas: Shell.el('tank-canvas'),
|
||||
announcer: announcer
|
||||
});
|
||||
|
||||
var ui = new window.AquariumUI(exhibit, announcer);
|
||||
|
||||
/* Contract layer. Optional by construction: everything above already
|
||||
* works without it. */
|
||||
var core = window.AquariumContract.create(exhibit, ui);
|
||||
|
||||
/* Transport layer. Also optional: with no host attached, nothing here
|
||||
* ever fires and the exhibit is unchanged. */
|
||||
var transport = new window.XZBTHostTransport({
|
||||
core: core,
|
||||
onMessage: function (request, response) {
|
||||
if (response.type === 'hello.result') {
|
||||
Shell.setText(Shell.el('contract-status'), 'host attached');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
exhibit.start();
|
||||
|
||||
window.addEventListener('resize', function () { exhibit.resize(); });
|
||||
|
||||
/* Exposed for the conformance harness and for manual console poking. */
|
||||
window.__exhibit = {
|
||||
core: core,
|
||||
exhibit: exhibit,
|
||||
ui: ui,
|
||||
transport: transport,
|
||||
announcer: announcer
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Aquarium — XZBT Exhibit Contract 5.2 adapter.
|
||||
*
|
||||
* This file is the only place in the exhibit that knows the contract exists.
|
||||
* It does three things and nothing else:
|
||||
*
|
||||
* 1. declares the public target catalog against the exhibit's real model
|
||||
* 2. binds each target to a real exhibit service (setter / reader / action)
|
||||
* 3. routes the native UI through the canonical mutation path
|
||||
*
|
||||
* It holds no state of its own. Every value it reports is read live from the
|
||||
* exhibit, so there is no shadow copy that can drift (Authoring Guide §W).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Core = window.XZBTContractCore;
|
||||
|
||||
var IDENTITY = {
|
||||
product: 'Aquarium',
|
||||
version: '1.0.0',
|
||||
build: 'reference-exhibit'
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Public target catalog.
|
||||
*
|
||||
* IDs are canonical and domain-shaped. They are not DOM ids, CSS
|
||||
* selectors, or widget names (Authoring Guide §W).
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
var DESCRIPTORS = [
|
||||
{
|
||||
id: 'tank.species',
|
||||
kind: 'selection',
|
||||
label: 'Species',
|
||||
description: 'The species currently stocked in the tank.',
|
||||
options: [
|
||||
{ value: 'clownfish', label: 'Clownfish' },
|
||||
{ value: 'blue-tang', label: 'Blue Tang' },
|
||||
{ value: 'neon-tetra', label: 'Neon Tetra' },
|
||||
{ value: 'angelfish', label: 'Angelfish' },
|
||||
{ value: 'guppy', label: 'Guppy' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.population',
|
||||
kind: 'range',
|
||||
label: 'Population',
|
||||
description: 'Number of fish in the tank.',
|
||||
min: 1, max: 40, step: 1, unit: 'fish',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.lighting',
|
||||
kind: 'selection',
|
||||
label: 'Lighting',
|
||||
description: 'Lighting programme for the tank.',
|
||||
options: [
|
||||
{ value: 'daylight', label: 'Daylight' },
|
||||
{ value: 'twilight', label: 'Twilight' },
|
||||
{ value: 'moonlight', label: 'Moonlight' },
|
||||
{ value: 'blackwater', label: 'Blackwater' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.current',
|
||||
kind: 'range',
|
||||
label: 'Water current',
|
||||
description: 'Strength of the circulation pump, 0 (still) to 1 (strong).',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.temperature',
|
||||
kind: 'range',
|
||||
label: 'Temperature',
|
||||
description: 'Heater setting, 0 (18 C) to 1 (28 C).',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'tank.paused',
|
||||
kind: 'state',
|
||||
valueType: 'boolean',
|
||||
label: 'Paused',
|
||||
description: 'Whether the simulation is frozen.',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'tank', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.feed',
|
||||
kind: 'impulse',
|
||||
label: 'Feed',
|
||||
description: 'Scatter food across the surface.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.startle',
|
||||
kind: 'impulse',
|
||||
label: 'Startle',
|
||||
description: 'Send a pressure wave through the tank.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.clean-glass',
|
||||
kind: 'impulse',
|
||||
label: 'Clean glass',
|
||||
description: 'Run the glass-cleaning cycle.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.fish-count',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
label: 'Fish count',
|
||||
description: 'Live number of fish currently rendered.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.food-level',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
label: 'Food level',
|
||||
description: 'Remaining food in the water column, 0 to 1.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.water-temp',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
unit: 'C',
|
||||
label: 'Water temperature',
|
||||
description: 'Actual water temperature in degrees Celsius.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
}
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Wiring
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function createAquariumContract(exhibit, ui) {
|
||||
var catalog = new Core.Catalog(DESCRIPTORS);
|
||||
|
||||
/* The exhibit has no capability that can fail: it is pure canvas and
|
||||
* needs no device, no network, and no permission. Reporting `ready`
|
||||
* once is honest; inventing loading/error transitions would not be
|
||||
* (Authoring Guide §M). */
|
||||
var capabilities = new Core.CapabilityRegistry();
|
||||
capabilities.declare('render', 'ready');
|
||||
|
||||
var setters = {
|
||||
'tank.species': function (v) { return exhibit.setSpecies(v); },
|
||||
'tank.population': function (v) { return exhibit.setPopulation(v); },
|
||||
'tank.lighting': function (v) { return exhibit.setLighting(v); },
|
||||
'tank.current': function (v) { return exhibit.setCurrent(v); },
|
||||
'tank.temperature': function (v) { return exhibit.setTemperature(v); },
|
||||
'tank.paused': function (v) { return exhibit.setPaused(v); }
|
||||
};
|
||||
|
||||
var readers = {};
|
||||
var ids = catalog.ids();
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
(function (id) {
|
||||
readers[id] = function () { return exhibit.read(id); };
|
||||
})(ids[i]);
|
||||
}
|
||||
|
||||
var actions = {
|
||||
'action.feed': function () { return exhibit.feed(); },
|
||||
'action.startle': function () { return exhibit.startle(); },
|
||||
'action.clean-glass': function () { return exhibit.cleanGlass(); }
|
||||
};
|
||||
|
||||
var core = new Core.ContractCore({
|
||||
identity: IDENTITY,
|
||||
catalog: catalog,
|
||||
capabilities: capabilities,
|
||||
setters: setters,
|
||||
readers: readers,
|
||||
actions: actions
|
||||
});
|
||||
|
||||
/* Route the native UI through the canonical path. From here on, a
|
||||
* slider drag and a host `set` are the same transaction, and both are
|
||||
* reported with the same source semantics (Authoring Guide §H). */
|
||||
ui.dispatchOverride = function (targetId, value) {
|
||||
core.applyMutation(targetId, value, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
ui.invokeOverride = function (targetId) {
|
||||
core.invokeAction(targetId, {}, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
|
||||
/* Keep the controls honest when the exhibit changes for any reason. */
|
||||
exhibit.onChange = function () { ui.sync(); };
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
window.AquariumContract = { create: createAquariumContract, IDENTITY: IDENTITY };
|
||||
})();
|
||||
@@ -0,0 +1,694 @@
|
||||
/*
|
||||
* Aquarium — exhibit behaviour.
|
||||
*
|
||||
* This file is the exhibit. It owns the tank model, the simulation, the
|
||||
* canvas rendering, and the native controls. It has no contract awareness
|
||||
* whatsoever: it does not know what a target ID is, it never emits an event,
|
||||
* and it would work identically if the contract layer were deleted.
|
||||
*
|
||||
* That is deliberate. The contract adapter is a layer added on top of a
|
||||
* working exhibit (Authoring Guide §X), not a scaffold the exhibit is built
|
||||
* inside of.
|
||||
*
|
||||
* The one thing this file does provide for the layer above it is a set of
|
||||
* absolute, idempotent setters and real actions. Those are the exhibit's own
|
||||
* services — the same functions the native UI calls — so UI-originated and
|
||||
* host-originated changes share exactly one code path (Authoring Guide §H).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TAU = Math.PI * 2;
|
||||
|
||||
var SPECIES = [
|
||||
{ id: 'clownfish', label: 'Clownfish', color: '#ff9a3c', size: 9, speed: 1.15, schooling: 0.35 },
|
||||
{ id: 'blue-tang', label: 'Blue Tang', color: '#3f8cff', size: 12, speed: 0.95, schooling: 0.55 },
|
||||
{ id: 'neon-tetra', label: 'Neon Tetra', color: '#4fe3ff', size: 6, speed: 1.45, schooling: 0.85 },
|
||||
{ id: 'angelfish', label: 'Angelfish', color: '#f2e06a', size: 14, speed: 0.7, schooling: 0.2 },
|
||||
{ id: 'guppy', label: 'Guppy', color: '#ff6fae', size: 7, speed: 1.3, schooling: 0.45 }
|
||||
];
|
||||
|
||||
var LIGHTING = [
|
||||
{ id: 'daylight', label: 'Daylight', tint: '#bfe9ff', ambient: 1.0, surface: 0.85 },
|
||||
{ id: 'twilight', label: 'Twilight', tint: '#6f7fd0', ambient: 0.62, surface: 0.5 },
|
||||
{ id: 'moonlight', label: 'Moonlight', tint: '#3d5a9c', ambient: 0.34, surface: 0.28 },
|
||||
{ id: 'blackwater', label: 'Blackwater', tint: '#2a1f3d', ambient: 0.18, surface: 0.12 }
|
||||
];
|
||||
|
||||
var FEED_AMOUNT = 0.22;
|
||||
var FEED_DECAY_PER_SECOND = 0.035;
|
||||
var MAX_FISH = 40;
|
||||
|
||||
function clamp(v, lo, hi) {
|
||||
return v < lo ? lo : (v > hi ? hi : v);
|
||||
}
|
||||
|
||||
function findById(list, id) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i].id === id) return list[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Tank model
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function Tank() {
|
||||
this.species = 'clownfish';
|
||||
this.population = 12;
|
||||
this.lighting = 'daylight';
|
||||
this.current = 0.35;
|
||||
this.temperature = 0.5;
|
||||
this.foodLevel = 0.0;
|
||||
this.paused = false;
|
||||
this.fish = [];
|
||||
this.bubbles = [];
|
||||
this.plants = [];
|
||||
this.time = 0;
|
||||
this._spawnAll();
|
||||
this._seedScenery();
|
||||
}
|
||||
|
||||
Tank.prototype._spawnAll = function () {
|
||||
this.fish = [];
|
||||
for (var i = 0; i < this.population; i++) this.fish.push(this._makeFish(i));
|
||||
};
|
||||
|
||||
Tank.prototype._makeFish = function (index) {
|
||||
var species = findById(SPECIES, this.species);
|
||||
var lane = (index % 5) / 5;
|
||||
return {
|
||||
x: Math.random(),
|
||||
y: 0.18 + lane * 0.62 + (Math.random() - 0.5) * 0.08,
|
||||
vx: (Math.random() < 0.5 ? -1 : 1) * (0.02 + Math.random() * 0.03),
|
||||
vy: (Math.random() - 0.5) * 0.012,
|
||||
phase: Math.random() * TAU,
|
||||
wobble: 0.6 + Math.random() * 0.8,
|
||||
size: species.size * (0.8 + Math.random() * 0.45),
|
||||
energy: 0.5 + Math.random() * 0.5
|
||||
};
|
||||
};
|
||||
|
||||
Tank.prototype._seedScenery = function () {
|
||||
this.plants = [];
|
||||
for (var i = 0; i < 7; i++) {
|
||||
this.plants.push({
|
||||
x: 0.06 + i * 0.14 + (Math.random() - 0.5) * 0.05,
|
||||
height: 0.18 + Math.random() * 0.3,
|
||||
phase: Math.random() * TAU,
|
||||
blades: 3 + Math.floor(Math.random() * 3)
|
||||
});
|
||||
}
|
||||
this.bubbles = [];
|
||||
for (var b = 0; b < 26; b++) {
|
||||
this.bubbles.push({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
r: 0.002 + Math.random() * 0.006,
|
||||
speed: 0.02 + Math.random() * 0.05,
|
||||
drift: Math.random() * TAU
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Simulation
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
Tank.prototype.step = function (dt) {
|
||||
if (this.paused) return;
|
||||
this.time += dt;
|
||||
|
||||
var species = findById(SPECIES, this.species);
|
||||
var light = findById(LIGHTING, this.lighting);
|
||||
|
||||
/* Food decays on its own, so a feed is a real, observable event rather
|
||||
* than a flag that stays set forever. */
|
||||
if (this.foodLevel > 0) {
|
||||
this.foodLevel = Math.max(0, this.foodLevel - FEED_DECAY_PER_SECOND * dt);
|
||||
}
|
||||
|
||||
var currentForce = (this.current - 0.5) * 0.09;
|
||||
var tempFactor = 0.6 + this.temperature * 0.9;
|
||||
var hunger = this.foodLevel;
|
||||
|
||||
for (var i = 0; i < this.fish.length; i++) {
|
||||
var f = this.fish[i];
|
||||
f.phase += dt * f.wobble * 2.2;
|
||||
|
||||
/* Schooling: drift toward the shoal's average position. */
|
||||
var avgX = 0, avgY = 0;
|
||||
for (var j = 0; j < this.fish.length; j++) {
|
||||
avgX += this.fish[j].x;
|
||||
avgY += this.fish[j].y;
|
||||
}
|
||||
avgX /= this.fish.length;
|
||||
avgY /= this.fish.length;
|
||||
|
||||
var pull = species.schooling * 0.35;
|
||||
f.vx += (avgX - f.x) * pull * dt;
|
||||
f.vy += (avgY - f.y) * pull * dt;
|
||||
|
||||
/* Feeding: food sinks from the surface, so fish rise toward it. */
|
||||
if (hunger > 0.01) {
|
||||
f.vy -= hunger * 0.5 * dt;
|
||||
f.energy = clamp(f.energy + hunger * dt * 0.6, 0, 1);
|
||||
}
|
||||
|
||||
/* Water current pushes horizontally. */
|
||||
f.vx += currentForce * dt;
|
||||
|
||||
/* Temperature sets overall activity. */
|
||||
var speed = species.speed * tempFactor * (0.7 + f.energy * 0.6);
|
||||
f.vx = clamp(f.vx, -0.09 * speed, 0.09 * speed);
|
||||
f.vy = clamp(f.vy, -0.05 * speed, 0.05 * speed);
|
||||
|
||||
f.x += f.vx * dt * 6;
|
||||
f.y += f.vy * dt * 6;
|
||||
|
||||
/* Wander and keep off the glass. */
|
||||
f.vy += Math.sin(f.phase) * 0.004 * dt;
|
||||
if (f.x < 0.04) { f.x = 0.04; f.vx = Math.abs(f.vx); }
|
||||
if (f.x > 0.96) { f.x = 0.96; f.vx = -Math.abs(f.vx); }
|
||||
if (f.y < 0.1) { f.y = 0.1; f.vy = Math.abs(f.vy); }
|
||||
if (f.y > 0.9) { f.y = 0.9; f.vy = -Math.abs(f.vy); }
|
||||
|
||||
f.energy = clamp(f.energy - dt * 0.02, 0.15, 1);
|
||||
}
|
||||
|
||||
/* Bubbles rise faster in warmer, more turbulent water. */
|
||||
var bubbleSpeed = 0.5 + this.temperature * 0.8 + Math.abs(this.current - 0.5) * 0.6;
|
||||
for (var b = 0; b < this.bubbles.length; b++) {
|
||||
var bubble = this.bubbles[b];
|
||||
bubble.y -= bubble.speed * bubbleSpeed * dt;
|
||||
bubble.drift += dt * 1.4;
|
||||
bubble.x += Math.sin(bubble.drift) * 0.004 * dt * 6;
|
||||
if (bubble.y < -0.02) {
|
||||
bubble.y = 1.02;
|
||||
bubble.x = Math.random();
|
||||
}
|
||||
}
|
||||
|
||||
/* Light level feeds back into the scene's own ambient value. */
|
||||
this.ambient = light.ambient;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Rendering
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function Renderer(canvas, tank) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.tank = tank;
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.resize();
|
||||
}
|
||||
|
||||
Renderer.prototype.resize = function () {
|
||||
var rect = this.canvas.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
this.width = Math.max(1, Math.round(rect.width));
|
||||
this.height = Math.max(1, Math.round(rect.height));
|
||||
this.canvas.width = Math.round(this.width * dpr);
|
||||
this.canvas.height = Math.round(this.height * dpr);
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
|
||||
Renderer.prototype.draw = function () {
|
||||
var ctx = this.ctx;
|
||||
var w = this.width;
|
||||
var h = this.height;
|
||||
var tank = this.tank;
|
||||
var light = findById(LIGHTING, tank.lighting);
|
||||
var species = findById(SPECIES, tank.species);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
/* Water column. */
|
||||
var grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, shade(light.tint, 0.55 * light.ambient + 0.12));
|
||||
grad.addColorStop(0.55, shade(light.tint, 0.3 * light.ambient + 0.06));
|
||||
grad.addColorStop(1, shade(light.tint, 0.12 * light.ambient + 0.03));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
/* Caustic light shafts from the surface. */
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.1 * light.surface;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
for (var s = 0; s < 6; s++) {
|
||||
var sx = ((s / 6) + Math.sin(tank.time * 0.25 + s) * 0.03) * w;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, 0);
|
||||
ctx.lineTo(sx + w * 0.05, 0);
|
||||
ctx.lineTo(sx + w * 0.12, h);
|
||||
ctx.lineTo(sx - w * 0.02, h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
/* Substrate. */
|
||||
ctx.fillStyle = shade(light.tint, 0.1);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h);
|
||||
for (var x = 0; x <= w; x += 12) {
|
||||
var y = h - 26 - Math.sin(x * 0.02) * 6 - Math.sin(x * 0.007) * 9;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(w, h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
/* Plants. */
|
||||
for (var p = 0; p < tank.plants.length; p++) {
|
||||
var plant = tank.plants[p];
|
||||
var baseX = plant.x * w;
|
||||
var baseY = h - 24;
|
||||
var sway = Math.sin(tank.time * 0.9 + plant.phase) * (0.02 + tank.current * 0.05);
|
||||
ctx.strokeStyle = shade('#2f7d4f', 0.35 + light.ambient * 0.5);
|
||||
ctx.lineWidth = 2.5;
|
||||
for (var bl = 0; bl < plant.blades; bl++) {
|
||||
var offset = (bl - (plant.blades - 1) / 2) * 7;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(baseX + offset, baseY);
|
||||
ctx.quadraticCurveTo(
|
||||
baseX + offset + sway * w * 0.5,
|
||||
baseY - plant.height * h * 0.6,
|
||||
baseX + offset + sway * w,
|
||||
baseY - plant.height * h
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/* Fish. */
|
||||
for (var i = 0; i < tank.fish.length; i++) {
|
||||
var f = tank.fish[i];
|
||||
var fx = f.x * w;
|
||||
var fy = f.y * h;
|
||||
var facing = f.vx >= 0 ? 1 : -1;
|
||||
var size = f.size * (0.85 + light.ambient * 0.3);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(fx, fy);
|
||||
ctx.scale(facing, 1);
|
||||
|
||||
/* Tail. */
|
||||
ctx.fillStyle = shade(species.color, 0.55 + light.ambient * 0.4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-size * 0.9, 0);
|
||||
ctx.lineTo(-size * 1.7, -size * 0.55 + Math.sin(f.phase) * size * 0.18);
|
||||
ctx.lineTo(-size * 1.7, size * 0.55 + Math.sin(f.phase) * size * 0.18);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
/* Body. */
|
||||
ctx.fillStyle = shade(species.color, 0.6 + light.ambient * 0.45);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, size, size * 0.52, 0, 0, TAU);
|
||||
ctx.fill();
|
||||
|
||||
/* Eye. */
|
||||
ctx.fillStyle = 'rgba(10,12,18,0.85)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(size * 0.5, -size * 0.1, Math.max(1, size * 0.13), 0, TAU);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* Bubbles. */
|
||||
ctx.strokeStyle = 'rgba(255,255,255,' + (0.12 + light.ambient * 0.22) + ')';
|
||||
ctx.lineWidth = 1;
|
||||
for (var b = 0; b < tank.bubbles.length; b++) {
|
||||
var bubble = tank.bubbles[b];
|
||||
ctx.beginPath();
|
||||
ctx.arc(bubble.x * w, bubble.y * h, bubble.r * w, 0, TAU);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* Food particles sinking from the surface. */
|
||||
if (tank.foodLevel > 0.01) {
|
||||
ctx.fillStyle = 'rgba(226,196,120,' + (0.35 + tank.foodLevel * 0.5) + ')';
|
||||
for (var k = 0; k < 40; k++) {
|
||||
var seed = k * 12.9898;
|
||||
var px = (Math.sin(seed) * 0.5 + 0.5) * w;
|
||||
var py = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * 0.5 + 0.5) * h;
|
||||
var sink = (tank.time * 0.06 + (k / 40)) % 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, (py * 0.3 + sink * 0.7) * h, 1.6, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/* Surface line. */
|
||||
ctx.strokeStyle = 'rgba(255,255,255,' + (0.1 + light.surface * 0.25) + ')';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
for (var sx2 = 0; sx2 <= w; sx2 += 8) {
|
||||
var sy = 8 + Math.sin(sx2 * 0.03 + tank.time * 1.6) * 3;
|
||||
if (sx2 === 0) ctx.moveTo(sx2, sy); else ctx.lineTo(sx2, sy);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
if (tank.paused) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||||
ctx.font = '600 13px "Segoe UI", sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('PAUSED', w / 2, h / 2);
|
||||
ctx.textAlign = 'start';
|
||||
}
|
||||
};
|
||||
|
||||
/** Lighten/darken a hex colour by a 0..1 factor. */
|
||||
function shade(hex, factor) {
|
||||
var n = parseInt(hex.slice(1), 16);
|
||||
var r = (n >> 16) & 255;
|
||||
var g = (n >> 8) & 255;
|
||||
var b = n & 255;
|
||||
var f = clamp(factor, 0, 1.6);
|
||||
r = Math.round(clamp(r * f, 0, 255));
|
||||
g = Math.round(clamp(g * f, 0, 255));
|
||||
b = Math.round(clamp(b * f, 0, 255));
|
||||
return 'rgb(' + r + ',' + g + ',' + b + ')';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Exhibit
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function AquariumExhibit(options) {
|
||||
this.canvas = options.canvas;
|
||||
this.tank = new Tank();
|
||||
this.renderer = new Renderer(this.canvas, this.tank);
|
||||
this.announcer = options.announcer;
|
||||
this.onChange = options.onChange || function () {};
|
||||
this._lastFrame = 0;
|
||||
this._running = false;
|
||||
this._boundFrame = this._frame.bind(this);
|
||||
}
|
||||
|
||||
AquariumExhibit.prototype.start = function () {
|
||||
if (this._running) return;
|
||||
this._running = true;
|
||||
this._lastFrame = performance.now();
|
||||
requestAnimationFrame(this._boundFrame);
|
||||
};
|
||||
|
||||
AquariumExhibit.prototype._frame = function (now) {
|
||||
if (!this._running) return;
|
||||
var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
|
||||
this._lastFrame = now;
|
||||
this.tank.step(dt);
|
||||
this.renderer.draw();
|
||||
requestAnimationFrame(this._boundFrame);
|
||||
};
|
||||
|
||||
AquariumExhibit.prototype.resize = function () {
|
||||
this.renderer.resize();
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Absolute, idempotent setters.
|
||||
*
|
||||
* Each returns { changed: boolean }. Returning changed:false for a no-op
|
||||
* is what lets the contract layer leave stateRevision untouched, and it is
|
||||
* also what stops a host-set value from being silently reverted the next
|
||||
* time a local control is touched (Authoring Guide §W).
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
AquariumExhibit.prototype.setSpecies = function (id) {
|
||||
if (this.tank.species === id) return { changed: false };
|
||||
this.tank.species = id;
|
||||
/* Restocking is a real consequence of changing species, so the fish are
|
||||
* respawned rather than recoloured in place. */
|
||||
this.tank._spawnAll();
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
AquariumExhibit.prototype.setPopulation = function (count) {
|
||||
count = Math.round(count);
|
||||
if (this.tank.population === count) return { changed: false };
|
||||
var previous = this.tank.population;
|
||||
this.tank.population = count;
|
||||
if (count > previous) {
|
||||
for (var i = previous; i < count; i++) this.tank.fish.push(this.tank._makeFish(i));
|
||||
} else {
|
||||
this.tank.fish.length = count;
|
||||
}
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
AquariumExhibit.prototype.setLighting = function (id) {
|
||||
if (this.tank.lighting === id) return { changed: false };
|
||||
this.tank.lighting = id;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
AquariumExhibit.prototype.setCurrent = function (value) {
|
||||
if (this.tank.current === value) return { changed: false };
|
||||
this.tank.current = value;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
AquariumExhibit.prototype.setTemperature = function (value) {
|
||||
if (this.tank.temperature === value) return { changed: false };
|
||||
this.tank.temperature = value;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
AquariumExhibit.prototype.setPaused = function (on) {
|
||||
if (this.tank.paused === on) return { changed: false };
|
||||
this.tank.paused = on;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Real actions.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
AquariumExhibit.prototype.feed = function () {
|
||||
this.tank.foodLevel = clamp(this.tank.foodLevel + FEED_AMOUNT, 0, 1);
|
||||
for (var i = 0; i < this.tank.fish.length; i++) {
|
||||
this.tank.fish[i].energy = clamp(this.tank.fish[i].energy + 0.25, 0, 1);
|
||||
}
|
||||
this.announcer.flash('Feeding — food dispersing through the water column.');
|
||||
this.onChange();
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
AquariumExhibit.prototype.startle = function () {
|
||||
for (var i = 0; i < this.tank.fish.length; i++) {
|
||||
var f = this.tank.fish[i];
|
||||
f.vx += (Math.random() - 0.5) * 0.12;
|
||||
f.vy += (Math.random() - 0.5) * 0.08;
|
||||
f.energy = 1;
|
||||
}
|
||||
this.announcer.flash('Startle — the shoal scatters.');
|
||||
this.onChange();
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
AquariumExhibit.prototype.cleanGlass = function () {
|
||||
this.tank.bubbles.length = 0;
|
||||
for (var b = 0; b < 26; b++) {
|
||||
this.tank.bubbles.push({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
r: 0.002 + Math.random() * 0.006,
|
||||
speed: 0.02 + Math.random() * 0.05,
|
||||
drift: Math.random() * TAU
|
||||
});
|
||||
}
|
||||
this.announcer.flash('Glass cleaned — clarity restored.');
|
||||
this.onChange();
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Reads
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
AquariumExhibit.prototype.read = function (targetId) {
|
||||
var t = this.tank;
|
||||
switch (targetId) {
|
||||
case 'tank.species': return t.species;
|
||||
case 'tank.population': return t.population;
|
||||
case 'tank.lighting': return t.lighting;
|
||||
case 'tank.current': return t.current;
|
||||
case 'tank.temperature': return t.temperature;
|
||||
case 'tank.paused': return t.paused;
|
||||
case 'telemetry.fish-count': return t.fish.length;
|
||||
case 'telemetry.food-level': return Math.round(t.foodLevel * 1000) / 1000;
|
||||
case 'telemetry.water-temp': return Math.round((18 + t.temperature * 10) * 10) / 10;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Native UI
|
||||
*
|
||||
* Every handler below calls the exhibit's own service method. None of them
|
||||
* dispatches a synthetic DOM event, and none of them writes state directly
|
||||
* (Authoring Guide §W).
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function AquariumUI(exhibit, announcer) {
|
||||
this.exhibit = exhibit;
|
||||
this.announcer = announcer;
|
||||
this.nodes = {
|
||||
species: document.getElementById('species-select'),
|
||||
population: document.getElementById('population-range'),
|
||||
populationReadout: document.getElementById('population-readout'),
|
||||
lighting: document.getElementById('lighting-select'),
|
||||
current: document.getElementById('current-range'),
|
||||
currentReadout: document.getElementById('current-readout'),
|
||||
temperature: document.getElementById('temperature-range'),
|
||||
temperatureReadout: document.getElementById('temperature-readout'),
|
||||
pause: document.getElementById('pause-button'),
|
||||
feed: document.getElementById('feed-button'),
|
||||
startle: document.getElementById('startle-button'),
|
||||
clean: document.getElementById('clean-button')
|
||||
};
|
||||
this._buildOptions();
|
||||
this._bind();
|
||||
this.sync();
|
||||
}
|
||||
|
||||
AquariumUI.prototype._buildOptions = function () {
|
||||
var speciesSelect = this.nodes.species;
|
||||
for (var i = 0; i < SPECIES.length; i++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = SPECIES[i].id;
|
||||
opt.textContent = SPECIES[i].label;
|
||||
speciesSelect.appendChild(opt);
|
||||
}
|
||||
var lightSelect = this.nodes.lighting;
|
||||
for (var j = 0; j < LIGHTING.length; j++) {
|
||||
var lopt = document.createElement('option');
|
||||
lopt.value = LIGHTING[j].id;
|
||||
lopt.textContent = LIGHTING[j].label;
|
||||
lightSelect.appendChild(lopt);
|
||||
}
|
||||
};
|
||||
|
||||
AquariumUI.prototype._bind = function () {
|
||||
var self = this;
|
||||
var ex = this.exhibit;
|
||||
|
||||
this.nodes.species.addEventListener('change', function (e) {
|
||||
self.dispatch('tank.species', e.target.value);
|
||||
});
|
||||
|
||||
this.nodes.population.addEventListener('input', function (e) {
|
||||
self.dispatch('tank.population', Number(e.target.value));
|
||||
});
|
||||
|
||||
this.nodes.lighting.addEventListener('change', function (e) {
|
||||
self.dispatch('tank.lighting', e.target.value);
|
||||
});
|
||||
|
||||
this.nodes.current.addEventListener('input', function (e) {
|
||||
self.dispatch('tank.current', Number(e.target.value));
|
||||
});
|
||||
|
||||
this.nodes.temperature.addEventListener('input', function (e) {
|
||||
self.dispatch('tank.temperature', Number(e.target.value));
|
||||
});
|
||||
|
||||
this.nodes.pause.addEventListener('click', function () {
|
||||
self.dispatch('tank.paused', !ex.tank.paused);
|
||||
});
|
||||
|
||||
this.nodes.feed.addEventListener('click', function () {
|
||||
self.invoke('action.feed');
|
||||
});
|
||||
this.nodes.startle.addEventListener('click', function () {
|
||||
self.invoke('action.startle');
|
||||
});
|
||||
this.nodes.clean.addEventListener('click', function () {
|
||||
self.invoke('action.clean-glass');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The UI's route into the canonical path. `dispatch` is installed by the
|
||||
* contract adapter; until then the UI still works, because the fallback
|
||||
* calls the exhibit service directly. Either way the exhibit's own setter
|
||||
* is the only thing that mutates state.
|
||||
*/
|
||||
AquariumUI.prototype.dispatch = function (targetId, value) {
|
||||
if (typeof this.dispatchOverride === 'function') {
|
||||
this.dispatchOverride(targetId, value);
|
||||
return;
|
||||
}
|
||||
this._fallbackSet(targetId, value);
|
||||
};
|
||||
|
||||
AquariumUI.prototype.invoke = function (targetId) {
|
||||
if (typeof this.invokeOverride === 'function') {
|
||||
this.invokeOverride(targetId);
|
||||
return;
|
||||
}
|
||||
this._fallbackInvoke(targetId);
|
||||
};
|
||||
|
||||
AquariumUI.prototype._fallbackSet = function (targetId, value) {
|
||||
var ex = this.exhibit;
|
||||
switch (targetId) {
|
||||
case 'tank.species': ex.setSpecies(value); break;
|
||||
case 'tank.population': ex.setPopulation(value); break;
|
||||
case 'tank.lighting': ex.setLighting(value); break;
|
||||
case 'tank.current': ex.setCurrent(value); break;
|
||||
case 'tank.temperature': ex.setTemperature(value); break;
|
||||
case 'tank.paused': ex.setPaused(value); break;
|
||||
default: break;
|
||||
}
|
||||
this.sync();
|
||||
};
|
||||
|
||||
AquariumUI.prototype._fallbackInvoke = function (targetId) {
|
||||
var ex = this.exhibit;
|
||||
if (targetId === 'action.feed') ex.feed();
|
||||
else if (targetId === 'action.startle') ex.startle();
|
||||
else if (targetId === 'action.clean-glass') ex.cleanGlass();
|
||||
this.sync();
|
||||
};
|
||||
|
||||
/** Reflect current exhibit state into the controls. */
|
||||
AquariumUI.prototype.sync = function () {
|
||||
var t = this.exhibit.tank;
|
||||
var n = this.nodes;
|
||||
if (n.species.value !== t.species) n.species.value = t.species;
|
||||
if (Number(n.population.value) !== t.population) n.population.value = String(t.population);
|
||||
if (n.lighting.value !== t.lighting) n.lighting.value = t.lighting;
|
||||
if (Number(n.current.value) !== t.current) n.current.value = String(t.current);
|
||||
if (Number(n.temperature.value) !== t.temperature) n.temperature.value = String(t.temperature);
|
||||
|
||||
n.populationReadout.textContent = t.population + ' fish';
|
||||
n.currentReadout.textContent = window.XZBTShell.formatPercent(t.current);
|
||||
n.temperatureReadout.textContent = window.XZBTShell.formatNumber(18 + t.temperature * 10, 1) + ' \u00b0C';
|
||||
n.pause.setAttribute('aria-pressed', t.paused ? 'true' : 'false');
|
||||
n.pause.textContent = t.paused ? 'Resume' : 'Pause';
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Exports
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
window.AquariumExhibit = AquariumExhibit;
|
||||
window.AquariumUI = AquariumUI;
|
||||
window.AquariumData = { SPECIES: SPECIES, LIGHTING: LIGHTING, MAX_FISH: MAX_FISH };
|
||||
})();
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Aquarium — XZBT Reference Exhibit</title>
|
||||
<link rel="stylesheet" href="../shared/exhibit-shell.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="exhibit">
|
||||
<header class="exhibit-header">
|
||||
<h1>Aquarium</h1>
|
||||
<span class="subtitle">Reef tank — live simulation</span>
|
||||
<span class="status" id="contract-status">standalone</span>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<canvas id="tank-canvas" aria-label="Aquarium view"></canvas>
|
||||
</main>
|
||||
|
||||
<aside class="panel">
|
||||
<section>
|
||||
<h2>Stocking</h2>
|
||||
<div class="control">
|
||||
<label for="species-select">Species</label>
|
||||
<select id="species-select"></select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="population-range">
|
||||
<span>Population</span>
|
||||
<span class="readout" id="population-readout">12 fish</span>
|
||||
</label>
|
||||
<input type="range" id="population-range" min="1" max="40" step="1" value="12">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Environment</h2>
|
||||
<div class="control">
|
||||
<label for="lighting-select">Lighting</label>
|
||||
<select id="lighting-select"></select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="current-range">
|
||||
<span>Water current</span>
|
||||
<span class="readout" id="current-readout">35%</span>
|
||||
</label>
|
||||
<input type="range" id="current-range" min="0" max="1" step="0.01" value="0.35">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="temperature-range">
|
||||
<span>Temperature</span>
|
||||
<span class="readout" id="temperature-readout">23.0 °C</span>
|
||||
</label>
|
||||
<input type="range" id="temperature-range" min="0" max="1" step="0.01" value="0.5">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Actions</h2>
|
||||
<div class="button-row">
|
||||
<button id="feed-button" type="button">Feed</button>
|
||||
<button id="startle-button" type="button">Startle</button>
|
||||
<button id="clean-button" type="button">Clean glass</button>
|
||||
</div>
|
||||
<div class="toggle-row" style="margin-top:12px">
|
||||
<span>Simulation</span>
|
||||
<button id="pause-button" type="button" aria-pressed="false">Pause</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<footer class="exhibit-footer">
|
||||
<span class="announcement-label">Tank</span>
|
||||
<span class="announcement-text is-idle" id="announcement">Tank settled — awaiting instruction.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="../shared/contract-core.js"></script>
|
||||
<script src="../shared/exhibit-shell.js"></script>
|
||||
<script src="../shared/host-transport.js"></script>
|
||||
<script src="exhibit.js"></script>
|
||||
<script src="contract-adapter.js"></script>
|
||||
<script src="boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Aquarium — domain styling only. Layout and controls come from the shell. */
|
||||
|
||||
body {
|
||||
background: #071018;
|
||||
color: #dff1ff;
|
||||
}
|
||||
|
||||
.exhibit-header {
|
||||
background: linear-gradient(90deg, rgba(30, 90, 130, 0.35), rgba(255, 255, 255, 0.03));
|
||||
border-color: rgba(120, 200, 255, 0.22);
|
||||
}
|
||||
|
||||
.exhibit-header h1 { color: #bfe9ff; }
|
||||
|
||||
.stage {
|
||||
border-color: rgba(120, 200, 255, 0.25);
|
||||
box-shadow: inset 0 0 60px rgba(0, 40, 70, 0.8);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(20, 60, 90, 0.22);
|
||||
border-color: rgba(120, 200, 255, 0.18);
|
||||
}
|
||||
|
||||
.panel h2 { color: #8fd4ff; }
|
||||
|
||||
input[type="range"] { accent-color: #4fc3ff; }
|
||||
|
||||
button {
|
||||
background: rgba(79, 195, 255, 0.12);
|
||||
border-color: rgba(120, 200, 255, 0.35);
|
||||
color: #dff1ff;
|
||||
}
|
||||
|
||||
button:hover { background: rgba(79, 195, 255, 0.24); }
|
||||
|
||||
button[aria-pressed="true"] {
|
||||
background: rgba(79, 195, 255, 0.4);
|
||||
border-color: rgba(160, 225, 255, 0.7);
|
||||
}
|
||||
|
||||
select {
|
||||
background: rgba(4, 24, 38, 0.85);
|
||||
border-color: rgba(120, 200, 255, 0.3);
|
||||
}
|
||||
|
||||
.exhibit-footer {
|
||||
background: rgba(20, 60, 90, 0.22);
|
||||
border-color: rgba(120, 200, 255, 0.18);
|
||||
}
|
||||
|
||||
.exhibit-footer .announcement-label { color: #8fd4ff; opacity: 0.7; }
|
||||
|
||||
.exhibit-header .status { color: #8fd4ff; }
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Haunted House — composition root. Last file to load.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Shell = window.XZBTShell;
|
||||
|
||||
var announcer = new Shell.Announcer({
|
||||
node: Shell.el('announcement'),
|
||||
idleText: 'The house is quiet \u2014 awaiting instruction.',
|
||||
maxLength: 120
|
||||
});
|
||||
|
||||
var exhibit = new window.HauntedHouseExhibit({
|
||||
canvas: Shell.el('room-canvas'),
|
||||
announcer: announcer
|
||||
});
|
||||
|
||||
var ui = new window.HauntedHouseUI(exhibit, announcer);
|
||||
|
||||
var core = window.HauntedHouseContract.create(exhibit, ui);
|
||||
|
||||
var transport = new window.XZBTHostTransport({
|
||||
core: core,
|
||||
onMessage: function (request, response) {
|
||||
if (response.type === 'hello.result') {
|
||||
Shell.setText(Shell.el('contract-status'), 'host attached');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
exhibit.start();
|
||||
|
||||
window.addEventListener('resize', function () { exhibit.resize(); });
|
||||
|
||||
window.__exhibit = {
|
||||
core: core,
|
||||
exhibit: exhibit,
|
||||
ui: ui,
|
||||
transport: transport,
|
||||
announcer: announcer
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Haunted House — XZBT Exhibit Contract 5.2 adapter.
|
||||
*
|
||||
* This adapter is the one that has to be honest about capabilities. The
|
||||
* exhibit's audio engine cannot start until the browser has seen a user
|
||||
* gesture, so `audio` is declared `available` at boot and moves to
|
||||
* `loading` then `ready` when it really does. The adapter mirrors the
|
||||
* exhibit's real state into the capability registry; it never invents a
|
||||
* transition (Authoring Guide §M).
|
||||
*
|
||||
* The audio-dependent actions are gated on that capability, so invoking
|
||||
* `action.moan` before the engine is ready returns CAPABILITY_UNAVAILABLE
|
||||
* rather than silently doing nothing.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Core = window.XZBTContractCore;
|
||||
|
||||
var IDENTITY = {
|
||||
product: 'Haunted House',
|
||||
version: '1.0.0',
|
||||
build: 'reference-exhibit'
|
||||
};
|
||||
|
||||
var DESCRIPTORS = [
|
||||
{
|
||||
id: 'room.current',
|
||||
kind: 'selection',
|
||||
label: 'Room',
|
||||
description: 'Room the visitor is standing in.',
|
||||
options: [
|
||||
{ value: 'foyer', label: 'Foyer' },
|
||||
{ value: 'library', label: 'Library' },
|
||||
{ value: 'cellar', label: 'Cellar' },
|
||||
{ value: 'attic', label: 'Attic' },
|
||||
{ value: 'nursery', label: 'Nursery' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'room', requires: []
|
||||
},
|
||||
{
|
||||
id: 'room.spirit',
|
||||
kind: 'selection',
|
||||
label: 'Spirit',
|
||||
description: 'Which presence is currently resident.',
|
||||
options: [
|
||||
{ value: 'restless', label: 'Restless' },
|
||||
{ value: 'vengeful', label: 'Vengeful' },
|
||||
{ value: 'mourning', label: 'Mourning' },
|
||||
{ value: 'mischievous', label: 'Mischievous' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'room', requires: []
|
||||
},
|
||||
{
|
||||
id: 'room.haunt-level',
|
||||
kind: 'range',
|
||||
label: 'Haunt level',
|
||||
description: 'Intensity of the haunting, 0 (dormant) to 1 (manifest).',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'room', requires: []
|
||||
},
|
||||
{
|
||||
id: 'room.fog-density',
|
||||
kind: 'range',
|
||||
label: 'Fog density',
|
||||
description: 'Density of the ground fog.',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'room', requires: []
|
||||
},
|
||||
{
|
||||
id: 'room.candlelight',
|
||||
kind: 'range',
|
||||
label: 'Candlelight',
|
||||
description: 'Strength of the candle pool of light.',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'room', requires: []
|
||||
},
|
||||
{
|
||||
id: 'room.audio-enabled',
|
||||
kind: 'state',
|
||||
valueType: 'boolean',
|
||||
label: 'Audio enabled',
|
||||
description: 'Whether the house is permitted to make sound.',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'room', requires: []
|
||||
},
|
||||
{
|
||||
id: 'room.paused',
|
||||
kind: 'state',
|
||||
valueType: 'boolean',
|
||||
label: 'Held',
|
||||
description: 'Whether the haunt simulation is frozen.',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'room', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.moan',
|
||||
kind: 'impulse',
|
||||
label: 'Moan',
|
||||
description: 'Emit a long descending moan.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: ['audio']
|
||||
},
|
||||
{
|
||||
id: 'action.slam-door',
|
||||
kind: 'impulse',
|
||||
label: 'Slam door',
|
||||
description: 'Slam a door somewhere in the house.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: ['audio']
|
||||
},
|
||||
{
|
||||
id: 'action.manifest',
|
||||
kind: 'impulse',
|
||||
label: 'Manifest',
|
||||
description: 'Bring the apparition to full manifestation.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.calm',
|
||||
kind: 'impulse',
|
||||
label: 'Calm',
|
||||
description: 'Settle the house back to dormancy.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.audio-state',
|
||||
kind: 'state',
|
||||
valueType: 'string',
|
||||
label: 'Audio engine state',
|
||||
description: 'Live state of the Web Audio engine.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.room-damp',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
label: 'Room damping',
|
||||
description: 'How strongly the current room suppresses the haunt.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
}
|
||||
];
|
||||
|
||||
function createHauntedHouseContract(exhibit, ui) {
|
||||
var catalog = new Core.Catalog(DESCRIPTORS);
|
||||
|
||||
var capabilities = new Core.CapabilityRegistry();
|
||||
capabilities.declare('render', 'ready');
|
||||
/* Honest initial state: the engine exists but the browser will not let
|
||||
* it start until a gesture arrives. */
|
||||
capabilities.declare('audio', 'available');
|
||||
|
||||
var setters = {
|
||||
'room.current': function (v) { return exhibit.setRoom(v); },
|
||||
'room.spirit': function (v) { return exhibit.setSpirit(v); },
|
||||
'room.haunt-level': function (v) { return exhibit.setHauntLevel(v); },
|
||||
'room.fog-density': function (v) { return exhibit.setFogDensity(v); },
|
||||
'room.candlelight': function (v) { return exhibit.setCandlelight(v); },
|
||||
'room.audio-enabled': function (v) { return exhibit.setAudioEnabled(v); },
|
||||
'room.paused': function (v) { return exhibit.setPaused(v); }
|
||||
};
|
||||
|
||||
var readers = {};
|
||||
var ids = catalog.ids();
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
(function (id) {
|
||||
readers[id] = function () { return exhibit.read(id); };
|
||||
})(ids[i]);
|
||||
}
|
||||
|
||||
var actions = {
|
||||
'action.moan': function () { return exhibit.moan(); },
|
||||
'action.slam-door': function () { return exhibit.slamDoor(); },
|
||||
'action.manifest': function () { return exhibit.manifest(); },
|
||||
'action.calm': function () { return exhibit.calm(); }
|
||||
};
|
||||
|
||||
var core = new Core.ContractCore({
|
||||
identity: IDENTITY,
|
||||
catalog: catalog,
|
||||
capabilities: capabilities,
|
||||
setters: setters,
|
||||
readers: readers,
|
||||
actions: actions
|
||||
});
|
||||
|
||||
/* Mirror the exhibit's real audio lifecycle into the contract. This is
|
||||
* a genuine state change driven by the browser, not a scripted one. */
|
||||
exhibit.audio.onStateChange = function (state) {
|
||||
core.setCapabilityState('audio', state);
|
||||
ui.sync();
|
||||
};
|
||||
|
||||
ui.dispatchOverride = function (targetId, value) {
|
||||
core.applyMutation(targetId, value, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
ui.invokeOverride = function (targetId) {
|
||||
core.invokeAction(targetId, {}, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
|
||||
exhibit.onChange = function () { ui.sync(); };
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
window.HauntedHouseContract = { create: createHauntedHouseContract, IDENTITY: IDENTITY };
|
||||
})();
|
||||
@@ -0,0 +1,764 @@
|
||||
/*
|
||||
* Haunted House — exhibit behaviour.
|
||||
*
|
||||
* Owns the room model, the haunt simulation, the canvas rendering, and the
|
||||
* native controls. No contract awareness.
|
||||
*
|
||||
* This exhibit is the one that exercises the capability lifecycle honestly:
|
||||
* its audio is synthesized with the Web Audio API, which browsers refuse to
|
||||
* start until the user has interacted with the page. So `audio` really does
|
||||
* begin `available`, really does go `loading` and then `ready` on the first
|
||||
* gesture, and really does report `error` if the context cannot be created.
|
||||
* Nothing here fakes a transition to make the contract look busy.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TAU = Math.PI * 2;
|
||||
|
||||
var ROOMS = [
|
||||
{ id: 'foyer', label: 'Foyer', width: 1.0, damp: 0.35, hue: '#6b5a3e' },
|
||||
{ id: 'library', label: 'Library', width: 0.9, damp: 0.6, hue: '#5a4630' },
|
||||
{ id: 'cellar', label: 'Cellar', width: 0.8, damp: 0.85, hue: '#3a3a44' },
|
||||
{ id: 'attic', label: 'Attic', width: 0.7, damp: 0.5, hue: '#4a4038' },
|
||||
{ id: 'nursery', label: 'Nursery', width: 0.75, damp: 0.4, hue: '#5c4a5a' }
|
||||
];
|
||||
|
||||
var SPIRITS = [
|
||||
{ id: 'restless', label: 'Restless', energy: 0.35, color: '#9fd8ff' },
|
||||
{ id: 'vengeful', label: 'Vengeful', energy: 0.75, color: '#ff8a6a' },
|
||||
{ id: 'mourning', label: 'Mourning', energy: 0.5, color: '#b9a6ff' },
|
||||
{ id: 'mischievous', label: 'Mischievous', energy: 0.6, color: '#8affc4' }
|
||||
];
|
||||
|
||||
var MAX_FOG = 1.0;
|
||||
|
||||
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
function findById(list, id) {
|
||||
for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
function hash(n) {
|
||||
var x = Math.sin(n * 91.7 + 47.3) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Room model
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function House() {
|
||||
this.room = 'foyer';
|
||||
this.spirit = 'restless';
|
||||
this.hauntLevel = 0.3;
|
||||
this.fogDensity = 0.25;
|
||||
this.candlelight = 0.55;
|
||||
this.audioEnabled = true;
|
||||
this.paused = false;
|
||||
this.time = 0;
|
||||
this.embers = [];
|
||||
this.dust = [];
|
||||
this._seed();
|
||||
}
|
||||
|
||||
House.prototype._seed = function () {
|
||||
this.embers = [];
|
||||
for (var i = 0; i < 18; i++) {
|
||||
this.embers.push({
|
||||
x: hash(i * 1.3),
|
||||
y: hash(i * 2.7),
|
||||
vy: -(0.01 + hash(i * 3.9) * 0.03),
|
||||
phase: hash(i * 5.1) * TAU
|
||||
});
|
||||
}
|
||||
this.dust = [];
|
||||
for (var d = 0; d < 60; d++) {
|
||||
this.dust.push({
|
||||
x: hash(d * 7.7),
|
||||
y: hash(d * 8.3),
|
||||
vx: (hash(d * 9.1) - 0.5) * 0.01,
|
||||
vy: (hash(d * 11.3) - 0.5) * 0.006,
|
||||
r: 0.4 + hash(d * 13.7) * 1.2
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
House.prototype.step = function (dt) {
|
||||
if (this.paused) return;
|
||||
this.time += dt;
|
||||
|
||||
var spirit = findById(SPIRITS, this.spirit);
|
||||
var room = findById(ROOMS, this.room);
|
||||
|
||||
/* The haunt level drifts toward the spirit's natural energy, damped by
|
||||
* the room. This is what makes the room and spirit selections matter
|
||||
* rather than being cosmetic. */
|
||||
var target = spirit.energy * (1 - room.damp * 0.5);
|
||||
this.hauntLevel += (target - this.hauntLevel) * dt * 0.35;
|
||||
|
||||
for (var i = 0; i < this.embers.length; i++) {
|
||||
var e = this.embers[i];
|
||||
e.y += e.vy * dt * (0.6 + this.hauntLevel);
|
||||
e.phase += dt * 2.2;
|
||||
if (e.y < -0.05) { e.y = 1.05; e.x = hash(i + this.time); }
|
||||
}
|
||||
|
||||
for (var d = 0; d < this.dust.length; d++) {
|
||||
var p = this.dust[d];
|
||||
p.x += p.vx * dt * (0.5 + this.hauntLevel * 2);
|
||||
p.y += p.vy * dt;
|
||||
if (p.x < 0) p.x = 1;
|
||||
if (p.x > 1) p.x = 0;
|
||||
if (p.y < 0) p.y = 1;
|
||||
if (p.y > 1) p.y = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Audio service
|
||||
*
|
||||
* A real Web Audio drone, and a real capability lifecycle. The context is
|
||||
* created lazily on the first user gesture because browsers require it.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function AudioService() {
|
||||
this.ctx = null;
|
||||
this.master = null;
|
||||
this.oscillators = [];
|
||||
this.state = 'available';
|
||||
this.onStateChange = function () {};
|
||||
}
|
||||
|
||||
AudioService.prototype._setState = function (state) {
|
||||
if (this.state === state) return;
|
||||
this.state = state;
|
||||
this.onStateChange(state);
|
||||
};
|
||||
|
||||
/** Called from a real user gesture. */
|
||||
AudioService.prototype.prepare = function () {
|
||||
if (this.ctx) {
|
||||
if (this.ctx.state === 'suspended') this.ctx.resume();
|
||||
this._setState('ready');
|
||||
return true;
|
||||
}
|
||||
this._setState('loading');
|
||||
try {
|
||||
var Ctor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctor) {
|
||||
this._setState('error');
|
||||
return false;
|
||||
}
|
||||
this.ctx = new Ctor();
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = 0.0;
|
||||
this.master.connect(this.ctx.destination);
|
||||
this._buildDrone();
|
||||
this._setState('ready');
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.ctx = null;
|
||||
this._setState('error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
AudioService.prototype._buildDrone = function () {
|
||||
var ctx = this.ctx;
|
||||
var freqs = [55, 82.5, 110, 164.8];
|
||||
for (var i = 0; i < freqs.length; i++) {
|
||||
var osc = ctx.createOscillator();
|
||||
osc.type = i % 2 === 0 ? 'sine' : 'triangle';
|
||||
osc.frequency.value = freqs[i];
|
||||
var gain = ctx.createGain();
|
||||
gain.gain.value = 0.12 / (i + 1);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.master);
|
||||
osc.start();
|
||||
this.oscillators.push({ osc: osc, gain: gain, base: freqs[i] });
|
||||
}
|
||||
};
|
||||
|
||||
/** Absolute, idempotent: sets the drone's intensity, not a delta. */
|
||||
AudioService.prototype.setIntensity = function (value) {
|
||||
if (!this.ctx || !this.master) return;
|
||||
var now = this.ctx.currentTime;
|
||||
this.master.gain.cancelScheduledValues(now);
|
||||
this.master.gain.setTargetAtTime(value * 0.5, now, 0.4);
|
||||
for (var i = 0; i < this.oscillators.length; i++) {
|
||||
var o = this.oscillators[i];
|
||||
o.osc.frequency.setTargetAtTime(o.base * (1 + value * 0.35), now, 0.6);
|
||||
}
|
||||
};
|
||||
|
||||
AudioService.prototype.moan = function () {
|
||||
if (!this.ctx || !this.master) return false;
|
||||
var ctx = this.ctx;
|
||||
var now = ctx.currentTime;
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
var filter = ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.value = 320;
|
||||
filter.Q.value = 6;
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(180, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(90, now + 1.6);
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.22, now + 0.25);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 1.8);
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(this.master);
|
||||
osc.start(now);
|
||||
osc.stop(now + 1.9);
|
||||
return true;
|
||||
};
|
||||
|
||||
AudioService.prototype.slam = function () {
|
||||
if (!this.ctx || !this.master) return false;
|
||||
var ctx = this.ctx;
|
||||
var now = ctx.currentTime;
|
||||
var bufferSize = Math.floor(ctx.sampleRate * 0.5);
|
||||
var buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
var data = buffer.getChannelData(0);
|
||||
for (var i = 0; i < bufferSize; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 3);
|
||||
}
|
||||
var src = ctx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
var filter = ctx.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 420;
|
||||
var gain = ctx.createGain();
|
||||
gain.gain.value = 0.5;
|
||||
src.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(this.master);
|
||||
src.start(now);
|
||||
return true;
|
||||
};
|
||||
|
||||
AudioService.prototype.stop = function () {
|
||||
if (!this.ctx) return;
|
||||
for (var i = 0; i < this.oscillators.length; i++) {
|
||||
try { this.oscillators[i].osc.stop(); } catch (e) { /* already stopped */ }
|
||||
}
|
||||
this.oscillators = [];
|
||||
try { this.ctx.close(); } catch (e) { /* already closed */ }
|
||||
this.ctx = null;
|
||||
this.master = null;
|
||||
this._setState('available');
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Rendering
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function Renderer(canvas, house) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.house = house;
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.resize();
|
||||
}
|
||||
|
||||
Renderer.prototype.resize = function () {
|
||||
var rect = this.canvas.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
this.width = Math.max(1, Math.round(rect.width));
|
||||
this.height = Math.max(1, Math.round(rect.height));
|
||||
this.canvas.width = Math.round(this.width * dpr);
|
||||
this.canvas.height = Math.round(this.height * dpr);
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
|
||||
Renderer.prototype.draw = function () {
|
||||
var ctx = this.ctx;
|
||||
var w = this.width;
|
||||
var h = this.height;
|
||||
var house = this.house;
|
||||
var room = findById(ROOMS, house.room);
|
||||
var spirit = findById(SPIRITS, house.spirit);
|
||||
var light = house.candlelight;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
/* Room shell. */
|
||||
var grad = ctx.createLinearGradient(0, 0, 0, h);
|
||||
grad.addColorStop(0, shade(room.hue, 0.35 + light * 0.5));
|
||||
grad.addColorStop(1, shade(room.hue, 0.12 + light * 0.2));
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
/* Back wall panelling. */
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.28)';
|
||||
ctx.lineWidth = 1;
|
||||
for (var x = 0; x < w; x += 46) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* Window, brighter with candlelight. */
|
||||
var winW = w * 0.22;
|
||||
var winH = h * 0.3;
|
||||
var winX = w * 0.62;
|
||||
var winY = h * 0.16;
|
||||
ctx.fillStyle = 'rgba(120,150,190,' + (0.1 + light * 0.35) + ')';
|
||||
ctx.fillRect(winX, winY, winW, winH);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(winX, winY, winW, winH);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(winX + winW / 2, winY);
|
||||
ctx.lineTo(winX + winW / 2, winY + winH);
|
||||
ctx.moveTo(winX, winY + winH / 2);
|
||||
ctx.lineTo(winX + winW, winY + winH / 2);
|
||||
ctx.stroke();
|
||||
|
||||
/* Floor. */
|
||||
ctx.fillStyle = shade(room.hue, 0.08 + light * 0.12);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h);
|
||||
ctx.lineTo(w * 0.12, h * 0.72);
|
||||
ctx.lineTo(w * 0.88, h * 0.72);
|
||||
ctx.lineTo(w, h);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
/* Candle pool of light. */
|
||||
var pool = ctx.createRadialGradient(w * 0.3, h * 0.62, 0, w * 0.3, h * 0.62, Math.min(w, h) * 0.55);
|
||||
pool.addColorStop(0, 'rgba(255,196,110,' + (0.28 * light) + ')');
|
||||
pool.addColorStop(1, 'rgba(255,196,110,0)');
|
||||
ctx.fillStyle = pool;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
/* Fog. */
|
||||
if (house.fogDensity > 0.01) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = clamp(house.fogDensity * 0.5, 0, 0.6);
|
||||
for (var f = 0; f < 5; f++) {
|
||||
var fy = h * (0.55 + f * 0.09);
|
||||
var fx = ((house.time * (0.01 + f * 0.004)) % 1) * w;
|
||||
var fogGrad = ctx.createRadialGradient(fx, fy, 0, fx, fy, w * 0.4);
|
||||
fogGrad.addColorStop(0, 'rgba(190,200,215,0.5)');
|
||||
fogGrad.addColorStop(1, 'rgba(190,200,215,0)');
|
||||
ctx.fillStyle = fogGrad;
|
||||
ctx.fillRect(0, fy - h * 0.2, w, h * 0.4);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/* The apparition. Its opacity and size follow the haunt level, so the
|
||||
* haunt level is visible rather than merely reported. */
|
||||
var apparitionAlpha = clamp(house.hauntLevel * 0.85, 0, 0.85);
|
||||
if (apparitionAlpha > 0.02) {
|
||||
var ax = w * (0.5 + Math.sin(house.time * 0.35) * 0.12);
|
||||
var ay = h * (0.42 + Math.sin(house.time * 0.21) * 0.05);
|
||||
var ar = Math.min(w, h) * (0.1 + house.hauntLevel * 0.12);
|
||||
|
||||
var aura = ctx.createRadialGradient(ax, ay, 0, ax, ay, ar * 2.4);
|
||||
aura.addColorStop(0, hexToRgba(spirit.color, apparitionAlpha * 0.75));
|
||||
aura.addColorStop(1, hexToRgba(spirit.color, 0));
|
||||
ctx.fillStyle = aura;
|
||||
ctx.beginPath();
|
||||
ctx.arc(ax, ay, ar * 2.4, 0, TAU);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = hexToRgba(spirit.color, apparitionAlpha);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(ax, ay, ar * 0.55, ar, 0, 0, TAU);
|
||||
ctx.fill();
|
||||
|
||||
/* Trailing veil. */
|
||||
ctx.strokeStyle = hexToRgba(spirit.color, apparitionAlpha * 0.5);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
for (var t = 0; t <= 1; t += 0.05) {
|
||||
var tx = ax + Math.sin(house.time * 1.4 + t * 6) * ar * 0.5;
|
||||
var ty = ay + ar + t * ar * 1.6;
|
||||
if (t === 0) ctx.moveTo(tx, ty); else ctx.lineTo(tx, ty);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* Embers. */
|
||||
for (var e = 0; e < house.embers.length; e++) {
|
||||
var em = house.embers[e];
|
||||
var ea = 0.25 + Math.sin(em.phase) * 0.2;
|
||||
ctx.fillStyle = 'rgba(255,170,90,' + clamp(ea * light * 2, 0, 0.9) + ')';
|
||||
ctx.beginPath();
|
||||
ctx.arc(em.x * w, em.y * h, 1.6, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/* Dust motes. */
|
||||
ctx.fillStyle = 'rgba(230,230,240,0.18)';
|
||||
for (var d = 0; d < house.dust.length; d++) {
|
||||
var p = house.dust[d];
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x * w, p.y * h, p.r, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/* Vignette. */
|
||||
var vig = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.25, w / 2, h / 2, Math.max(w, h) * 0.75);
|
||||
vig.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
vig.addColorStop(1, 'rgba(0,0,0,0.72)');
|
||||
ctx.fillStyle = vig;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
/* Room label. */
|
||||
ctx.fillStyle = 'rgba(240,230,210,0.7)';
|
||||
ctx.font = '600 12px "Segoe UI", sans-serif';
|
||||
ctx.fillText(room.label.toUpperCase(), 14, 24);
|
||||
|
||||
if (house.paused) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
ctx.font = '600 13px "Segoe UI", sans-serif';
|
||||
ctx.fillText('HELD', 14, 44);
|
||||
}
|
||||
};
|
||||
|
||||
function shade(hex, factor) {
|
||||
var n = parseInt(hex.slice(1), 16);
|
||||
var r = clamp(Math.round(((n >> 16) & 255) * factor), 0, 255);
|
||||
var g = clamp(Math.round(((n >> 8) & 255) * factor), 0, 255);
|
||||
var b = clamp(Math.round((n & 255) * factor), 0, 255);
|
||||
return 'rgb(' + r + ',' + g + ',' + b + ')';
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha) {
|
||||
var n = parseInt(hex.slice(1), 16);
|
||||
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Exhibit
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function HauntedHouseExhibit(options) {
|
||||
this.canvas = options.canvas;
|
||||
this.house = new House();
|
||||
this.audio = new AudioService();
|
||||
this.renderer = new Renderer(this.canvas, this.house);
|
||||
this.announcer = options.announcer;
|
||||
this.onChange = options.onChange || function () {};
|
||||
this._lastFrame = 0;
|
||||
this._running = false;
|
||||
this._boundFrame = this._frame.bind(this);
|
||||
}
|
||||
|
||||
HauntedHouseExhibit.prototype.start = function () {
|
||||
if (this._running) return;
|
||||
this._running = true;
|
||||
this._lastFrame = performance.now();
|
||||
requestAnimationFrame(this._boundFrame);
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype._frame = function (now) {
|
||||
if (!this._running) return;
|
||||
var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
|
||||
this._lastFrame = now;
|
||||
this.house.step(dt);
|
||||
this.renderer.draw();
|
||||
requestAnimationFrame(this._boundFrame);
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.resize = function () { this.renderer.resize(); };
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Absolute, idempotent setters
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
HauntedHouseExhibit.prototype.setRoom = function (id) {
|
||||
if (this.house.room === id) return { changed: false };
|
||||
this.house.room = id;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.setSpirit = function (id) {
|
||||
if (this.house.spirit === id) return { changed: false };
|
||||
this.house.spirit = id;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.setHauntLevel = function (value) {
|
||||
if (this.house.hauntLevel === value) return { changed: false };
|
||||
this.house.hauntLevel = value;
|
||||
this.audio.setIntensity(value);
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.setFogDensity = function (value) {
|
||||
if (this.house.fogDensity === value) return { changed: false };
|
||||
this.house.fogDensity = value;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.setCandlelight = function (value) {
|
||||
if (this.house.candlelight === value) return { changed: false };
|
||||
this.house.candlelight = value;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.setAudioEnabled = function (on) {
|
||||
if (this.house.audioEnabled === on) return { changed: false };
|
||||
this.house.audioEnabled = on;
|
||||
if (!on) this.audio.setIntensity(0);
|
||||
else this.audio.setIntensity(this.house.hauntLevel);
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.setPaused = function (on) {
|
||||
if (this.house.paused === on) return { changed: false };
|
||||
this.house.paused = on;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Real actions
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
HauntedHouseExhibit.prototype.moan = function () {
|
||||
if (!this.house.audioEnabled) {
|
||||
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'Audio is disabled.' };
|
||||
}
|
||||
if (!this.audio.moan()) {
|
||||
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'The audio engine is not ready.' };
|
||||
}
|
||||
this.announcer.flash('A long moan rolls through the ' + this.house.room + '.');
|
||||
this.onChange();
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.slamDoor = function () {
|
||||
if (!this.house.audioEnabled) {
|
||||
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'Audio is disabled.' };
|
||||
}
|
||||
if (!this.audio.slam()) {
|
||||
return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'The audio engine is not ready.' };
|
||||
}
|
||||
this.house.hauntLevel = clamp(this.house.hauntLevel + 0.12, 0, 1);
|
||||
this.announcer.flash('A door slams somewhere above.');
|
||||
this.onChange();
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.manifest = function () {
|
||||
this.house.hauntLevel = 1;
|
||||
this.audio.setIntensity(1);
|
||||
this.announcer.flash('The apparition manifests fully.');
|
||||
this.onChange();
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype.calm = function () {
|
||||
this.house.hauntLevel = 0;
|
||||
this.audio.setIntensity(0);
|
||||
this.announcer.flash('The house settles into silence.');
|
||||
this.onChange();
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Reads
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
HauntedHouseExhibit.prototype.read = function (targetId) {
|
||||
var h = this.house;
|
||||
switch (targetId) {
|
||||
case 'room.current': return h.room;
|
||||
case 'room.spirit': return h.spirit;
|
||||
case 'room.haunt-level': return Math.round(h.hauntLevel * 1000) / 1000;
|
||||
case 'room.fog-density': return h.fogDensity;
|
||||
case 'room.candlelight': return h.candlelight;
|
||||
case 'room.audio-enabled': return h.audioEnabled;
|
||||
case 'room.paused': return h.paused;
|
||||
case 'telemetry.audio-state': return this.audio.state;
|
||||
case 'telemetry.manifestations': return this._manifestations;
|
||||
case 'telemetry.room-damp': return findById(ROOMS, h.room).damp;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
HauntedHouseExhibit.prototype._manifestations = 0;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Native UI
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function HauntedHouseUI(exhibit, announcer) {
|
||||
this.exhibit = exhibit;
|
||||
this.announcer = announcer;
|
||||
this.nodes = {
|
||||
room: document.getElementById('room-select'),
|
||||
spirit: document.getElementById('spirit-select'),
|
||||
haunt: document.getElementById('haunt-range'),
|
||||
hauntReadout: document.getElementById('haunt-readout'),
|
||||
fog: document.getElementById('fog-range'),
|
||||
fogReadout: document.getElementById('fog-readout'),
|
||||
candle: document.getElementById('candle-range'),
|
||||
candleReadout: document.getElementById('candle-readout'),
|
||||
audio: document.getElementById('audio-button'),
|
||||
pause: document.getElementById('pause-button'),
|
||||
moan: document.getElementById('moan-button'),
|
||||
slam: document.getElementById('slam-button'),
|
||||
manifest: document.getElementById('manifest-button'),
|
||||
calm: document.getElementById('calm-button'),
|
||||
audioState: document.getElementById('audio-state')
|
||||
};
|
||||
this._buildOptions();
|
||||
this._bind();
|
||||
this.sync();
|
||||
}
|
||||
|
||||
HauntedHouseUI.prototype._buildOptions = function () {
|
||||
var r = this.nodes.room;
|
||||
for (var i = 0; i < ROOMS.length; i++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = ROOMS[i].id;
|
||||
opt.textContent = ROOMS[i].label;
|
||||
r.appendChild(opt);
|
||||
}
|
||||
var s = this.nodes.spirit;
|
||||
for (var j = 0; j < SPIRITS.length; j++) {
|
||||
var sopt = document.createElement('option');
|
||||
sopt.value = SPIRITS[j].id;
|
||||
sopt.textContent = SPIRITS[j].label;
|
||||
s.appendChild(sopt);
|
||||
}
|
||||
};
|
||||
|
||||
HauntedHouseUI.prototype._bind = function () {
|
||||
var self = this;
|
||||
var ex = this.exhibit;
|
||||
|
||||
this.nodes.room.addEventListener('change', function (e) {
|
||||
self.dispatch('room.current', e.target.value);
|
||||
});
|
||||
this.nodes.spirit.addEventListener('change', function (e) {
|
||||
self.dispatch('room.spirit', e.target.value);
|
||||
});
|
||||
this.nodes.haunt.addEventListener('input', function (e) {
|
||||
self.dispatch('room.haunt-level', Number(e.target.value));
|
||||
});
|
||||
this.nodes.fog.addEventListener('input', function (e) {
|
||||
self.dispatch('room.fog-density', Number(e.target.value));
|
||||
});
|
||||
this.nodes.candle.addEventListener('input', function (e) {
|
||||
self.dispatch('room.candlelight', Number(e.target.value));
|
||||
});
|
||||
this.nodes.audio.addEventListener('click', function () {
|
||||
self.dispatch('room.audio-enabled', !ex.house.audioEnabled);
|
||||
});
|
||||
this.nodes.pause.addEventListener('click', function () {
|
||||
self.dispatch('room.paused', !ex.house.paused);
|
||||
});
|
||||
|
||||
/* The first real gesture is what unlocks the audio engine. This is the
|
||||
* browser's rule, not the exhibit's, so the capability genuinely moves
|
||||
* available -> loading -> ready here. */
|
||||
this.nodes.moan.addEventListener('click', function () {
|
||||
self._ensureAudio();
|
||||
self.invoke('action.moan');
|
||||
});
|
||||
this.nodes.slam.addEventListener('click', function () {
|
||||
self._ensureAudio();
|
||||
self.invoke('action.slam-door');
|
||||
});
|
||||
this.nodes.manifest.addEventListener('click', function () {
|
||||
self._ensureAudio();
|
||||
self.invoke('action.manifest');
|
||||
});
|
||||
this.nodes.calm.addEventListener('click', function () {
|
||||
self.invoke('action.calm');
|
||||
});
|
||||
};
|
||||
|
||||
HauntedHouseUI.prototype._ensureAudio = function () {
|
||||
if (this.exhibit.audio.state === 'ready') return;
|
||||
this.exhibit.audio.prepare();
|
||||
this.sync();
|
||||
};
|
||||
|
||||
HauntedHouseUI.prototype.dispatch = function (targetId, value) {
|
||||
if (typeof this.dispatchOverride === 'function') {
|
||||
this.dispatchOverride(targetId, value);
|
||||
return;
|
||||
}
|
||||
this._fallbackSet(targetId, value);
|
||||
};
|
||||
|
||||
HauntedHouseUI.prototype.invoke = function (targetId) {
|
||||
if (typeof this.invokeOverride === 'function') {
|
||||
this.invokeOverride(targetId);
|
||||
return;
|
||||
}
|
||||
this._fallbackInvoke(targetId);
|
||||
};
|
||||
|
||||
HauntedHouseUI.prototype._fallbackSet = function (targetId, value) {
|
||||
var ex = this.exhibit;
|
||||
switch (targetId) {
|
||||
case 'room.current': ex.setRoom(value); break;
|
||||
case 'room.spirit': ex.setSpirit(value); break;
|
||||
case 'room.haunt-level': ex.setHauntLevel(value); break;
|
||||
case 'room.fog-density': ex.setFogDensity(value); break;
|
||||
case 'room.candlelight': ex.setCandlelight(value); break;
|
||||
case 'room.audio-enabled': ex.setAudioEnabled(value); break;
|
||||
case 'room.paused': ex.setPaused(value); break;
|
||||
default: break;
|
||||
}
|
||||
this.sync();
|
||||
};
|
||||
|
||||
HauntedHouseUI.prototype._fallbackInvoke = function (targetId) {
|
||||
var ex = this.exhibit;
|
||||
if (targetId === 'action.moan') ex.moan();
|
||||
else if (targetId === 'action.slam-door') ex.slamDoor();
|
||||
else if (targetId === 'action.manifest') ex.manifest();
|
||||
else if (targetId === 'action.calm') ex.calm();
|
||||
this.sync();
|
||||
};
|
||||
|
||||
HauntedHouseUI.prototype.sync = function () {
|
||||
var h = this.exhibit.house;
|
||||
var n = this.nodes;
|
||||
var Shell = window.XZBTShell;
|
||||
|
||||
if (n.room.value !== h.room) n.room.value = h.room;
|
||||
if (n.spirit.value !== h.spirit) n.spirit.value = h.spirit;
|
||||
if (Number(n.haunt.value) !== h.hauntLevel) n.haunt.value = String(h.hauntLevel);
|
||||
if (Number(n.fog.value) !== h.fogDensity) n.fog.value = String(h.fogDensity);
|
||||
if (Number(n.candle.value) !== h.candlelight) n.candle.value = String(h.candlelight);
|
||||
|
||||
n.hauntReadout.textContent = Shell.formatPercent(h.hauntLevel);
|
||||
n.fogReadout.textContent = Shell.formatPercent(h.fogDensity);
|
||||
n.candleReadout.textContent = Shell.formatPercent(h.candlelight);
|
||||
n.audio.setAttribute('aria-pressed', h.audioEnabled ? 'true' : 'false');
|
||||
n.audio.textContent = h.audioEnabled ? 'Audio on' : 'Audio off';
|
||||
n.pause.setAttribute('aria-pressed', h.paused ? 'true' : 'false');
|
||||
n.pause.textContent = h.paused ? 'Resume' : 'Hold';
|
||||
|
||||
var state = this.exhibit.audio.state;
|
||||
n.audioState.textContent = state;
|
||||
n.audioState.setAttribute('data-state', state);
|
||||
};
|
||||
|
||||
window.HauntedHouseExhibit = HauntedHouseExhibit;
|
||||
window.HauntedHouseUI = HauntedHouseUI;
|
||||
window.HauntedHouseData = { ROOMS: ROOMS, SPIRITS: SPIRITS };
|
||||
})();
|
||||
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Haunted House — XZBT Reference Exhibit</title>
|
||||
<link rel="stylesheet" href="../shared/exhibit-shell.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="exhibit">
|
||||
<header class="exhibit-header">
|
||||
<h1>Haunted House</h1>
|
||||
<span class="subtitle">Fifth room — live haunt</span>
|
||||
<span class="status" id="contract-status">standalone</span>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<canvas id="room-canvas" aria-label="Haunted house room view"></canvas>
|
||||
</main>
|
||||
|
||||
<aside class="panel">
|
||||
<section>
|
||||
<h2>Presence</h2>
|
||||
<div class="control">
|
||||
<label for="room-select">Room</label>
|
||||
<select id="room-select"></select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="spirit-select">Spirit</label>
|
||||
<select id="spirit-select"></select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="haunt-range">
|
||||
<span>Haunt level</span>
|
||||
<span class="readout" id="haunt-readout">30%</span>
|
||||
</label>
|
||||
<input type="range" id="haunt-range" min="0" max="1" step="0.01" value="0.3">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Atmosphere</h2>
|
||||
<div class="control">
|
||||
<label for="fog-range">
|
||||
<span>Fog density</span>
|
||||
<span class="readout" id="fog-readout">25%</span>
|
||||
</label>
|
||||
<input type="range" id="fog-range" min="0" max="1" step="0.01" value="0.25">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="candle-range">
|
||||
<span>Candlelight</span>
|
||||
<span class="readout" id="candle-readout">55%</span>
|
||||
</label>
|
||||
<input type="range" id="candle-range" min="0" max="1" step="0.01" value="0.55">
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<span>Audio engine: <strong id="audio-state" data-state="available">available</strong></span>
|
||||
<button id="audio-button" type="button" aria-pressed="true">Audio on</button>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<span>Simulation</span>
|
||||
<button id="pause-button" type="button" aria-pressed="false">Hold</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Actions</h2>
|
||||
<div class="button-row">
|
||||
<button id="moan-button" type="button">Moan</button>
|
||||
<button id="slam-button" type="button">Slam door</button>
|
||||
</div>
|
||||
<div class="button-row" style="margin-top:6px">
|
||||
<button id="manifest-button" type="button">Manifest</button>
|
||||
<button id="calm-button" type="button">Calm</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<footer class="exhibit-footer">
|
||||
<span class="announcement-label">House</span>
|
||||
<span class="announcement-text is-idle" id="announcement">The house is quiet — awaiting instruction.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="../shared/contract-core.js"></script>
|
||||
<script src="../shared/exhibit-shell.js"></script>
|
||||
<script src="../shared/host-transport.js"></script>
|
||||
<script src="exhibit.js"></script>
|
||||
<script src="contract-adapter.js"></script>
|
||||
<script src="boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Haunted House — domain styling only. */
|
||||
|
||||
body {
|
||||
background: #0d0a08;
|
||||
color: #f0e6d8;
|
||||
}
|
||||
|
||||
.exhibit-header {
|
||||
background: linear-gradient(90deg, rgba(120, 70, 30, 0.35), rgba(255, 255, 255, 0.03));
|
||||
border-color: rgba(220, 170, 110, 0.22);
|
||||
}
|
||||
|
||||
.exhibit-header h1 { color: #f0d9b5; }
|
||||
|
||||
.stage {
|
||||
border-color: rgba(220, 170, 110, 0.25);
|
||||
box-shadow: inset 0 0 70px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(90, 60, 30, 0.2);
|
||||
border-color: rgba(220, 170, 110, 0.18);
|
||||
}
|
||||
|
||||
.panel h2 { color: #e0b98a; }
|
||||
|
||||
input[type="range"] { accent-color: #e0a860; }
|
||||
|
||||
button {
|
||||
background: rgba(224, 168, 96, 0.12);
|
||||
border-color: rgba(220, 170, 110, 0.35);
|
||||
color: #f0e6d8;
|
||||
}
|
||||
|
||||
button:hover { background: rgba(224, 168, 96, 0.24); }
|
||||
|
||||
button[aria-pressed="true"] {
|
||||
background: rgba(224, 168, 96, 0.4);
|
||||
border-color: rgba(245, 210, 160, 0.7);
|
||||
}
|
||||
|
||||
select {
|
||||
background: rgba(20, 14, 10, 0.85);
|
||||
border-color: rgba(220, 170, 110, 0.3);
|
||||
}
|
||||
|
||||
.exhibit-footer {
|
||||
background: rgba(90, 60, 30, 0.2);
|
||||
border-color: rgba(220, 170, 110, 0.18);
|
||||
}
|
||||
|
||||
.exhibit-footer .announcement-label { color: #e0b98a; opacity: 0.7; }
|
||||
|
||||
.exhibit-header .status { color: #e0b98a; }
|
||||
|
||||
/* The audio engine state is a real capability, so it is shown plainly. */
|
||||
#audio-state {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
#audio-state[data-state="available"] { color: #d8c090; }
|
||||
#audio-state[data-state="loading"] { color: #ffd27a; }
|
||||
#audio-state[data-state="ready"] { color: #9fe0a0; }
|
||||
#audio-state[data-state="error"] { color: #ff8a7a; }
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Planetarium — composition root. Last file to load.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Shell = window.XZBTShell;
|
||||
|
||||
var announcer = new Shell.Announcer({
|
||||
node: Shell.el('announcement'),
|
||||
idleText: 'Dome idle \u2014 awaiting instruction.',
|
||||
maxLength: 120
|
||||
});
|
||||
|
||||
var exhibit = new window.PlanetariumExhibit({
|
||||
canvas: Shell.el('sky-canvas'),
|
||||
announcer: announcer
|
||||
});
|
||||
|
||||
var ui = new window.PlanetariumUI(exhibit, announcer);
|
||||
|
||||
var core = window.PlanetariumContract.create(exhibit, ui);
|
||||
|
||||
var transport = new window.XZBTHostTransport({
|
||||
core: core,
|
||||
onMessage: function (request, response) {
|
||||
if (response.type === 'hello.result') {
|
||||
Shell.setText(Shell.el('contract-status'), 'host attached');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
exhibit.start();
|
||||
|
||||
window.addEventListener('resize', function () { exhibit.resize(); });
|
||||
|
||||
window.__exhibit = {
|
||||
core: core,
|
||||
exhibit: exhibit,
|
||||
ui: ui,
|
||||
transport: transport,
|
||||
announcer: announcer
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Planetarium — XZBT Exhibit Contract 5.2 adapter.
|
||||
*
|
||||
* Declares the catalog, binds targets to real exhibit services, and routes
|
||||
* the native UI through the canonical mutation path. No state of its own.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Core = window.XZBTContractCore;
|
||||
|
||||
var IDENTITY = {
|
||||
product: 'Planetarium',
|
||||
version: '1.0.0',
|
||||
build: 'reference-exhibit'
|
||||
};
|
||||
|
||||
var DESCRIPTORS = [
|
||||
{
|
||||
id: 'sky.constellation',
|
||||
kind: 'selection',
|
||||
label: 'Constellation',
|
||||
description: 'Constellation currently tracked and drawn.',
|
||||
options: [
|
||||
{ value: 'orion', label: 'Orion' },
|
||||
{ value: 'cygnus', label: 'Cygnus' },
|
||||
{ value: 'scorpius', label: 'Scorpius' },
|
||||
{ value: 'ursa-major', label: 'Ursa Major' },
|
||||
{ value: 'cassiopeia', label: 'Cassiopeia' },
|
||||
{ value: 'lyra', label: 'Lyra' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.magnitude-limit',
|
||||
kind: 'range',
|
||||
label: 'Magnitude limit',
|
||||
description: 'Faintest stellar magnitude rendered. Higher values show more stars.',
|
||||
min: 1, max: 6, step: 0.1, unit: 'mag',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.azimuth',
|
||||
kind: 'range',
|
||||
label: 'Azimuth',
|
||||
description: 'Compass bearing of the view, 0 to 1 of a full turn.',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.altitude',
|
||||
kind: 'range',
|
||||
label: 'Altitude',
|
||||
description: 'Elevation of the view above the horizon, 0 to 1.',
|
||||
min: 0, max: 1, step: 0.01,
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.time-scale',
|
||||
kind: 'range',
|
||||
label: 'Time scale',
|
||||
description: 'Rate at which sky time advances.',
|
||||
min: 0, max: 4, step: 0.05, unit: 'x',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.filter',
|
||||
kind: 'selection',
|
||||
label: 'Filter',
|
||||
description: 'Optical filter applied to the view.',
|
||||
options: [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'light-pollution', label: 'Light pollution' },
|
||||
{ value: 'h-alpha', label: 'H-alpha' },
|
||||
{ value: 'oxygen-iii', label: 'Oxygen III' }
|
||||
],
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.tracking',
|
||||
kind: 'state',
|
||||
valueType: 'boolean',
|
||||
label: 'Tracking',
|
||||
description: 'Whether the mount is tracking the selected constellation.',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'sky.paused',
|
||||
kind: 'state',
|
||||
valueType: 'boolean',
|
||||
label: 'Sky frozen',
|
||||
description: 'Whether sky time is frozen.',
|
||||
readable: true, writable: true, restorable: true,
|
||||
category: 'sky', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.advance-time',
|
||||
kind: 'impulse',
|
||||
label: 'Advance time',
|
||||
description: 'Advance sky time. Optional argument: minutes (1 to 240).',
|
||||
arguments: [
|
||||
{ name: 'minutes', type: 'number', required: false, min: 1, max: 240, default: 10 }
|
||||
],
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.reset-sky',
|
||||
kind: 'impulse',
|
||||
label: 'Reset sky',
|
||||
description: 'Return sky time to epoch.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'action.toggle-tracking',
|
||||
kind: 'impulse',
|
||||
label: 'Toggle tracking',
|
||||
description: 'Engage or disengage the mount.',
|
||||
readable: false, writable: false, restorable: false,
|
||||
category: 'action', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.visible-stars',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
label: 'Visible stars',
|
||||
description: 'Stars currently above the magnitude limit.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.sky-time',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
unit: 'h',
|
||||
label: 'Sky time',
|
||||
description: 'Elapsed sky time in hours.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
},
|
||||
{
|
||||
id: 'telemetry.moon-phase',
|
||||
kind: 'state',
|
||||
valueType: 'number',
|
||||
label: 'Moon phase',
|
||||
description: 'Lunar phase as a fraction of the cycle.',
|
||||
readable: true, writable: false, restorable: false,
|
||||
category: 'telemetry', requires: []
|
||||
}
|
||||
];
|
||||
|
||||
function createPlanetariumContract(exhibit, ui) {
|
||||
var catalog = new Core.Catalog(DESCRIPTORS);
|
||||
|
||||
var capabilities = new Core.CapabilityRegistry();
|
||||
capabilities.declare('render', 'ready');
|
||||
|
||||
var setters = {
|
||||
'sky.constellation': function (v) { return exhibit.setConstellation(v); },
|
||||
'sky.magnitude-limit': function (v) { return exhibit.setMagnitudeLimit(v); },
|
||||
'sky.azimuth': function (v) { return exhibit.setAzimuth(v); },
|
||||
'sky.altitude': function (v) { return exhibit.setAltitude(v); },
|
||||
'sky.time-scale': function (v) { return exhibit.setTimeScale(v); },
|
||||
'sky.filter': function (v) { return exhibit.setFilter(v); },
|
||||
'sky.tracking': function (v) { return exhibit.setTracking(v); },
|
||||
'sky.paused': function (v) { return exhibit.setPaused(v); }
|
||||
};
|
||||
|
||||
var readers = {};
|
||||
var ids = catalog.ids();
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
(function (id) {
|
||||
readers[id] = function () { return exhibit.read(id); };
|
||||
})(ids[i]);
|
||||
}
|
||||
|
||||
var actions = {
|
||||
'action.advance-time': function (args) { return exhibit.advanceTime(args); },
|
||||
'action.reset-sky': function () { return exhibit.resetSky(); },
|
||||
'action.toggle-tracking': function () { return exhibit.toggleTracking(); }
|
||||
};
|
||||
|
||||
var core = new Core.ContractCore({
|
||||
identity: IDENTITY,
|
||||
catalog: catalog,
|
||||
capabilities: capabilities,
|
||||
setters: setters,
|
||||
readers: readers,
|
||||
actions: actions
|
||||
});
|
||||
|
||||
ui.dispatchOverride = function (targetId, value) {
|
||||
core.applyMutation(targetId, value, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
ui.invokeOverride = function (targetId, args) {
|
||||
core.invokeAction(targetId, args || {}, 'ui');
|
||||
ui.sync();
|
||||
};
|
||||
|
||||
exhibit.onChange = function () { ui.sync(); };
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
window.PlanetariumContract = { create: createPlanetariumContract, IDENTITY: IDENTITY };
|
||||
})();
|
||||
@@ -0,0 +1,564 @@
|
||||
/*
|
||||
* Planetarium — exhibit behaviour.
|
||||
*
|
||||
* Owns the sky model, the ephemeris, the canvas rendering, and the native
|
||||
* controls. No contract awareness: no target IDs, no events, no revisions.
|
||||
*
|
||||
* The exhibit's own services (absolute setters and real actions) are what
|
||||
* the contract layer binds to, so the native UI and a future host share one
|
||||
* code path.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TAU = Math.PI * 2;
|
||||
|
||||
var CATALOG = [
|
||||
{ id: 'orion', label: 'Orion', stars: 9, brightness: 1.0, hue: '#cfe4ff' },
|
||||
{ id: 'cygnus', label: 'Cygnus', stars: 7, brightness: 0.85, hue: '#dbe8ff' },
|
||||
{ id: 'scorpius', label: 'Scorpius', stars: 8, brightness: 0.95, hue: '#ffe0c4' },
|
||||
{ id: 'ursa-major', label: 'Ursa Major', stars: 7, brightness: 0.8, hue: '#e6f0ff' },
|
||||
{ id: 'cassiopeia', label: 'Cassiopeia', stars: 5, brightness: 0.75, hue: '#f0e6ff' },
|
||||
{ id: 'lyra', label: 'Lyra', stars: 6, brightness: 0.9, hue: '#e8f4ff' }
|
||||
];
|
||||
|
||||
var BODIES = [
|
||||
{ id: 'moon', label: 'Moon', radius: 0.055, period: 0.09, color: '#e8eef7', phase: 0.2 },
|
||||
{ id: 'mars', label: 'Mars', radius: 0.085, period: 0.16, color: '#e07a52', phase: 1.1 },
|
||||
{ id: 'jupiter', label: 'Jupiter', radius: 0.115, period: 0.27, color: '#e6c79a', phase: 2.4 },
|
||||
{ id: 'saturn', label: 'Saturn', radius: 0.145, period: 0.41, color: '#e8d9a8', phase: 4.0, ring: true }
|
||||
];
|
||||
|
||||
var FILTERS = [
|
||||
{ id: 'none', label: 'None', tint: null, gain: 1.0 },
|
||||
{ id: 'light-pollution', label: 'Light pollution', tint: '#3a2a1a', gain: 0.55 },
|
||||
{ id: 'h-alpha', label: 'H-alpha', tint: '#5a1020', gain: 0.8 },
|
||||
{ id: 'oxygen-iii', label: 'Oxygen III', tint: '#0d3a4a', gain: 0.8 }
|
||||
];
|
||||
|
||||
var MAX_MAGNITUDE = 6.0;
|
||||
var MIN_MAGNITUDE = 1.0;
|
||||
|
||||
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
||||
|
||||
function findById(list, id) {
|
||||
for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Deterministic pseudo-random so a constellation always looks the same. */
|
||||
function hash(n) {
|
||||
var x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Sky model
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function Sky() {
|
||||
this.constellation = 'orion';
|
||||
this.magnitudeLimit = 4.5;
|
||||
this.azimuth = 0.25;
|
||||
this.altitude = 0.5;
|
||||
this.timeScale = 0.5;
|
||||
this.filter = 'none';
|
||||
this.tracking = true;
|
||||
this.paused = false;
|
||||
this.skyTime = 0;
|
||||
this.stars = [];
|
||||
this._buildStars();
|
||||
}
|
||||
|
||||
Sky.prototype._buildStars = function () {
|
||||
this.stars = [];
|
||||
var count = 420;
|
||||
for (var i = 0; i < count; i++) {
|
||||
var mag = MIN_MAGNITUDE + hash(i * 3.1) * (MAX_MAGNITUDE - MIN_MAGNITUDE);
|
||||
this.stars.push({
|
||||
x: hash(i * 1.7),
|
||||
y: hash(i * 2.3) * 0.92,
|
||||
mag: mag,
|
||||
twinkle: hash(i * 5.9) * TAU,
|
||||
hue: hash(i * 7.3) > 0.82 ? '#ffd9b0' : (hash(i * 9.1) > 0.7 ? '#c9d8ff' : '#ffffff')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Sky.prototype.step = function (dt) {
|
||||
if (this.paused) return;
|
||||
this.skyTime += dt * this.timeScale;
|
||||
};
|
||||
|
||||
/** Position of a body on the sky dome, in normalised 0..1 coordinates. */
|
||||
Sky.prototype.bodyPosition = function (body) {
|
||||
var angle = body.phase + this.skyTime * TAU * body.period;
|
||||
var cx = 0.5 + (this.azimuth - 0.5) * 0.6;
|
||||
var cy = 0.5 + (this.altitude - 0.5) * 0.6;
|
||||
return {
|
||||
x: cx + Math.cos(angle) * body.radius * 3.2,
|
||||
y: cy + Math.sin(angle) * body.radius * 1.9
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Rendering
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function Renderer(canvas, sky) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
this.sky = sky;
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.resize();
|
||||
}
|
||||
|
||||
Renderer.prototype.resize = function () {
|
||||
var rect = this.canvas.getBoundingClientRect();
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
this.width = Math.max(1, Math.round(rect.width));
|
||||
this.height = Math.max(1, Math.round(rect.height));
|
||||
this.canvas.width = Math.round(this.width * dpr);
|
||||
this.canvas.height = Math.round(this.height * dpr);
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
|
||||
Renderer.prototype.draw = function () {
|
||||
var ctx = this.ctx;
|
||||
var w = this.width;
|
||||
var h = this.height;
|
||||
var sky = this.sky;
|
||||
var filter = findById(FILTERS, sky.filter);
|
||||
var constellation = findById(CATALOG, sky.constellation);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
/* Sky dome. */
|
||||
var grad = ctx.createRadialGradient(w * 0.5, h * 0.55, 0, w * 0.5, h * 0.55, Math.max(w, h) * 0.75);
|
||||
grad.addColorStop(0, '#0b1226');
|
||||
grad.addColorStop(1, '#03050c');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
/* Horizon glow. */
|
||||
var horizon = ctx.createLinearGradient(0, h * 0.72, 0, h);
|
||||
horizon.addColorStop(0, 'rgba(20,40,80,0)');
|
||||
horizon.addColorStop(1, 'rgba(30,60,110,0.35)');
|
||||
ctx.fillStyle = horizon;
|
||||
ctx.fillRect(0, h * 0.72, w, h * 0.28);
|
||||
|
||||
/* Stars, filtered by the magnitude limit. */
|
||||
for (var i = 0; i < sky.stars.length; i++) {
|
||||
var s = sky.stars[i];
|
||||
if (s.mag > sky.magnitudeLimit) continue;
|
||||
var norm = 1 - (s.mag - MIN_MAGNITUDE) / (MAX_MAGNITUDE - MIN_MAGNITUDE);
|
||||
var alpha = clamp(norm * 0.95 + 0.05, 0, 1) * filter.gain;
|
||||
var twinkle = 0.85 + Math.sin(sky.skyTime * 2.4 + s.twinkle) * 0.15;
|
||||
var r = 0.6 + norm * 1.9;
|
||||
|
||||
ctx.globalAlpha = clamp(alpha * twinkle, 0, 1);
|
||||
ctx.fillStyle = s.hue;
|
||||
ctx.beginPath();
|
||||
ctx.arc(s.x * w, s.y * h, r, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
/* The selected constellation, drawn as a connected figure. */
|
||||
var baseX = (0.5 + (sky.azimuth - 0.5) * 0.7) * w;
|
||||
var baseY = (0.42 + (sky.altitude - 0.5) * 0.5) * h;
|
||||
var scale = Math.min(w, h) * 0.16;
|
||||
var points = [];
|
||||
for (var k = 0; k < constellation.stars; k++) {
|
||||
var angle = (k / constellation.stars) * TAU + hash(k * 4.4) * 0.6;
|
||||
var dist = 0.35 + hash(k * 6.1) * 0.65;
|
||||
points.push({
|
||||
x: baseX + Math.cos(angle) * dist * scale,
|
||||
y: baseY + Math.sin(angle) * dist * scale * 0.7
|
||||
});
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(150,200,255,' + (0.28 * filter.gain) + ')';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (var p = 0; p < points.length; p++) {
|
||||
var next = points[(p + 1) % points.length];
|
||||
ctx.moveTo(points[p].x, points[p].y);
|
||||
ctx.lineTo(next.x, next.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
for (var q = 0; q < points.length; q++) {
|
||||
ctx.fillStyle = constellation.hue;
|
||||
ctx.globalAlpha = clamp(constellation.brightness * filter.gain, 0, 1);
|
||||
ctx.beginPath();
|
||||
ctx.arc(points[q].x, points[q].y, 2.4, 0, TAU);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
/* Label the constellation with textContent-equivalent canvas text. */
|
||||
ctx.fillStyle = 'rgba(190,220,255,0.75)';
|
||||
ctx.font = '600 12px "Segoe UI", sans-serif';
|
||||
ctx.fillText(constellation.label, baseX - scale * 0.5, baseY + scale * 1.15);
|
||||
|
||||
/* Solar-system bodies. */
|
||||
for (var b = 0; b < BODIES.length; b++) {
|
||||
var body = BODIES[b];
|
||||
var pos = sky.bodyPosition(body);
|
||||
var bx = pos.x * w;
|
||||
var by = pos.y * h;
|
||||
var br = body.radius * Math.min(w, h) * 0.5;
|
||||
|
||||
ctx.globalAlpha = clamp(filter.gain, 0, 1);
|
||||
ctx.fillStyle = body.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(bx, by, br, 0, TAU);
|
||||
ctx.fill();
|
||||
|
||||
if (body.ring) {
|
||||
ctx.strokeStyle = 'rgba(232,217,168,0.7)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(bx, by, br * 1.9, br * 0.6, 0.4, 0, TAU);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(200,220,255,0.6)';
|
||||
ctx.font = '11px "Segoe UI", sans-serif';
|
||||
ctx.fillText(body.label, bx + br + 5, by + 4);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/* Filter tint over the whole frame. */
|
||||
if (filter.tint) {
|
||||
ctx.globalAlpha = 0.22;
|
||||
ctx.fillStyle = filter.tint;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/* Tracking reticle. */
|
||||
if (sky.tracking) {
|
||||
ctx.strokeStyle = 'rgba(120,200,255,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(baseX, baseY, scale * 1.35, 0, TAU);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(baseX - scale * 1.5, baseY);
|
||||
ctx.lineTo(baseX - scale * 1.2, baseY);
|
||||
ctx.moveTo(baseX + scale * 1.2, baseY);
|
||||
ctx.lineTo(baseX + scale * 1.5, baseY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (sky.paused) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
||||
ctx.font = '600 13px "Segoe UI", sans-serif';
|
||||
ctx.fillText('SKY FROZEN', 14, 24);
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Exhibit
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function PlanetariumExhibit(options) {
|
||||
this.canvas = options.canvas;
|
||||
this.sky = new Sky();
|
||||
this.renderer = new Renderer(this.canvas, this.sky);
|
||||
this.announcer = options.announcer;
|
||||
this.onChange = options.onChange || function () {};
|
||||
this._lastFrame = 0;
|
||||
this._running = false;
|
||||
this._boundFrame = this._frame.bind(this);
|
||||
}
|
||||
|
||||
PlanetariumExhibit.prototype.start = function () {
|
||||
if (this._running) return;
|
||||
this._running = true;
|
||||
this._lastFrame = performance.now();
|
||||
requestAnimationFrame(this._boundFrame);
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype._frame = function (now) {
|
||||
if (!this._running) return;
|
||||
var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
|
||||
this._lastFrame = now;
|
||||
this.sky.step(dt);
|
||||
this.renderer.draw();
|
||||
requestAnimationFrame(this._boundFrame);
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.resize = function () { this.renderer.resize(); };
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Absolute, idempotent setters
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
PlanetariumExhibit.prototype.setConstellation = function (id) {
|
||||
if (this.sky.constellation === id) return { changed: false };
|
||||
this.sky.constellation = id;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.setMagnitudeLimit = function (value) {
|
||||
if (this.sky.magnitudeLimit === value) return { changed: false };
|
||||
this.sky.magnitudeLimit = value;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.setAzimuth = function (value) {
|
||||
if (this.sky.azimuth === value) return { changed: false };
|
||||
this.sky.azimuth = value;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.setAltitude = function (value) {
|
||||
if (this.sky.altitude === value) return { changed: false };
|
||||
this.sky.altitude = value;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.setTimeScale = function (value) {
|
||||
if (this.sky.timeScale === value) return { changed: false };
|
||||
this.sky.timeScale = value;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.setFilter = function (id) {
|
||||
if (this.sky.filter === id) return { changed: false };
|
||||
this.sky.filter = id;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.setTracking = function (on) {
|
||||
if (this.sky.tracking === on) return { changed: false };
|
||||
this.sky.tracking = on;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.setPaused = function (on) {
|
||||
if (this.sky.paused === on) return { changed: false };
|
||||
this.sky.paused = on;
|
||||
this.onChange();
|
||||
return { changed: true };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Real actions
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
PlanetariumExhibit.prototype.advanceTime = function (args) {
|
||||
var minutes = 10;
|
||||
if (args && typeof args.minutes === 'number') {
|
||||
if (!isFinite(args.minutes) || args.minutes < 1 || args.minutes > 240) {
|
||||
return { ok: false, code: 'INVALID_ARGUMENTS', message: 'minutes must be between 1 and 240.' };
|
||||
}
|
||||
minutes = Math.round(args.minutes);
|
||||
}
|
||||
this.sky.skyTime += minutes / 60;
|
||||
this.announcer.flash('Advanced sky time by ' + minutes + ' minutes.');
|
||||
this.onChange();
|
||||
return { ok: true, args: { minutes: minutes } };
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.resetSky = function () {
|
||||
this.sky.skyTime = 0;
|
||||
this.announcer.flash('Sky time reset to epoch.');
|
||||
this.onChange();
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype.toggleTracking = function () {
|
||||
this.sky.tracking = !this.sky.tracking;
|
||||
this.announcer.flash(this.sky.tracking ? 'Tracking engaged.' : 'Tracking disengaged.');
|
||||
this.onChange();
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Reads
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
PlanetariumExhibit.prototype.read = function (targetId) {
|
||||
var s = this.sky;
|
||||
switch (targetId) {
|
||||
case 'sky.constellation': return s.constellation;
|
||||
case 'sky.magnitude-limit': return s.magnitudeLimit;
|
||||
case 'sky.azimuth': return s.azimuth;
|
||||
case 'sky.altitude': return s.altitude;
|
||||
case 'sky.time-scale': return s.timeScale;
|
||||
case 'sky.filter': return s.filter;
|
||||
case 'sky.tracking': return s.tracking;
|
||||
case 'sky.paused': return s.paused;
|
||||
case 'telemetry.visible-stars': return this._visibleStarCount();
|
||||
case 'telemetry.sky-time': return Math.round(s.skyTime * 100) / 100;
|
||||
case 'telemetry.moon-phase': return Math.round(((s.skyTime * 0.09) % 1) * 100) / 100;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
PlanetariumExhibit.prototype._visibleStarCount = function () {
|
||||
var count = 0;
|
||||
for (var i = 0; i < this.sky.stars.length; i++) {
|
||||
if (this.sky.stars[i].mag <= this.sky.magnitudeLimit) count++;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Native UI
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function PlanetariumUI(exhibit, announcer) {
|
||||
this.exhibit = exhibit;
|
||||
this.announcer = announcer;
|
||||
this.nodes = {
|
||||
constellation: document.getElementById('constellation-select'),
|
||||
magnitude: document.getElementById('magnitude-range'),
|
||||
magnitudeReadout: document.getElementById('magnitude-readout'),
|
||||
azimuth: document.getElementById('azimuth-range'),
|
||||
azimuthReadout: document.getElementById('azimuth-readout'),
|
||||
altitude: document.getElementById('altitude-range'),
|
||||
altitudeReadout: document.getElementById('altitude-readout'),
|
||||
timeScale: document.getElementById('timescale-range'),
|
||||
timeScaleReadout: document.getElementById('timescale-readout'),
|
||||
filter: document.getElementById('filter-select'),
|
||||
tracking: document.getElementById('tracking-button'),
|
||||
pause: document.getElementById('pause-button'),
|
||||
advance: document.getElementById('advance-button'),
|
||||
reset: document.getElementById('reset-button')
|
||||
};
|
||||
this._buildOptions();
|
||||
this._bind();
|
||||
this.sync();
|
||||
}
|
||||
|
||||
PlanetariumUI.prototype._buildOptions = function () {
|
||||
var c = this.nodes.constellation;
|
||||
for (var i = 0; i < CATALOG.length; i++) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = CATALOG[i].id;
|
||||
opt.textContent = CATALOG[i].label;
|
||||
c.appendChild(opt);
|
||||
}
|
||||
var f = this.nodes.filter;
|
||||
for (var j = 0; j < FILTERS.length; j++) {
|
||||
var fopt = document.createElement('option');
|
||||
fopt.value = FILTERS[j].id;
|
||||
fopt.textContent = FILTERS[j].label;
|
||||
f.appendChild(fopt);
|
||||
}
|
||||
};
|
||||
|
||||
PlanetariumUI.prototype._bind = function () {
|
||||
var self = this;
|
||||
var ex = this.exhibit;
|
||||
|
||||
this.nodes.constellation.addEventListener('change', function (e) {
|
||||
self.dispatch('sky.constellation', e.target.value);
|
||||
});
|
||||
this.nodes.magnitude.addEventListener('input', function (e) {
|
||||
self.dispatch('sky.magnitude-limit', Number(e.target.value));
|
||||
});
|
||||
this.nodes.azimuth.addEventListener('input', function (e) {
|
||||
self.dispatch('sky.azimuth', Number(e.target.value));
|
||||
});
|
||||
this.nodes.altitude.addEventListener('input', function (e) {
|
||||
self.dispatch('sky.altitude', Number(e.target.value));
|
||||
});
|
||||
this.nodes.timeScale.addEventListener('input', function (e) {
|
||||
self.dispatch('sky.time-scale', Number(e.target.value));
|
||||
});
|
||||
this.nodes.filter.addEventListener('change', function (e) {
|
||||
self.dispatch('sky.filter', e.target.value);
|
||||
});
|
||||
this.nodes.tracking.addEventListener('click', function () {
|
||||
self.dispatch('sky.tracking', !ex.sky.tracking);
|
||||
});
|
||||
this.nodes.pause.addEventListener('click', function () {
|
||||
self.dispatch('sky.paused', !ex.sky.paused);
|
||||
});
|
||||
this.nodes.advance.addEventListener('click', function () {
|
||||
self.invoke('action.advance-time', { minutes: 30 });
|
||||
});
|
||||
this.nodes.reset.addEventListener('click', function () {
|
||||
self.invoke('action.reset-sky');
|
||||
});
|
||||
};
|
||||
|
||||
PlanetariumUI.prototype.dispatch = function (targetId, value) {
|
||||
if (typeof this.dispatchOverride === 'function') {
|
||||
this.dispatchOverride(targetId, value);
|
||||
return;
|
||||
}
|
||||
this._fallbackSet(targetId, value);
|
||||
};
|
||||
|
||||
PlanetariumUI.prototype.invoke = function (targetId, args) {
|
||||
if (typeof this.invokeOverride === 'function') {
|
||||
this.invokeOverride(targetId, args);
|
||||
return;
|
||||
}
|
||||
this._fallbackInvoke(targetId, args);
|
||||
};
|
||||
|
||||
PlanetariumUI.prototype._fallbackSet = function (targetId, value) {
|
||||
var ex = this.exhibit;
|
||||
switch (targetId) {
|
||||
case 'sky.constellation': ex.setConstellation(value); break;
|
||||
case 'sky.magnitude-limit': ex.setMagnitudeLimit(value); break;
|
||||
case 'sky.azimuth': ex.setAzimuth(value); break;
|
||||
case 'sky.altitude': ex.setAltitude(value); break;
|
||||
case 'sky.time-scale': ex.setTimeScale(value); break;
|
||||
case 'sky.filter': ex.setFilter(value); break;
|
||||
case 'sky.tracking': ex.setTracking(value); break;
|
||||
case 'sky.paused': ex.setPaused(value); break;
|
||||
default: break;
|
||||
}
|
||||
this.sync();
|
||||
};
|
||||
|
||||
PlanetariumUI.prototype._fallbackInvoke = function (targetId, args) {
|
||||
var ex = this.exhibit;
|
||||
if (targetId === 'action.advance-time') ex.advanceTime(args || {});
|
||||
else if (targetId === 'action.reset-sky') ex.resetSky();
|
||||
else if (targetId === 'action.toggle-tracking') ex.toggleTracking();
|
||||
this.sync();
|
||||
};
|
||||
|
||||
PlanetariumUI.prototype.sync = function () {
|
||||
var s = this.exhibit.sky;
|
||||
var n = this.nodes;
|
||||
var Shell = window.XZBTShell;
|
||||
|
||||
if (n.constellation.value !== s.constellation) n.constellation.value = s.constellation;
|
||||
if (Number(n.magnitude.value) !== s.magnitudeLimit) n.magnitude.value = String(s.magnitudeLimit);
|
||||
if (Number(n.azimuth.value) !== s.azimuth) n.azimuth.value = String(s.azimuth);
|
||||
if (Number(n.altitude.value) !== s.altitude) n.altitude.value = String(s.altitude);
|
||||
if (Number(n.timeScale.value) !== s.timeScale) n.timeScale.value = String(s.timeScale);
|
||||
if (n.filter.value !== s.filter) n.filter.value = s.filter;
|
||||
|
||||
n.magnitudeReadout.textContent = 'mag \u2264 ' + Shell.formatNumber(s.magnitudeLimit, 1);
|
||||
n.azimuthReadout.textContent = Shell.formatDegrees(s.azimuth);
|
||||
n.altitudeReadout.textContent = Shell.formatDegrees(s.altitude);
|
||||
n.timeScaleReadout.textContent = Shell.formatNumber(s.timeScale, 2) + '\u00d7';
|
||||
n.tracking.setAttribute('aria-pressed', s.tracking ? 'true' : 'false');
|
||||
n.pause.setAttribute('aria-pressed', s.paused ? 'true' : 'false');
|
||||
n.pause.textContent = s.paused ? 'Resume sky' : 'Freeze sky';
|
||||
};
|
||||
|
||||
window.PlanetariumExhibit = PlanetariumExhibit;
|
||||
window.PlanetariumUI = PlanetariumUI;
|
||||
window.PlanetariumData = { CATALOG: CATALOG, BODIES: BODIES, FILTERS: FILTERS };
|
||||
})();
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Planetarium — XZBT Reference Exhibit</title>
|
||||
<link rel="stylesheet" href="../shared/exhibit-shell.css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="exhibit">
|
||||
<header class="exhibit-header">
|
||||
<h1>Planetarium</h1>
|
||||
<span class="subtitle">Sky dome — live ephemeris</span>
|
||||
<span class="status" id="contract-status">standalone</span>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<canvas id="sky-canvas" aria-label="Planetarium sky view"></canvas>
|
||||
</main>
|
||||
|
||||
<aside class="panel">
|
||||
<section>
|
||||
<h2>Target</h2>
|
||||
<div class="control">
|
||||
<label for="constellation-select">Constellation</label>
|
||||
<select id="constellation-select"></select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="magnitude-range">
|
||||
<span>Magnitude limit</span>
|
||||
<span class="readout" id="magnitude-readout">mag ≤ 4.5</span>
|
||||
</label>
|
||||
<input type="range" id="magnitude-range" min="1" max="6" step="0.1" value="4.5">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Pointing</h2>
|
||||
<div class="control">
|
||||
<label for="azimuth-range">
|
||||
<span>Azimuth</span>
|
||||
<span class="readout" id="azimuth-readout">90°</span>
|
||||
</label>
|
||||
<input type="range" id="azimuth-range" min="0" max="1" step="0.01" value="0.25">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="altitude-range">
|
||||
<span>Altitude</span>
|
||||
<span class="readout" id="altitude-readout">180°</span>
|
||||
</label>
|
||||
<input type="range" id="altitude-range" min="0" max="1" step="0.01" value="0.5">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="timescale-range">
|
||||
<span>Time scale</span>
|
||||
<span class="readout" id="timescale-readout">0.50×</span>
|
||||
</label>
|
||||
<input type="range" id="timescale-range" min="0" max="4" step="0.05" value="0.5">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Optics</h2>
|
||||
<div class="control">
|
||||
<label for="filter-select">Filter</label>
|
||||
<select id="filter-select"></select>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<span>Mount tracking</span>
|
||||
<button id="tracking-button" type="button" aria-pressed="true">Tracking</button>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<span>Sky time</span>
|
||||
<button id="pause-button" type="button" aria-pressed="false">Freeze sky</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Actions</h2>
|
||||
<div class="button-row">
|
||||
<button id="advance-button" type="button">Advance 30 min</button>
|
||||
<button id="reset-button" type="button">Reset sky</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<footer class="exhibit-footer">
|
||||
<span class="announcement-label">Dome</span>
|
||||
<span class="announcement-text is-idle" id="announcement">Dome idle — awaiting instruction.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="../shared/contract-core.js"></script>
|
||||
<script src="../shared/exhibit-shell.js"></script>
|
||||
<script src="../shared/host-transport.js"></script>
|
||||
<script src="exhibit.js"></script>
|
||||
<script src="contract-adapter.js"></script>
|
||||
<script src="boot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
/* Planetarium — domain styling only. */
|
||||
|
||||
body {
|
||||
background: #05070f;
|
||||
color: #e2ecff;
|
||||
}
|
||||
|
||||
.exhibit-header {
|
||||
background: linear-gradient(90deg, rgba(60, 70, 150, 0.35), rgba(255, 255, 255, 0.03));
|
||||
border-color: rgba(150, 180, 255, 0.22);
|
||||
}
|
||||
|
||||
.exhibit-header h1 { color: #cdd9ff; }
|
||||
|
||||
.stage {
|
||||
border-color: rgba(150, 180, 255, 0.25);
|
||||
box-shadow: inset 0 0 80px rgba(0, 0, 30, 0.9);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(40, 50, 110, 0.22);
|
||||
border-color: rgba(150, 180, 255, 0.18);
|
||||
}
|
||||
|
||||
.panel h2 { color: #a9bcff; }
|
||||
|
||||
input[type="range"] { accent-color: #8fa8ff; }
|
||||
|
||||
button {
|
||||
background: rgba(143, 168, 255, 0.12);
|
||||
border-color: rgba(150, 180, 255, 0.35);
|
||||
color: #e2ecff;
|
||||
}
|
||||
|
||||
button:hover { background: rgba(143, 168, 255, 0.24); }
|
||||
|
||||
button[aria-pressed="true"] {
|
||||
background: rgba(143, 168, 255, 0.4);
|
||||
border-color: rgba(190, 210, 255, 0.7);
|
||||
}
|
||||
|
||||
select {
|
||||
background: rgba(6, 10, 26, 0.85);
|
||||
border-color: rgba(150, 180, 255, 0.3);
|
||||
}
|
||||
|
||||
.exhibit-footer {
|
||||
background: rgba(40, 50, 110, 0.22);
|
||||
border-color: rgba(150, 180, 255, 0.18);
|
||||
}
|
||||
|
||||
.exhibit-footer .announcement-label { color: #a9bcff; opacity: 0.7; }
|
||||
|
||||
.exhibit-header .status { color: #a9bcff; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
SciFiAmbientDisplay_v3co.html
|
||||
Version: v3co (co = Claude Opus 5) Base: v2cs Date: 2026-09-04
|
||||
|
||||
CHANGE: Per-theme OBSERVATION layer isolation.
|
||||
v2cs correctly fixed the `.observation-stage svg > rect:first-child` selector that had
|
||||
never matched, but the rule was GLOBAL -- uncovering the shared canvas layer stack in
|
||||
nine themes at once and making every OBSERVATION display read as a variation of one.
|
||||
v3co keeps the correct selector and makes canvas visibility an explicit, per-theme,
|
||||
default-deny declaration: see the OBSERVATION_CANVAS manifest above class
|
||||
ObservationEngine. Rollback point: SciFiAmbientDisplay_v2cs.html (untouched).
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sci-Fi Ambience Generator - Publish Build v14co</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body class="theme-lcars-tng">
|
||||
|
||||
<!-- Header Frame with Interactive Universe Dropdown -->
|
||||
<header class="lcars-header">
|
||||
<div class="universe-selector-container" id="universe-selector-container">
|
||||
<div class="lcars-elbow-left" id="header-universe-btn" title="Click to Change Sci-Fi Universe">
|
||||
<span id="header-universe-label">STARFLIGHT</span> <span style="font-size: 0.8rem; margin-left: 4px;">▼</span>
|
||||
</div>
|
||||
<div class="universe-dropdown-menu" id="universe-dropdown">
|
||||
<!-- Injected dynamically from UniverseRegistry -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lcars-top-bar">
|
||||
<span id="header-ship-name">USS INTERPRISE-G // MAIN BRIDGE</span>
|
||||
<div class="header-volume-dock">
|
||||
<button class="btn-header-mute" id="btn-header-mute" title="Toggle Mute (M)">MUTE</button>
|
||||
<span class="header-vol-val" id="header-vol-val">75%</span>
|
||||
</div>
|
||||
<span id="header-matrix-label">STARFLIGHT SOUND MATRIX v2.9</span>
|
||||
</div>
|
||||
<div class="lcars-pill-end"></div>
|
||||
</header>
|
||||
|
||||
<!-- Main Application Container -->
|
||||
<main class="lcars-container">
|
||||
|
||||
<!-- Left Sidebar: Active Universe Presets (Purely segregated per theme) -->
|
||||
<aside class="left-sidebar">
|
||||
<div class="sidebar-heading" id="sidebar-title">STARSHIP PRESETS</div>
|
||||
<div class="preset-list" id="preset-container">
|
||||
<!-- Dynamically injected preset buttons for active universe only -->
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Center Panel: Master Controls, Presets Description & Channel Synthesizers -->
|
||||
<section class="center-panel">
|
||||
|
||||
<!-- Master Control Card -->
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<div class="panel-title" id="current-preset-title">INTERPRISE-G: MAIN BRIDGE</div>
|
||||
<div class="panel-sub" id="current-preset-desc">Warm, low-frequency 50Hz hull tone with gentle ventilation and soft LCARD computer chirps.</div>
|
||||
</div>
|
||||
<div class="diagnostic-stats" style="width: 220px;">
|
||||
<div class="stat-box">
|
||||
<span class="stat-label">AUDIO ENGINE</span>
|
||||
<span class="stat-value" id="stat-audio-status">STANDBY</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-label">CORE FREQ</span>
|
||||
<span class="stat-value" id="stat-core-freq">58.0 Hz</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Master Transport & Actions -->
|
||||
<div class="transport-bar">
|
||||
<button class="btn-lcars-large" id="btn-master-play">
|
||||
<span id="play-icon">▶</span> <span id="play-text">ENGAGE</span>
|
||||
</button>
|
||||
|
||||
<div class="pill-button-group" id="universe-events-container">
|
||||
<!-- Dynamically populated per universe -->
|
||||
</div>
|
||||
<div class="watch-experience-wrap">
|
||||
<button class="btn-lcars-pill btn-observation" id="btn-observation" title="Start the full-screen procedural visual and sound experience">WATCH EXPERIENCE</button>
|
||||
<div class="watch-experience-help">Full-screen procedural visuals, soundscape, telemetry, events, and optional local AI announcements.</div>
|
||||
</div>
|
||||
<label class="observation-hud-control" title="Toggle starship viewport window frame (ON: framed starship lounge / OFF: pure deep space)">
|
||||
<span class="observation-hud-label">VIEWPORT</span>
|
||||
<input type="checkbox" id="toggle-observation-viewport" checked aria-label="Toggle Observation Viewport Frame">
|
||||
<span class="observation-hud-state" id="val-observation-viewport">ON</span>
|
||||
</label>
|
||||
<label class="observation-hud-control" title="Toggle vertical structural window pillars / mullions (OFF: unobstructed panoramic view / ON: structural posts)">
|
||||
<span class="observation-hud-label">PILLARS</span>
|
||||
<input type="checkbox" id="toggle-observation-pillars" aria-label="Toggle Window Pillars">
|
||||
<span class="observation-hud-state" id="val-observation-pillars">OFF</span>
|
||||
</label>
|
||||
<div class="observation-activity-control" title="Controls how frequently and intensely Observation generates transient visual activity">
|
||||
<span class="observation-activity-label">ACTIVITY</span>
|
||||
<input type="range" id="slider-observation-activity" min="0" max="100" step="1" value="60" aria-label="Observation activity level">
|
||||
<span class="observation-activity-value" id="val-observation-activity">60%</span>
|
||||
</div>
|
||||
<label class="observation-hud-control" title="Keep OBSERVATION profile/status information visible. Turn off to fade it out over 30 seconds.">
|
||||
<span class="observation-hud-label">HUD HOLD</span>
|
||||
<input type="checkbox" id="toggle-observation-hud" checked aria-label="Keep Observation information visible">
|
||||
<span class="observation-hud-state" id="val-observation-hud">ON</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Dedicated Master Volume Console -->
|
||||
<div class="master-volume-console">
|
||||
<div class="master-vol-top">
|
||||
<div class="master-vol-title-group">
|
||||
<span class="master-vol-tag">MAIN MASTER VOLUME</span>
|
||||
<span class="master-vol-readout" id="val-master-vol">75%</span>
|
||||
<span class="master-vol-db" id="val-master-db">-2.5 dB</span>
|
||||
</div>
|
||||
<div class="master-vol-actions">
|
||||
<button class="btn-lcars-pill btn-mute" id="btn-master-mute" title="Toggle Mute (M)">MUTE</button>
|
||||
<div class="master-vol-presets">
|
||||
<button class="btn-vol-step" data-vol="0.25">25%</button>
|
||||
<button class="btn-vol-step" data-vol="0.50">50%</button>
|
||||
<button class="btn-vol-step active" data-vol="0.75">75%</button>
|
||||
<button class="btn-vol-step" data-vol="1.00">MAX</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="master-slider-row">
|
||||
<button class="vol-adj-btn" id="btn-vol-down" title="Volume Down (-5% or Down Arrow)">−</button>
|
||||
<div class="slider-track-container">
|
||||
<input type="range" id="slider-master-vol" min="0" max="1" step="0.01" value="0.75" class="slider-master-large" aria-label="Main Volume">
|
||||
<!-- Visual 10-Segment LED Level Meter -->
|
||||
<div class="vol-meter-leds" id="vol-meter-leds">
|
||||
<div class="led-pip active"></div>
|
||||
<div class="led-pip active"></div>
|
||||
<div class="led-pip active"></div>
|
||||
<div class="led-pip active"></div>
|
||||
<div class="led-pip active"></div>
|
||||
<div class="led-pip active"></div>
|
||||
<div class="led-pip active"></div>
|
||||
<div class="led-pip active"></div>
|
||||
<div class="led-pip"></div>
|
||||
<div class="led-pip"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="vol-adj-btn" id="btn-vol-up" title="Volume Up (+5% or Up Arrow)">+</button>
|
||||
</div>
|
||||
|
||||
<!-- Futuristic Noice Cancellation: deterministic comfort filtering of the three ambience beds -->
|
||||
<div class="fnc-console" id="fnc-console">
|
||||
<div class="fnc-top">
|
||||
<div class="fnc-title-group">
|
||||
<span class="fnc-title">FNC // FUTURISTIC NOICE CANCELLATION</span>
|
||||
<span class="fnc-state" id="fnc-state">MONITORING</span>
|
||||
</div>
|
||||
<button class="btn-lcars-pill btn-fnc enabled" id="btn-fnc-enable" aria-pressed="true" title="Enable or bypass Futuristic Noice Cancellation">FNC ENABLED</button>
|
||||
</div>
|
||||
<div class="fnc-level-row">
|
||||
<span class="fnc-level-label">FNC LEVEL</span>
|
||||
<input type="range" id="slider-fnc-level" min="0" max="1" step="0.01" value="0.65" aria-label="Futuristic Noice Cancellation level">
|
||||
<span class="fnc-level-value" id="val-fnc-level">65%</span>
|
||||
</div>
|
||||
<div class="fnc-detail" id="fnc-detail">Comfort suppression active — Life Support, Hull Drone, and Worp Core are reduced according to FNC level.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Real-Time Frequency Spectrum Visualizer -->
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">ACOUSTIC FREQUENCY SPECTRUM</span>
|
||||
<span class="panel-sub">PROCEDURAL HARMONIC ANALYSIS</span>
|
||||
</div>
|
||||
<div class="canvas-wrapper spectrum-wrapper">
|
||||
<div class="canvas-label-overlay">SPECTRUM BIN: 32 CH // REAL-TIME FFT</div>
|
||||
<canvas id="spectrum-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4-Channel Synthesizer Mixer Card -->
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">PROCEDURAL SOUND ENGINE MIXER</span>
|
||||
<span class="panel-sub">LIVE PARAMETER MODULATION</span>
|
||||
</div>
|
||||
|
||||
<div class="mixer-grid">
|
||||
<!-- Channel 1: Hull Vibration -->
|
||||
<div class="channel-strip">
|
||||
<div class="channel-header">
|
||||
<span>1. HULL DRONE</span>
|
||||
<span id="val-hull-vol">65%</span>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>LEVEL</span></div>
|
||||
<input type="range" id="slider-hull-vol" min="0" max="1" step="0.01" value="0.65">
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>BASE TONE</span><span id="val-hull-freq">50 Hz</span></div>
|
||||
<input type="range" id="slider-hull-freq" min="30" max="120" step="1" value="50">
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>DAMPING CUTOFF</span><span id="val-hull-cutoff">105 Hz</span></div>
|
||||
<input type="range" id="slider-hull-cutoff" min="50" max="300" step="5" value="105">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel 2: Worp Core Pulse -->
|
||||
<div class="channel-strip">
|
||||
<div class="channel-header">
|
||||
<span>2. WORP CORE</span>
|
||||
<span id="val-warp-vol">35%</span>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>LEVEL</span></div>
|
||||
<input type="range" id="slider-warp-vol" min="0" max="1" step="0.01" value="0.35">
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>PULSE RATE (BPM)</span><span id="val-warp-bpm">48 BPM</span></div>
|
||||
<input type="range" id="slider-warp-bpm" min="20" max="120" step="1" value="48">
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>REACTOR PITCH</span><span id="val-warp-carrier">58 Hz</span></div>
|
||||
<input type="range" id="slider-warp-carrier" min="35" max="150" step="1" value="58">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel 3: Life Support -->
|
||||
<div class="channel-strip">
|
||||
<div class="channel-header">
|
||||
<span>3. LIFE SUPPORT</span>
|
||||
<span id="val-air-vol">55%</span>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>LEVEL</span></div>
|
||||
<input type="range" id="slider-air-vol" min="0" max="1" step="0.01" value="0.55">
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>AIRFLOW AIR FILTER</span><span id="val-air-cutoff">1600 Hz</span></div>
|
||||
<input type="range" id="slider-air-cutoff" min="500" max="4000" step="50" value="1600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel 4: Telemetry -->
|
||||
<div class="channel-strip">
|
||||
<div class="channel-header">
|
||||
<span>4. TELEMETRY</span>
|
||||
<span id="val-telemetry-vol">45%</span>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>LEVEL</span></div>
|
||||
<input type="range" id="slider-telemetry-vol" min="0" max="1" step="0.01" value="0.45">
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<div class="control-label"><span>BACKGROUND CHIRP DENSITY</span><span id="val-telemetry-density">65%</span></div>
|
||||
<input type="range" id="slider-telemetry-density" min="0" max="1" step="0.05" value="0.65">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Right Sidebar: Worp Core Intermix Chamber & LCARD Soundboard -->
|
||||
<aside class="right-column">
|
||||
|
||||
<!-- Core / Rotor Visualizer Card -->
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" id="core-visualizer-title">MATTER / ANTIMATTER CORE</span>
|
||||
</div>
|
||||
<div class="canvas-wrapper warp-core-wrapper">
|
||||
<div class="canvas-label-overlay" id="core-visualizer-label">INTERMIX CHAMBER // ACTIVE</div>
|
||||
<canvas id="warp-core-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- XZBT Generative Experience / WebLLM + Kokoro. Optional, network-gated: see agents.md. -->
|
||||
<div class="panel-card ai-experience-card" id="ai-experience-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">GENERATIVE EXPERIENCE</span>
|
||||
<span class="panel-sub">LOCAL WEBLLM + KOKORO TTS</span>
|
||||
</div>
|
||||
<div class="ai-status-line">
|
||||
<span>LOCAL GENERATIVE SYSTEM</span>
|
||||
<span class="ai-status-badge" id="ai-status-badge">STANDBY</span>
|
||||
</div>
|
||||
<div class="ai-fixed-model">
|
||||
<b>LANGUAGE MODEL</b><br>
|
||||
<span id="ai-model-fixed">Qwen3-1.7B-q4f16_1-MLC</span><br>
|
||||
<small>Automatically prepared when the experience starts. No model selection required.</small>
|
||||
</div>
|
||||
<div class="ai-engine-row" style="margin-top:7px">
|
||||
<div class="ai-engine-chip">WEBLLM <span id="ai-llm-state">OFF</span></div>
|
||||
<div class="ai-engine-chip">KOKORO <span id="ai-tts-state">OFF</span></div>
|
||||
</div>
|
||||
<div class="ai-control-grid">
|
||||
<button class="btn-lcars-pill" id="btn-ai-prepare">PREPARE AI</button>
|
||||
<button class="btn-lcars-pill" id="btn-ai-generate" disabled>GENERATE NOW</button>
|
||||
<label class="ai-toggle-row"><input type="checkbox" id="ai-auto-enabled" checked> AUTO UPDATES</label>
|
||||
<label class="ai-toggle-row"><input type="checkbox" id="ai-speech-enabled" checked> SPEAK</label>
|
||||
<select class="span-2" id="ai-kokoro-voice" aria-label="Kokoro voice">
|
||||
<option value="af_heart">Kokoro voice loads on preparation</option>
|
||||
</select>
|
||||
<div class="span-2">
|
||||
<div class="control-label"><span>VOICE AUTOMATION</span><span id="ai-robot-value">84%</span></div>
|
||||
<input type="range" id="ai-robot-amount" min="0" max="1" step="0.01" value="0.84" aria-label="Robotic voice effect intensity">
|
||||
</div>
|
||||
<div class="span-2">
|
||||
<div class="control-label"><span>AUTO CADENCE</span><span id="ai-cadence-value">90-180 SEC</span></div>
|
||||
<input type="range" id="ai-cadence" min="30" max="240" step="15" value="90">
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-progress" id="ai-progress">AI and neural voice models load only when needed and are cached by the browser when supported.</div>
|
||||
<div class="ai-experience-output" id="ai-experience-output">GENERATIVE CHANNEL STANDBY</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- TV Presentation -->
|
||||
<div class="panel-card tv-mode-card" id="tv-mode-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">TV PRESENTATION</span>
|
||||
<span class="panel-sub">CAST-FRIENDLY 16:9</span>
|
||||
</div>
|
||||
<label class="ai-toggle-row"><input type="checkbox" id="tv-aspect-lock" checked> LOCK EXPERIENCE TO 16:9</label>
|
||||
<div class="control-label"><span>RENDER DENSITY</span><span id="tv-quality-value">1080P TARGET</span></div>
|
||||
<select id="tv-quality">
|
||||
<option value="0.667">720P PERFORMANCE</option>
|
||||
<option value="1" selected>1080P STANDARD</option>
|
||||
<option value="1.333">1440P HIGH</option>
|
||||
<option value="2">2160P ULTRA</option>
|
||||
</select>
|
||||
<div class="ai-progress">The scene keeps a canonical 16:9 layout on phones, PCs, and cast displays. Chrome still negotiates the final Cast stream resolution.</div>
|
||||
</div>
|
||||
|
||||
<!-- Sleep Timer & Auto-Fadeout Card -->
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">SLEEP TIMER</span>
|
||||
</div>
|
||||
<div class="timer-readout" id="timer-display">TIMER INACTIVE (CONTINUOUS)</div>
|
||||
<div class="pill-button-group">
|
||||
<button class="btn-lcars-pill btn-timer" data-minutes="15">15M</button>
|
||||
<button class="btn-lcars-pill btn-timer" data-minutes="30">30M</button>
|
||||
<button class="btn-lcars-pill btn-timer" data-minutes="60">60M</button>
|
||||
<button class="btn-lcars-pill btn-timer" data-minutes="0">OFF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tactical Soundboard & Console Keypad -->
|
||||
<div class="panel-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" id="soundboard-title">LCARD KEYPAD</span>
|
||||
</div>
|
||||
<div class="telemetry-triggers" id="soundboard-container">
|
||||
<!-- Dynamically populated per universe -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
|
||||
<!-- OBSERVATION: passive casting / display mode. Click background to return. -->
|
||||
<section class="observation-overlay" id="observation-overlay" aria-hidden="true" aria-label="Watch Experience full-screen display">
|
||||
<!-- Layer 0 & 1: 60fps Deep Celestial Canvas (Parallax 3D Stars, Warp Streaks, Nebulae, Planets, Encounters) -->
|
||||
<canvas id="observation-canvas" class="observation-canvas"></canvas>
|
||||
|
||||
<!-- Legacy / Vector Art Stage (Preserved for compatibility and vector overlays) -->
|
||||
<div class="observation-stage" id="observation-stage"></div>
|
||||
<div class="observation-sim-layer" id="observation-sim-layer"></div>
|
||||
<div class="observation-event-layer" id="observation-event-layer"></div>
|
||||
|
||||
<!-- Layer 2: Viewport Architecture & Glass (Starship Window Frames & Specular Sheen) -->
|
||||
<div class="observation-viewport-frame" id="observation-viewport-frame"></div>
|
||||
<div class="observation-glass-sheen"></div>
|
||||
<div class="observation-scanlines"></div>
|
||||
<div class="observation-vignette"></div>
|
||||
|
||||
<!-- Layer 3: Emergency Alert Environmental Lighting Wash -->
|
||||
<div class="observation-alert-wash" id="observation-alert-wash"></div>
|
||||
|
||||
<!-- Layer 4: Holographic LCARD HUD & Audio Waveform Sill -->
|
||||
<div class="observation-chrome">
|
||||
<div class="observation-ticker" id="observation-ticker">
|
||||
<span class="observation-ticker-label">SYS</span>
|
||||
<div class="observation-ticker-track">
|
||||
<span class="observation-ticker-text" id="observation-ticker-text">STANDBY</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="observation-topline">
|
||||
<div>
|
||||
<div class="observation-mode-label" id="observation-universe">OBSERVATION</div>
|
||||
<div class="observation-profile" id="observation-profile">ACTIVE PROFILE</div>
|
||||
</div>
|
||||
<div class="observation-status" id="observation-status">
|
||||
OBSERVATION ACTIVE<br>
|
||||
PROCEDURAL AUDIO LINK: STANDBY
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Holographic Audio Waveform Sill -->
|
||||
<div class="observation-waveform-container" id="observation-waveform-container">
|
||||
<span class="observation-waveform-label">SUBSPACE HARMONICS</span>
|
||||
<canvas id="observation-waveform-canvas" width="480" height="28"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In-Observation Quick Glass Control Dock (Auto-hides on inactivity) -->
|
||||
<div class="observation-control-dock" id="observation-control-dock">
|
||||
<button class="obs-dock-btn" id="obs-btn-warp" title="Toggle Warp Flight / Orbital Cruise">WARP: OFF</button>
|
||||
<button class="obs-dock-btn active" id="obs-btn-frame" title="Toggle Starship Viewport Framing">VIEWPORT: ON</button>
|
||||
<button class="obs-dock-btn" id="obs-btn-pillars" title="Toggle Window Pillars / Mullions">PILLARS: OFF</button>
|
||||
<button class="obs-dock-btn" id="obs-btn-alert" title="Toggle Red Alert">RED ALERT</button>
|
||||
<select class="obs-dock-select" id="obs-select-preset" title="Switch Preset"></select>
|
||||
<button class="obs-dock-btn obs-dock-close" id="obs-btn-exit" title="Return to Main Console">RETURN ✕</button>
|
||||
</div>
|
||||
|
||||
<div class="observation-return" id="observation-return-pill">CLICK BACKGROUND TO RETURN</div>
|
||||
</section>
|
||||
|
||||
<!-- LCARD Footer Status -->
|
||||
<footer class="lcars-footer">
|
||||
<span id="footer-status-label">SYSTEM STATUS: NORMAL</span>
|
||||
<span><a href="https://www.labyricorn.com/" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none;">VIBE ENGINEERED BY LABYRICORN STUDIOS</a></span>
|
||||
<span id="footer-hotkeys-label">SPACE: PLAY/PAUSE | M: MUTE | ↑/↓: VOLUME</span>
|
||||
</footer>
|
||||
|
||||
<!-- All-in-One Procedural Audio Engine & UI -->
|
||||
<script src="js/audio.js"></script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/core-animations.js"></script>
|
||||
<script src="js/visualizer.js"></script>
|
||||
<script src="js/observation-bezels.js"></script>
|
||||
<script src="js/observation-engine.js"></script>
|
||||
<script src="js/control-bus.js"></script>
|
||||
<script src="js/generative-experience.js"></script>
|
||||
<script src="js/contract-adapter.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* XZBT Control Bus
|
||||
*
|
||||
* XZBTControlBus is a small registry of named, settable console targets
|
||||
* (ranges, toggles and triggers). Anything that wants to drive the console
|
||||
* from outside the DOM — contract adapter, hotkeys, future transports —
|
||||
* talks to the bus rather than reaching into app.js internals.
|
||||
*
|
||||
* MIDI NOTE: Web MIDI control belonged to standalone SciFi-XZBT until
|
||||
* Step 3.5. External MIDI integration will be handled by XZBT-NGN, which
|
||||
* will send canonical contract commands (set / invoke) to this exhibit.
|
||||
* The control bus itself is transport-agnostic and is intentionally kept here.
|
||||
*/
|
||||
|
||||
class XZBTControlBus {
|
||||
constructor() {
|
||||
this.targets = new Map();
|
||||
this.listeners = new Set();
|
||||
}
|
||||
register(id, spec) {
|
||||
this.targets.set(id, { id, ...spec });
|
||||
}
|
||||
set(id, value, source = 'external') {
|
||||
const t = this.targets.get(id);
|
||||
if (!t) return false;
|
||||
let v = value;
|
||||
if (t.type === 'range') {
|
||||
v = Math.max(t.min, Math.min(t.max, Number(value)));
|
||||
if (t.step) v = Math.round(v / t.step) * t.step;
|
||||
}
|
||||
if (t.apply) t.apply(v, source);
|
||||
this.listeners.forEach(fn => fn({ id, value: v, source, target: t }));
|
||||
return true;
|
||||
}
|
||||
trigger(id, source = 'external') {
|
||||
const t = this.targets.get(id);
|
||||
if (!t) return false;
|
||||
if (t.apply) t.apply(true, source);
|
||||
this.listeners.forEach(fn => fn({ id, value: true, source, target: t }));
|
||||
return true;
|
||||
}
|
||||
list() {
|
||||
return [...this.targets.values()];
|
||||
}
|
||||
}
|
||||
window.XZBTControlBus = XZBTControlBus;
|
||||
@@ -0,0 +1,216 @@
|
||||
// Approved settings-panel core animations. Procedural, offline, and dependency-free.
|
||||
(() => {
|
||||
'use strict';
|
||||
const TAU = Math.PI * 2;
|
||||
function line(c, points, color, width = 1) {
|
||||
c.beginPath(); points.forEach(([x, y], i) => i ? c.lineTo(x, y) : c.moveTo(x, y));
|
||||
c.strokeStyle = color; c.lineWidth = width; c.stroke();
|
||||
}
|
||||
function ring(c, x, y, r, color, width = 1, start = 0, end = TAU) {
|
||||
c.beginPath(); c.arc(x, y, r, start, end); c.strokeStyle = color; c.lineWidth = width; c.stroke();
|
||||
}
|
||||
function dot(c, x, y, r, color) {
|
||||
c.fillStyle = color; c.beginPath(); c.arc(x, y, r, 0, TAU); c.fill();
|
||||
}
|
||||
function glow(c, x, y, r, color, alpha = 1) {
|
||||
c.save(); c.globalAlpha = alpha;
|
||||
const g = c.createRadialGradient(x, y, 0, x, y, r);
|
||||
g.addColorStop(0, color); g.addColorStop(1, color + '00');
|
||||
c.fillStyle = g; c.fillRect(x - r, y - r, r * 2, r * 2); c.restore();
|
||||
}
|
||||
function rect(c, x, y, w, h, fill, stroke) {
|
||||
c.fillStyle = fill; c.fillRect(x, y, w, h);
|
||||
if (stroke) { c.strokeStyle = stroke; c.lineWidth = 1; c.strokeRect(x, y, w, h); }
|
||||
}
|
||||
function polygon(c, r, sides, color, rotation = 0, width = 1) {
|
||||
const p = Array.from({ length: sides + 1 }, (_, i) => [Math.cos(i * TAU / sides + rotation) * r, Math.sin(i * TAU / sides + rotation) * r]);
|
||||
line(c, p, color, width);
|
||||
}
|
||||
function base(c) {
|
||||
for (let x = -160; x <= 160; x += 20) for (let y = -120; y <= 120; y += 20) dot(c, x, y, 0.6, '#253545');
|
||||
line(c, [[-158, -93], [-158, -110], [-141, -110]], '#344556');
|
||||
line(c, [[158, 93], [158, 110], [141, 110]], '#344556');
|
||||
}
|
||||
|
||||
const renderers = {
|
||||
starfleet(c, t, load) {
|
||||
const beat = Math.pow((Math.sin(t * 3.4) + 1) / 2, 5);
|
||||
glow(c, 0, 0, 92, '#167eca', 0.17 + load * 0.2);
|
||||
rect(c, -39, -105, 78, 210, '#0b1b2b', '#335770');
|
||||
for (const side of [-1, 1]) {
|
||||
line(c, [[side * 34, -105], [side * 48, -78], [side * 48, 78], [side * 34, 105]], '#6f8d9c', 3);
|
||||
rect(c, -37, side < 0 ? -110 : 102, 74, 8, '#b89976');
|
||||
line(c, [[side * 28, 0], [side * 87, 0], [side * 106, 20], [side * 146, 20]], '#234253', 8);
|
||||
line(c, [[side * 28, 0], [side * 87, 0], [side * 106, 20], [side * 146, 20]], '#80b4c4', 1);
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const travel = (i / 18 + t * 0.55) % 1;
|
||||
const y = side * (100 - travel * 97);
|
||||
const x = Math.sin(travel * 13 + t * 2) * 12;
|
||||
line(c, [[x, y + side * 9], [x, y]], side < 0 ? '#75baff' : '#c8f5ff', 1 + load * 2);
|
||||
}
|
||||
}
|
||||
for (let i = -6; i <= 6; i++) {
|
||||
const wave = Math.pow(Math.max(0, Math.cos(t * 3.4 - Math.abs(i) * 0.62)), 6);
|
||||
c.save(); c.globalAlpha = 0.2 + wave * (0.4 + load * 0.4);
|
||||
line(c, [[-32, i * 15], [-22, i * 15 + 3], [22, i * 15 + 3], [32, i * 15]], '#89eaff', 3); c.restore();
|
||||
}
|
||||
glow(c, 0, 0, 28 + beat * 18 * load, '#81ddff', 0.8);
|
||||
line(c, [[0, -20], [14, 0], [0, 20], [-14, 0], [0, -20]], '#e8fdff', 2);
|
||||
line(c, [[0, -20], [0, 20], [14, 0], [-14, 0]], '#b0eeff');
|
||||
},
|
||||
military(c, t, load) {
|
||||
polygon(c, 104, 6, '#3f4a50', Math.PI / 6, 12);
|
||||
polygon(c, 110, 6, '#a7987e', Math.PI / 6);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = i * TAU / 6 + Math.PI / 6;
|
||||
dot(c, Math.cos(a) * 104, Math.sin(a) * 104, 3, '#d6c6ad');
|
||||
c.save(); c.rotate(a);
|
||||
rect(c, 38, -10, 38, 20, '#171f25', '#655e4d');
|
||||
for (let j = 0; j < 5; j++) rect(c, 42 + j * 6, -6, 4, 12, j < 1 + load * 4 && (t * 2 + i) % 6 > j * 0.65 ? '#e8b468' : '#433e30');
|
||||
c.restore();
|
||||
}
|
||||
for (const [r, direction, color] of [[86, 1, '#d3b285'], [31, -1, '#8ea8a6']]) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const a = i * TAU / 12 + direction * t * 0.65;
|
||||
ring(c, 0, 0, r, color, 4, a, a + 0.3);
|
||||
}
|
||||
}
|
||||
glow(c, 0, 0, 29, '#eab164', 0.25 + load * 0.4);
|
||||
polygon(c, 18, 6, '#f8d09d', 0, 2);
|
||||
line(c, [[-10, 0], [10, 0]], '#e7d8bf', 3);
|
||||
for (const x of [-136, 126]) for (let j = 0; j < 7; j++) rect(c, x, -38 + j * 12, 10, 7, j < load * 7 ? '#c7a16c' : '#263039');
|
||||
},
|
||||
outlaw(c, t, load) {
|
||||
c.save(); c.translate(Math.sin(t * 19) * load * 1.4, Math.sin(t * 23) * load * 1.5);
|
||||
line(c, [[-126, 50], [-93, 50], [-93, -40], [26, -40], [26, -19]], '#a26c4f', 5);
|
||||
line(c, [[-77, 48], [-54, 66], [47, 66], [66, 29]], '#746554', 4);
|
||||
rect(c, -103, -23, 40, 70, '#302d29', '#81705c');
|
||||
rect(c, -74, -38, 100, 85, '#34383b', '#8b8c7b');
|
||||
rect(c, -53, -45, 29, 17, '#674938', '#c28b58');
|
||||
rect(c, -19, -21, 40, 48, '#233c39', '#609a87');
|
||||
for (let i = 0; i < 4; i++) rect(c, -14, -15 + i * 9, 28, 4, '#79b49e');
|
||||
rect(c, 27, -22, 35, 51, '#494139', '#a08c6e');
|
||||
for (let i = 0; i < 4; i++) rect(c, 30 + i * 8, -27, 4, 61, '#80745f');
|
||||
const flicker = 0.8 + Math.sin(t * 17) * 0.12 + Math.sin(t * 29) * 0.08;
|
||||
c.save(); c.translate(68, 3); c.scale((0.2 + load * 0.85) * flicker, 0.4 + load * 0.5);
|
||||
glow(c, 25, 0, 55, '#e88250', 0.5);
|
||||
for (let j = 0; j < 7; j++) {
|
||||
const len = 42 + 35 * Math.sin(t * 13 + j) ** 2;
|
||||
line(c, [[0, (j - 3) * 4], [len * 0.5, (j - 3) * 3], [len, Math.sin(t * 8 + j) * 5]], j % 2 ? '#ffc07c' : '#a3e5e7', 2);
|
||||
}
|
||||
c.restore();
|
||||
dot(c, -67, 2, 27, '#0b1015'); ring(c, -67, 2, 28, '#b0967a', 3);
|
||||
c.save(); c.translate(-67, 2); c.rotate(t * 4);
|
||||
for (let i = 0; i < 5; i++) { c.rotate(TAU / 5); line(c, [[5, 0], [20, -8], [22, 3], [5, 0]], '#a49c87', 3); }
|
||||
c.restore(); dot(c, -67, 2, 6, '#d8ae7a');
|
||||
c.font = '9px Consolas, monospace'; c.fillStyle = '#dcc1a1'; c.fillText('NO. 07', -15, 43);
|
||||
c.restore();
|
||||
},
|
||||
comedy(c, t, load) {
|
||||
c.translate(0, Math.sin(t * 1.2) * 8);
|
||||
glow(c, 0, 0, 100, '#6b49af', 0.17);
|
||||
const colors = ['#d0acff', '#81ded2', '#f0c78e'];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
c.save(); c.rotate(i * Math.PI / 3 + Math.sin(t * 0.5 + i) * 0.3);
|
||||
const r = 74 + load * 15;
|
||||
const squeeze = 0.22 + Math.abs(Math.sin(t * 0.65 + i)) * 0.55;
|
||||
c.scale(1, squeeze); ring(c, 0, 0, r, colors[i], 2);
|
||||
const a = t * (0.7 + i * 0.2) + i * 2;
|
||||
dot(c, Math.cos(a) * r, Math.sin(a) * r, 5, colors[i]); c.restore();
|
||||
}
|
||||
glow(c, 0, 0, 45, '#a686f4', 0.4);
|
||||
const vertices = Array.from({ length: 6 }, (_, i) => {
|
||||
const a = i * TAU / 6 + t * 0.3;
|
||||
const r = 24 + Math.sin(t * 1.7 + i * 2) * (4 + load * 10);
|
||||
return [Math.cos(a) * r, Math.sin(a) * r];
|
||||
});
|
||||
line(c, [...vertices, vertices[0]], '#ece0ff', 2);
|
||||
for (let i = 0; i < 6; i++) { line(c, [vertices[i], vertices[(i + 2) % 6]], '#b6a4d9'); dot(c, ...vertices[i], 2, '#ffffff'); }
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const a = i * 2.4 + t * 0.18;
|
||||
const x = Math.cos(a) * 121, y = Math.sin(a) * 86;
|
||||
line(c, [[x - 3, y], [x + 3, y]], colors[i % 3]); line(c, [[x, y - 3], [x, y + 3]], colors[i % 3]);
|
||||
}
|
||||
},
|
||||
industrial(c, t, load) {
|
||||
const compression = (Math.sin(t * 2) + 1) / 2;
|
||||
rect(c, -70, -93, 140, 186, '#27241e', '#877653');
|
||||
for (const y of [-94, 85]) for (let i = 0; i < 14; i++) rect(c, -70 + i * 10, y, 10, 9, i % 2 ? '#bd943f' : '#30291a');
|
||||
rect(c, -54, -76, 108, 152, '#0d1011', '#5e543d');
|
||||
glow(c, 0, 0, 60 + compression * load * 18, '#ec8c26', 0.6 + load * 0.3);
|
||||
c.save(); c.scale(1 - compression * 0.25, 1 + compression * 0.13);
|
||||
glow(c, 0, 0, 31 + load * 8, '#ffda87'); c.restore();
|
||||
for (const side of [-1, 1]) {
|
||||
const x = side < 0 ? -133 : 78;
|
||||
rect(c, x, -21, 55, 42, '#2e3332', '#6e7770');
|
||||
const reach = 25 + compression * (12 + load * 16);
|
||||
const inner = side * (77 - reach);
|
||||
rect(c, side < 0 ? -78 : inner, -8, reach, 16, '#a6a092', '#d1c6ac');
|
||||
rect(c, inner - (side < 0 ? 0 : 8), -34, 8, 68, '#746447', '#d9b875');
|
||||
}
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const travel = (i / 16 + t * 0.24) % 1;
|
||||
dot(c, Math.sin(i * 8 + travel * 4) * 36, 60 - travel * 125, 0.8 + (i % 3) * 0.5, '#e4ad61');
|
||||
}
|
||||
for (const y of [-65, 59]) for (let i = 0; i < 8; i++) rect(c, -45 + i * 12, y, 6, 6, i < 2 + load * 6 ? '#e4a751' : '#55422b');
|
||||
for (const x of [-63, 63]) for (const y of [-80, 80]) dot(c, x, y, 3, '#ad9b77');
|
||||
},
|
||||
station(c, t, load) {
|
||||
ring(c, 0, 0, 109, '#34545c');
|
||||
for (let i = 0; i < 4; i++) {
|
||||
c.save(); c.rotate(i * Math.PI / 2);
|
||||
line(c, [[-15, -119], [-15, -106], [15, -106], [15, -119]], '#8ba9a9', 3); c.restore();
|
||||
}
|
||||
c.save(); c.rotate(t * 0.24);
|
||||
ring(c, 0, 0, 89, '#263f44', 19); ring(c, 0, 0, 100, '#93bcb8', 2); ring(c, 0, 0, 78, '#537c7e', 2);
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const a = i * TAU / 24;
|
||||
ring(c, 0, 0, 90, i % 4 === 0 ? '#c4d5bd' : '#568d86', 8, a + 0.025, a + 0.16);
|
||||
}
|
||||
for (let i = 0; i < 6; i++) {
|
||||
c.save(); c.rotate(i * TAU / 6);
|
||||
line(c, [[19, -4], [78, -4]], '#5b797c', 2); line(c, [[19, 4], [78, 4]], '#5b797c', 2);
|
||||
for (let j = 0; j < 1 + Math.floor(load * 3); j++) {
|
||||
const travel = (t * 0.55 + j / 3 + i / 6) % 1;
|
||||
dot(c, 22 + travel * 55, 0, 2, '#b4ffe0');
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
c.restore();
|
||||
glow(c, 0, 0, 30, '#73e4c2', 0.3 + load * 0.3);
|
||||
dot(c, 0, 0, 21, '#142d32'); ring(c, 0, 0, 21, '#a3d9cd', 2);
|
||||
line(c, [[-10, -5], [10, -5], [10, 5], [-10, 5], [-10, -5]], '#c5e7dc', 2);
|
||||
for (let i = 0; i < 6; i++) ring(c, 0, 0, 115, '#3b625f', 2, i * TAU / 6 + 0.3, i * TAU / 6 + 0.6);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const modes = {
|
||||
'intermix-cascade': 'starfleet',
|
||||
'tactical-flywheel': 'military',
|
||||
'patchwork-drive': 'outlaw',
|
||||
'improbability-engine': 'comedy',
|
||||
'compression-furnace': 'industrial',
|
||||
'station-hub': 'station'
|
||||
};
|
||||
const CoreAnimations = {
|
||||
has(mode) { return Object.prototype.hasOwnProperty.call(modes, mode); },
|
||||
render(ctx, mode, width, height, time, load) {
|
||||
if (!this.has(mode) || width <= 20 || height <= 42) return;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save();
|
||||
// Preserve the existing label and the full motion envelope at sidebar size.
|
||||
const labelSpace = 32;
|
||||
const bottomSpace = 10;
|
||||
ctx.translate(width / 2, labelSpace + (height - labelSpace - bottomSpace) / 2);
|
||||
const scale = Math.min((width - 20) / 330, (height - labelSpace - bottomSpace) / 250);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
base(ctx);
|
||||
renderers[modes[mode]](ctx, time, Math.max(0, Math.min(1, load)));
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
window.CoreAnimations = CoreAnimations;
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Procedural SVG Viewport Frames & Bezels for Observation Mode
|
||||
*/
|
||||
window.ObservationBezels = {
|
||||
getViewportFrameSvg(universeId, presetId, engine) {
|
||||
if (universeId === 'spacestations') {
|
||||
// Space Stations has its own authentic native station viewport in observationStage!
|
||||
return '';
|
||||
}
|
||||
|
||||
if (universeId === 'starfleet') {
|
||||
let era = 'tng';
|
||||
const preset = (typeof StarshipPresets !== 'undefined' && presetId) ? StarshipPresets[presetId] : null;
|
||||
if (preset && preset.era) era = preset.era;
|
||||
|
||||
if (era === 'tos') {
|
||||
// TOS Original Series - hexagonal bridge viewscreen bezel
|
||||
return `
|
||||
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="tosMetal" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#2b2118"/>
|
||||
<stop offset="50%" stop-color="#171310"/>
|
||||
<stop offset="100%" stop-color="#0a0806"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M0,0 H1600 V70 L1350,190 H250 L0,70 Z" fill="url(#tosMetal)" stroke="#f59e0b" stroke-width="3"/>
|
||||
<path d="M0,900 H1600 V830 L1350,710 H250 L0,830 Z" fill="url(#tosMetal)" stroke="#f59e0b" stroke-width="3"/>
|
||||
<path d="M0,0 V900 H130 L170,710 V190 L130,0 Z" fill="url(#tosMetal)" stroke="#f59e0b" stroke-width="3"/>
|
||||
<path d="M1600,0 V900 H1470 L1430,710 V190 L1470,0 Z" fill="url(#tosMetal)" stroke="#f59e0b" stroke-width="3"/>
|
||||
|
||||
${(engine ? engine.showPillars : false) ? `
|
||||
<rect x="530" y="195" width="30" height="515" fill="#171310" stroke="#dc2626" stroke-width="2.5" opacity="0.9"/>
|
||||
<rect x="1040" y="195" width="30" height="515" fill="#171310" stroke="#dc2626" stroke-width="2.5" opacity="0.9"/>
|
||||
` : ''}
|
||||
|
||||
<text x="800" y="865" text-anchor="middle" fill="#f59e0b" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="4">U.S.S. INTERPRISE NCC-1701.5 // BRIDGE VIEWSCREEN</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
if (era === 'voyager') {
|
||||
// Wayfarer Intrepid Class - sleeker rounded modern arch, cyan accent
|
||||
return `
|
||||
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="voyMetal" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#0f2027"/>
|
||||
<stop offset="50%" stop-color="#0a1418"/>
|
||||
<stop offset="100%" stop-color="#050a0c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M0,0 H1600 V70 Q800,130 0,70 Z" fill="url(#voyMetal)" stroke="#22d3ee" stroke-width="3"/>
|
||||
<path d="M0,900 H1600 V830 Q800,770 0,830 Z" fill="url(#voyMetal)" stroke="#22d3ee" stroke-width="3"/>
|
||||
<path d="M0,0 V900 H80 Q110,450 80,0 Z" fill="url(#voyMetal)"/>
|
||||
<path d="M1600,0 V900 H1520 Q1490,450 1520,0 Z" fill="url(#voyMetal)"/>
|
||||
|
||||
${(engine ? engine.showPillars : false) ? `
|
||||
<path d="M525,90 L535,800 L515,800 L505,90 Z" fill="#0a1418" stroke="#22d3ee" stroke-width="2" opacity="0.9"/>
|
||||
<path d="M1075,90 L1065,800 L1085,800 L1095,90 Z" fill="#0a1418" stroke="#22d3ee" stroke-width="2" opacity="0.9"/>
|
||||
` : ''}
|
||||
|
||||
<g transform="translate(500, 838)">
|
||||
<rect x="0" y="0" width="600" height="20" rx="10" fill="#22d3ee" opacity="0.85"/>
|
||||
<text x="300" y="15" text-anchor="middle" fill="#000" font-family="'Antonio', sans-serif" font-weight="700" font-size="12" letter-spacing="3">USS WAYFARER // BRIDGE VIEWPORT</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
if (era === 'ds9') {
|
||||
// Defiance Class / DS9 Ops - angular, aggressive warship framing
|
||||
return `
|
||||
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="defMetal" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1c1410"/>
|
||||
<stop offset="50%" stop-color="#0c0806"/>
|
||||
<stop offset="100%" stop-color="#1c1410"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M0,0 H1600 V60 L1300,170 H300 L0,60 Z" fill="url(#defMetal)" stroke="#f97316" stroke-width="3"/>
|
||||
<path d="M0,900 H1600 V840 L1300,730 H300 L0,840 Z" fill="url(#defMetal)" stroke="#f97316" stroke-width="3"/>
|
||||
<path d="M0,0 V900 H150 L190,730 V170 L150,0 Z" fill="url(#defMetal)" stroke="#f97316" stroke-width="3"/>
|
||||
<path d="M1600,0 V900 H1450 L1410,730 V170 L1450,0 Z" fill="url(#defMetal)" stroke="#f97316" stroke-width="3"/>
|
||||
<text x="800" y="865" text-anchor="middle" fill="#f97316" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="4">USS DEFIANCE // TACTICAL BRIDGE VIEWPORT</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
if (era === 'nx') {
|
||||
// Interprise NS-01 - early, industrial, submarine-like bulkhead
|
||||
return `
|
||||
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="nxMetal" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1c1917"/>
|
||||
<stop offset="50%" stop-color="#0c0a09"/>
|
||||
<stop offset="100%" stop-color="#1c1917"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M0,0 H1600 V110 L1420,175 H180 L0,110 Z" fill="url(#nxMetal)" stroke="#a16207" stroke-width="3"/>
|
||||
<path d="M0,900 H1600 V790 L1420,725 H180 L0,790 Z" fill="url(#nxMetal)" stroke="#a16207" stroke-width="3"/>
|
||||
<path d="M0,0 V900 H120 L150,725 V175 L120,0 Z" fill="url(#nxMetal)" stroke="#a16207" stroke-width="3"/>
|
||||
<path d="M1600,0 V900 H1480 L1450,725 V175 L1480,0 Z" fill="url(#nxMetal)" stroke="#a16207" stroke-width="3"/>
|
||||
<text x="800" y="865" text-anchor="middle" fill="#a16207" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="4">INTERPRISE NS-01 // COMMAND BRIDGE VIEWPORT</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
// era === 'tng' (or unrecognized) falls through to the default Ten Forward arch below.
|
||||
}
|
||||
|
||||
if (universeId === 'industrial' || universeId === 'outlaw') {
|
||||
// Heavy Industrial Reinforced Bulkhead & Riveted Blast Shutter Framing
|
||||
return `
|
||||
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="indMetal" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1c1917"/>
|
||||
<stop offset="50%" stop-color="#0c0a09"/>
|
||||
<stop offset="100%" stop-color="#1c1917"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Outer Reinforced Rim -->
|
||||
<path d="M0,0 H1600 V100 L1460,160 H140 L0,100 Z" fill="url(#indMetal)" stroke="#b45309" stroke-width="3"/>
|
||||
<path d="M0,900 H1600 V800 L1460,740 H140 L0,800 Z" fill="url(#indMetal)" stroke="#b45309" stroke-width="3"/>
|
||||
<path d="M0,0 V900 H110 L140,740 V160 L110,0 Z" fill="url(#indMetal)" stroke="#b45309" stroke-width="3"/>
|
||||
<path d="M1600,0 V900 H1490 L1460,740 V160 L1490,0 Z" fill="url(#indMetal)" stroke="#b45309" stroke-width="3"/>
|
||||
|
||||
${(engine ? engine.showPillars : false) ? `
|
||||
<!-- Vertical Heavy Structural Mullions -->
|
||||
<rect x="530" y="160" width="34" height="580" fill="#292524" stroke="#78350f" stroke-width="3"/>
|
||||
<rect x="1036" y="160" width="34" height="580" fill="#292524" stroke="#78350f" stroke-width="3"/>
|
||||
` : ''}
|
||||
|
||||
<!-- Hazard Stripes on Lower Sill -->
|
||||
<g opacity="0.6">
|
||||
<rect x="220" y="755" width="1160" height="14" fill="#f59e0b"/>
|
||||
<path d="M230,755 L250,769 M270,755 L290,769 M310,755 L330,769 M350,755 L370,769 M390,755 L410,769 M430,755 L450,769 M470,755 L490,769 M510,755 L530,769" stroke="#000" stroke-width="6"/>
|
||||
</g>
|
||||
<text x="800" y="865" text-anchor="middle" fill="#d97706" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="4">HEAVY INDUSTRIAL VIEWPORT // DECK 04 CARGO GANTRY</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
if (universeId === 'whataverse') {
|
||||
// Gallifreyan Observatory Roundel Viewing Portal / TARDIX Frame
|
||||
// Console era (classic/revival/modern) tints the rings and label.
|
||||
let era = 'revival';
|
||||
const wUniverse = (typeof UniverseRegistry !== 'undefined') ? UniverseRegistry['whataverse'] : null;
|
||||
const preset = (wUniverse && wUniverse.presets && presetId) ? wUniverse.presets[presetId] : null;
|
||||
if (preset && preset.era) era = preset.era;
|
||||
|
||||
let ringA = '#d4af37', ringB = '#00e5ff', dash = '16 12', labelColor = '#d4af37';
|
||||
let label = 'T.A.R.D.I.X. TEMPORAL VORTEX OBSERVATION PORTAL';
|
||||
if (era === 'classic') {
|
||||
ringA = '#b45309'; ringB = '#f5deb3'; dash = '22 10'; labelColor = '#f5deb3';
|
||||
label = 'TYPE 4D CLASSIC CONSOLE // TEMPORAL VIEWPORT';
|
||||
} else if (era === 'modern') {
|
||||
ringA = '#e2e8f0'; ringB = '#94a3b8'; dash = '4 6'; labelColor = '#e2e8f0';
|
||||
label = 'INFINITE WHITE // TEMPORAL OBSERVATION PORTAL';
|
||||
}
|
||||
|
||||
return `
|
||||
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Circular Astrolabe Arch Frame -->
|
||||
<path d="M0,0 H1600 V900 H0 Z M800,450 m-680,0 a680,410 0 1,0 1360,0 a680,410 0 1,0 -1360,0" fill="#030712" fill-rule="evenodd" stroke="${ringB}" stroke-width="4"/>
|
||||
<!-- Gallifreyan Circular Inscriptions -->
|
||||
<ellipse cx="800" cy="450" rx="690" ry="420" fill="none" stroke="${ringA}" stroke-width="3" stroke-dasharray="${dash}"/>
|
||||
<ellipse cx="800" cy="450" rx="715" ry="440" fill="none" stroke="${ringB}" stroke-width="1.5" opacity="0.6"/>
|
||||
<text x="800" y="875" text-anchor="middle" fill="${labelColor}" font-family="'Share Tech Mono', monospace" font-size="17" letter-spacing="4">${label}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
if (universeId === 'deepspace') {
|
||||
// Terok Nor / DS9 Arched Station Viewport overlooking Docking Pylons
|
||||
return `
|
||||
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0,0 H1600 V120 Q800,190 0,120 Z" fill="#0d1117" stroke="#38bdf8" stroke-width="3"/>
|
||||
<path d="M0,900 H1600 V780 Q800,720 0,780 Z" fill="#0d1117" stroke="#38bdf8" stroke-width="3"/>
|
||||
<path d="M0,0 V900 H140 L160,780 Q180,450 160,120 L140,0 Z" fill="#0d1117" stroke="#38bdf8" stroke-width="3"/>
|
||||
<path d="M1600,0 V900 H1460 L1440,780 Q1420,450 1440,120 L1460,0 Z" fill="#0d1117" stroke="#38bdf8" stroke-width="3"/>
|
||||
|
||||
${(engine ? engine.showPillars : false) ? `
|
||||
<!-- Exterior Docking Ring Structure visible outside -->
|
||||
<path d="M180,520 L420,410 L440,430 L180,560 Z" fill="#1e293b" opacity="0.6" stroke="#38bdf8" stroke-width="2"/>
|
||||
<path d="M1420,520 L1180,410 L1160,430 L1420,560 Z" fill="#1e293b" opacity="0.6" stroke="#38bdf8" stroke-width="2"/>
|
||||
` : ''}
|
||||
|
||||
<text x="800" y="860" text-anchor="middle" fill="#38bdf8" font-family="'Share Tech Mono', monospace" font-size="16" letter-spacing="3">DEEP SPACE STATION // PROMENADE OBSERVATION DECK</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
if (universeId === 'bioships' || universeId === 'retrofuture' || universeId === 'military' || universeId === 'comedy') {
|
||||
// These four universes previously had no case here at all and silently fell through
|
||||
// to the Starflight default below, which hardcodes "USS INTERPRISE-G" into the plaque --
|
||||
// showing the wrong ship name in every one of these themes' Observation view.
|
||||
// Fix: reuse the same bulkhead shape (it already themes correctly via the CSS
|
||||
// accent variables set per universe), but pull the REAL ship/deck name for the
|
||||
// plaque text from this universe's own preset data, same source the header uses.
|
||||
const universe = UniverseRegistry[universeId];
|
||||
const preset = (universe && universe.presets && presetId) ? universe.presets[presetId] : null;
|
||||
const plaqueText = preset
|
||||
? `${preset.name.toUpperCase()} // OBSERVATION DECK`
|
||||
: `${universe ? universe.name : universeId.toUpperCase()} // OBSERVATION DECK`;
|
||||
|
||||
return `
|
||||
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="genBulkhead" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#1e222d"/>
|
||||
<stop offset="50%" stop-color="#10141e"/>
|
||||
<stop offset="100%" stop-color="#0a0d14"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M0,0 H1600 V80 Q800,150 0,80 Z" fill="url(#genBulkhead)" stroke="var(--primary-accent, #ff9900)" stroke-width="3"/>
|
||||
<path d="M0,900 H1600 V810 Q800,740 0,810 Z" fill="url(#genBulkhead)" stroke="var(--secondary-accent, #cc99cc)" stroke-width="3"/>
|
||||
<path d="M0,0 V900 H90 Q120,450 90,0 Z" fill="url(#genBulkhead)"/>
|
||||
<path d="M1600,0 V900 H1510 Q1480,450 1510,0 Z" fill="url(#genBulkhead)"/>
|
||||
|
||||
${(engine ? engine.showPillars : false) ? `
|
||||
<path d="M510,95 L535,95 L545,765 L500,765 Z" fill="#151922" stroke="var(--tertiary-accent, #99ccff)" stroke-width="2.5" opacity="0.95"/>
|
||||
<path d="M1090,95 L1065,95 L1055,765 L1100,765 Z" fill="#151922" stroke="var(--tertiary-accent, #99ccff)" stroke-width="2.5" opacity="0.95"/>
|
||||
` : ''}
|
||||
|
||||
<g transform="translate(480, 830)">
|
||||
<rect x="0" y="0" width="640" height="24" rx="12" fill="var(--primary-accent, #ff9900)" opacity="0.85"/>
|
||||
<text x="320" y="17" text-anchor="middle" fill="#000" font-family="'Antonio', sans-serif" font-weight="700" font-size="14" letter-spacing="3">${plaqueText}</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// Default: Iconic Starflight Ten Forward / Galaxy-Class Observation Lounge
|
||||
return `
|
||||
<svg viewBox="0 0 1600 900" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="sfBulkhead" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#1e222d"/>
|
||||
<stop offset="50%" stop-color="#10141e"/>
|
||||
<stop offset="100%" stop-color="#0a0d14"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Top Arched Bulkhead Canopy -->
|
||||
<path d="M0,0 H1600 V80 Q800,150 0,80 Z" fill="url(#sfBulkhead)" stroke="var(--primary-accent, #ff9900)" stroke-width="3"/>
|
||||
|
||||
<!-- Bottom Curved Padded Console Sill -->
|
||||
<path d="M0,900 H1600 V810 Q800,740 0,810 Z" fill="url(#sfBulkhead)" stroke="var(--secondary-accent, #cc99cc)" stroke-width="3"/>
|
||||
|
||||
<!-- Side Bulkheads -->
|
||||
<path d="M0,0 V900 H90 Q120,450 90,0 Z" fill="url(#sfBulkhead)"/>
|
||||
<path d="M1600,0 V900 H1510 Q1480,450 1510,0 Z" fill="url(#sfBulkhead)"/>
|
||||
|
||||
${(engine ? engine.showPillars : false) ? `
|
||||
<!-- Vertical Architectural Tapered Mullions dividing into 3 bays -->
|
||||
<path d="M510,95 L535,95 L545,765 L500,765 Z" fill="#151922" stroke="var(--tertiary-accent, #99ccff)" stroke-width="2.5" opacity="0.95"/>
|
||||
<path d="M1090,95 L1065,95 L1055,765 L1100,765 Z" fill="#151922" stroke="var(--tertiary-accent, #99ccff)" stroke-width="2.5" opacity="0.95"/>
|
||||
` : ''}
|
||||
|
||||
<!-- Authentic LCARD Display Bar along lower sill -->
|
||||
<g transform="translate(480, 830)">
|
||||
<rect x="0" y="0" width="640" height="24" rx="12" fill="var(--primary-accent, #ff9900)" opacity="0.85"/>
|
||||
<text x="320" y="17" text-anchor="middle" fill="#000" font-family="'Antonio', sans-serif" font-weight="700" font-size="14" letter-spacing="3">USS INTERPRISE-G // OBSERVATION LOUNGE // DECK 10 FORWARD</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,673 @@
|
||||
class StarshipVisualizer {
|
||||
constructor(audioManager, warpSynth) {
|
||||
this.am = audioManager;
|
||||
this.warpSynth = warpSynth;
|
||||
|
||||
this.spectrumCanvas = null;
|
||||
this.spectrumCtx = null;
|
||||
this.warpCoreCanvas = null;
|
||||
this.warpCoreCtx = null;
|
||||
|
||||
this.animationFrameId = null;
|
||||
this.pulseEnergy = 0.2;
|
||||
this.warpParticles = [];
|
||||
this.mode = 'warp-core'; // 'warp-core' or 'time-rotor'
|
||||
this.rotorPhase = 0;
|
||||
this.coreTime = 0;
|
||||
this.coreLoad = 0.6;
|
||||
this.lastFrameTime = null;
|
||||
|
||||
// Hook into worp core pulse callback
|
||||
if (this.warpSynth) {
|
||||
this.warpSynth.onPulse = (phase, duration) => {
|
||||
this.pulseEnergy = 1.0;
|
||||
this.spawnWarpPulses();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
this.mode = mode || 'warp-core';
|
||||
}
|
||||
|
||||
init(spectrumCanvasId, warpCoreCanvasId) {
|
||||
this.spectrumCanvas = document.getElementById(spectrumCanvasId);
|
||||
if (this.spectrumCanvas) {
|
||||
this.spectrumCtx = this.spectrumCanvas.getContext('2d');
|
||||
}
|
||||
|
||||
this.warpCoreCanvas = document.getElementById(warpCoreCanvasId);
|
||||
if (this.warpCoreCanvas) {
|
||||
this.warpCoreCtx = this.warpCoreCanvas.getContext('2d');
|
||||
this.initWarpParticles();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => this.resizeCanvases());
|
||||
this.resizeCanvases();
|
||||
this.startRenderLoop();
|
||||
}
|
||||
|
||||
resizeCanvases() {
|
||||
if (this.spectrumCanvas) {
|
||||
const rect = this.spectrumCanvas.parentElement.getBoundingClientRect();
|
||||
this.spectrumCanvas.width = rect.width * window.devicePixelRatio;
|
||||
this.spectrumCanvas.height = (rect.height || 160) * window.devicePixelRatio;
|
||||
this.spectrumCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
}
|
||||
if (this.warpCoreCanvas) {
|
||||
const rect = this.warpCoreCanvas.parentElement.getBoundingClientRect();
|
||||
this.warpCoreCanvas.width = rect.width * window.devicePixelRatio;
|
||||
this.warpCoreCanvas.height = (rect.height || 260) * window.devicePixelRatio;
|
||||
this.warpCoreCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
}
|
||||
}
|
||||
|
||||
initWarpParticles() {
|
||||
this.warpParticles = [];
|
||||
for (let i = 0; i < 36; i++) {
|
||||
this.warpParticles.push({
|
||||
y: Math.random(),
|
||||
speed: (Math.random() * 0.008 + 0.004) * (Math.random() > 0.5 ? 1 : -1),
|
||||
size: Math.random() * 4 + 2,
|
||||
opacity: Math.random() * 0.7 + 0.3
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
spawnWarpPulses() {
|
||||
// The new machines render their own flow; only legacy particle modes need a pool.
|
||||
if (this.mode !== 'warp-core' && this.mode !== 'time-rotor') return;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
this.warpParticles.push({
|
||||
y: 0.5, // Center matter/antimatter reaction plane
|
||||
speed: (Math.random() * 0.018 + 0.01) * (i % 2 === 0 ? 1 : -1),
|
||||
size: Math.random() * 6 + 3,
|
||||
opacity: 1.0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
startRenderLoop() {
|
||||
this.lastFrameTime = null;
|
||||
const render = (now) => {
|
||||
const elapsed = this.lastFrameTime === null ? 0 : Math.min((now - this.lastFrameTime) / 1000, 0.05);
|
||||
this.lastFrameTime = now;
|
||||
const volume = this.warpSynth && !this.warpSynth.isMuted ? this.warpSynth.params.volume : 0;
|
||||
const targetLoad = Math.max(0, Math.min(1, volume * (0.65 + this.pulseEnergy * 0.35)));
|
||||
this.coreLoad += (targetLoad - this.coreLoad) * (1 - Math.exp(-elapsed * 6));
|
||||
this.coreTime += elapsed * (this.mode === 'tactical-flywheel' ? 0.6 + this.coreLoad * 0.8 : 1);
|
||||
this.renderSpectrum();
|
||||
if (window.CoreAnimations && CoreAnimations.has(this.mode)) {
|
||||
if (this.warpCoreCanvas && this.warpCoreCtx) {
|
||||
CoreAnimations.render(this.warpCoreCtx, this.mode,
|
||||
this.warpCoreCanvas.width / window.devicePixelRatio,
|
||||
this.warpCoreCanvas.height / window.devicePixelRatio,
|
||||
this.coreTime, this.coreLoad);
|
||||
}
|
||||
} else {
|
||||
switch (this.mode) {
|
||||
case 'time-rotor':
|
||||
this.renderTimeRotor();
|
||||
break;
|
||||
case 'industrial-reactor':
|
||||
this.renderIndustrialReactor();
|
||||
break;
|
||||
case 'bio-heart':
|
||||
this.renderBioHeart();
|
||||
break;
|
||||
case 'retro-oscilloscope':
|
||||
this.renderRetroOscilloscope();
|
||||
break;
|
||||
case 'singularity-core':
|
||||
this.renderSingularityCore();
|
||||
break;
|
||||
case 'warp-core':
|
||||
default:
|
||||
this.renderWarpCore();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Decay pulse energy smoothly
|
||||
this.pulseEnergy = Math.max(0.15, this.pulseEnergy * Math.pow(0.94, elapsed * 60));
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
renderSpectrum() {
|
||||
if (!this.spectrumCanvas || !this.spectrumCtx || !this.am.analyser) return;
|
||||
|
||||
const ctx = this.spectrumCtx;
|
||||
const w = this.spectrumCanvas.width / window.devicePixelRatio;
|
||||
const h = this.spectrumCanvas.height / window.devicePixelRatio;
|
||||
|
||||
const bufferLength = this.am.analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
this.am.analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Dynamic grid color per visualizer mode
|
||||
let gridCol = 'rgba(255, 153, 0, 0.12)';
|
||||
if (this.mode === 'time-rotor' || this.mode === 'singularity-core') gridCol = 'rgba(0, 229, 255, 0.12)';
|
||||
else if (this.mode === 'bio-heart') gridCol = 'rgba(16, 185, 129, 0.12)';
|
||||
else if (this.mode === 'retro-oscilloscope') gridCol = 'rgba(34, 197, 94, 0.15)';
|
||||
else if (this.mode === 'industrial-reactor' || this.mode === 'compression-furnace' || this.mode === 'station-hub') gridCol = 'rgba(245, 158, 11, 0.15)';
|
||||
|
||||
ctx.strokeStyle = gridCol;
|
||||
ctx.lineWidth = 1;
|
||||
for (let y = 20; y < h; y += 30) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw segmented frequency bars
|
||||
const numBars = 32;
|
||||
const barWidth = (w / numBars) - 3;
|
||||
|
||||
for (let i = 0; i < numBars; i++) {
|
||||
const binIdx = Math.floor(Math.pow(i / numBars, 1.8) * (bufferLength * 0.6));
|
||||
const val = dataArray[binIdx] || 0;
|
||||
const barHeight = Math.max(4, (val / 255) * (h - 20));
|
||||
|
||||
const x = i * (barWidth + 3);
|
||||
const y = h - barHeight;
|
||||
|
||||
let col;
|
||||
if (this.mode === 'time-rotor') {
|
||||
if (i < 8) col = '#00e5ff';
|
||||
else if (i < 20) col = '#38bdf8';
|
||||
else if (i < 28) col = '#d4af37';
|
||||
else col = '#ffffff';
|
||||
} else if (this.mode === 'bio-heart') {
|
||||
if (i < 8) col = '#10b981';
|
||||
else if (i < 20) col = '#34d399';
|
||||
else if (i < 28) col = '#a855f7';
|
||||
else col = '#c084fc';
|
||||
} else if (this.mode === 'retro-oscilloscope') {
|
||||
col = i < 28 ? '#22c55e' : '#86efac';
|
||||
} else if (this.mode === 'industrial-reactor' || this.mode === 'compression-furnace' || this.mode === 'station-hub') {
|
||||
if (i < 8) col = '#d97706';
|
||||
else if (i < 20) col = '#f59e0b';
|
||||
else if (i < 28) col = '#fbbf24';
|
||||
else col = '#fef08a';
|
||||
} else if (this.mode === 'singularity-core') {
|
||||
if (i < 8) col = '#4f46e5';
|
||||
else if (i < 20) col = '#6366f1';
|
||||
else if (i < 28) col = '#38bdf8';
|
||||
else col = '#ffffff';
|
||||
} else {
|
||||
// Starflight / Classic LCARD
|
||||
if (i < 8) col = '#ff6600';
|
||||
else if (i < 20) col = '#ff9933';
|
||||
else if (i < 28) col = '#cc99cc';
|
||||
else col = '#99ccff';
|
||||
}
|
||||
|
||||
ctx.fillStyle = col;
|
||||
ctx.shadowColor = col;
|
||||
ctx.shadowBlur = val > 120 ? 8 : 0;
|
||||
ctx.fillRect(x, y, barWidth, barHeight);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(x, y - 2, barWidth, 2);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
renderWarpCore() {
|
||||
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
|
||||
|
||||
const ctx = this.warpCoreCtx;
|
||||
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
|
||||
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const centerX = w / 2;
|
||||
const chamberWidth = Math.min(70, w * 0.4);
|
||||
|
||||
// 1. Draw outer intermix chamber housing
|
||||
ctx.fillStyle = '#111625';
|
||||
ctx.fillRect(centerX - chamberWidth / 2 - 8, 0, chamberWidth + 16, h);
|
||||
|
||||
// Chamber glass gradient
|
||||
const glassGrad = ctx.createLinearGradient(centerX - chamberWidth / 2, 0, centerX + chamberWidth / 2, 0);
|
||||
glassGrad.addColorStop(0, 'rgba(0, 50, 100, 0.4)');
|
||||
glassGrad.addColorStop(0.5, 'rgba(0, 180, 255, 0.15)');
|
||||
glassGrad.addColorStop(1, 'rgba(0, 50, 100, 0.4)');
|
||||
ctx.fillStyle = glassGrad;
|
||||
ctx.fillRect(centerX - chamberWidth / 2, 0, chamberWidth, h);
|
||||
|
||||
// 2. Matter / Antimatter injectors (Top and Bottom)
|
||||
ctx.fillStyle = '#ff9900';
|
||||
ctx.fillRect(centerX - chamberWidth / 2 - 4, 0, chamberWidth + 8, 12);
|
||||
ctx.fillRect(centerX - chamberWidth / 2 - 4, h - 12, chamberWidth + 8, 12);
|
||||
|
||||
// 3. Central Reaction Intermix Chamber (Center glowing disc)
|
||||
const centerY = h / 2;
|
||||
const glowRadius = 24 + this.pulseEnergy * 28;
|
||||
const coreGlow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, glowRadius);
|
||||
coreGlow.addColorStop(0, '#ffffff');
|
||||
coreGlow.addColorStop(0.3, `rgba(0, 210, 255, ${0.7 + this.pulseEnergy * 0.3})`);
|
||||
coreGlow.addColorStop(0.7, `rgba(0, 100, 255, ${0.4 + this.pulseEnergy * 0.4})`);
|
||||
coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
ctx.fillStyle = coreGlow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, glowRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 4. Segmented Magnetic Constriction Coils (horizontal pulsing rings)
|
||||
const numCoils = 14;
|
||||
for (let i = 0; i < numCoils; i++) {
|
||||
const coilY = (i / (numCoils - 1)) * (h - 30) + 15;
|
||||
const distFromCenter = Math.abs(coilY - centerY) / (h / 2);
|
||||
const coilIntensity = Math.max(0.2, (1.0 - distFromCenter * 0.6) * (0.4 + this.pulseEnergy * 0.6));
|
||||
|
||||
ctx.fillStyle = `rgba(0, 230, 255, ${coilIntensity})`;
|
||||
ctx.shadowColor = '#00e6ff';
|
||||
ctx.shadowBlur = this.pulseEnergy > 0.6 ? 12 : 3;
|
||||
|
||||
// Draw coil bar
|
||||
ctx.fillRect(centerX - chamberWidth / 2 + 4, coilY - 2, chamberWidth - 8, 4);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 5. Plasma stream particles
|
||||
for (let i = this.warpParticles.length - 1; i >= 0; i--) {
|
||||
const p = this.warpParticles[i];
|
||||
p.y += p.speed;
|
||||
|
||||
if (p.y < 0 || p.y > 1) {
|
||||
if (this.warpParticles.length > 36) {
|
||||
this.warpParticles.splice(i, 1);
|
||||
continue;
|
||||
} else {
|
||||
p.y = p.speed > 0 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
const py = p.y * h;
|
||||
const px = centerX + (Math.sin(p.y * 12) * (chamberWidth * 0.25));
|
||||
|
||||
ctx.fillStyle = `rgba(180, 240, 255, ${p.opacity * (0.4 + this.pulseEnergy * 0.6)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, p.size * (0.8 + this.pulseEnergy * 0.4), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the canonical TARDIX Central Time Rotor
|
||||
* A glass cylinder containing an interior mechanical column physically rising and falling
|
||||
* in sync with the pulse cycle, illuminated with glowing Gallifreyan cyan/emerald light.
|
||||
*/
|
||||
renderTimeRotor() {
|
||||
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
|
||||
|
||||
const ctx = this.warpCoreCtx;
|
||||
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
|
||||
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const centerX = w / 2;
|
||||
const columnWidth = Math.min(84, w * 0.45);
|
||||
|
||||
// 1. TARDIX Console Plinth & Ceiling Collar (Victorian Brass / Gallifreyan Bronze)
|
||||
const collarGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0);
|
||||
collarGrad.addColorStop(0, '#593e10');
|
||||
collarGrad.addColorStop(0.3, '#d4af37');
|
||||
collarGrad.addColorStop(0.7, '#fef08a');
|
||||
collarGrad.addColorStop(1, '#593e10');
|
||||
|
||||
ctx.fillStyle = collarGrad;
|
||||
ctx.fillRect(centerX - columnWidth / 2 - 8, 0, columnWidth + 16, 14);
|
||||
ctx.fillRect(centerX - columnWidth / 2 - 8, h - 14, columnWidth + 16, 14);
|
||||
|
||||
// 2. Outer Glass Column Tube
|
||||
const glassGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0);
|
||||
glassGrad.addColorStop(0, 'rgba(0, 40, 80, 0.45)');
|
||||
glassGrad.addColorStop(0.15, 'rgba(0, 229, 255, 0.25)');
|
||||
glassGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.12)');
|
||||
glassGrad.addColorStop(0.85, 'rgba(0, 229, 255, 0.25)');
|
||||
glassGrad.addColorStop(1, 'rgba(0, 40, 80, 0.45)');
|
||||
|
||||
ctx.fillStyle = glassGrad;
|
||||
ctx.fillRect(centerX - columnWidth / 2, 14, columnWidth, h - 28);
|
||||
|
||||
// Glass edge highlights
|
||||
ctx.strokeStyle = 'rgba(0, 229, 255, 0.6)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(centerX - columnWidth / 2, 14, columnWidth, h - 28);
|
||||
|
||||
// 3. Central Bobbing Time Rotor Column
|
||||
// Physical oscillation: rises and falls smoothly
|
||||
this.rotorPhase += 0.038;
|
||||
const maxTravel = (h - 90) * 0.35;
|
||||
const rotorOffset = Math.sin(this.rotorPhase) * maxTravel;
|
||||
const rotorCenterY = (h / 2) + rotorOffset;
|
||||
const rotorHeight = (h - 28) * 0.48;
|
||||
|
||||
// Moving Inner Rotor Rod & Glass Tubes
|
||||
const innerWidth = columnWidth * 0.58;
|
||||
|
||||
// Glowing core glow
|
||||
const coreGlow = ctx.createRadialGradient(centerX, rotorCenterY, 4, centerX, rotorCenterY, 36 + this.pulseEnergy * 30);
|
||||
coreGlow.addColorStop(0, '#ffffff');
|
||||
coreGlow.addColorStop(0.4, `rgba(0, 229, 255, ${0.7 + this.pulseEnergy * 0.3})`);
|
||||
coreGlow.addColorStop(0.8, `rgba(0, 100, 200, ${0.3 + this.pulseEnergy * 0.4})`);
|
||||
coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
ctx.fillStyle = coreGlow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, rotorCenterY, 36 + this.pulseEnergy * 30, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Inner mechanical tubes
|
||||
ctx.fillStyle = '#00e5ff';
|
||||
ctx.shadowColor = '#00e5ff';
|
||||
ctx.shadowBlur = 10 + this.pulseEnergy * 10;
|
||||
ctx.fillRect(centerX - 4, rotorCenterY - rotorHeight / 2, 8, rotorHeight);
|
||||
|
||||
// Left and right secondary crystal tubes
|
||||
ctx.fillStyle = 'rgba(180, 240, 255, 0.85)';
|
||||
ctx.fillRect(centerX - innerWidth / 2 + 2, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20);
|
||||
ctx.fillRect(centerX + innerWidth / 2 - 7, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20);
|
||||
|
||||
// Gallifreyan Circular Rotor Rings
|
||||
for (let r = 0; r < 4; r++) {
|
||||
const ringY = rotorCenterY - rotorHeight / 2 + (r * (rotorHeight / 3));
|
||||
ctx.strokeStyle = '#d4af37';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(centerX, ringY, innerWidth / 2 + 2, 5, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 4. Sparkling Vortex Time Energy Particles
|
||||
for (let i = this.warpParticles.length - 1; i >= 0; i--) {
|
||||
const p = this.warpParticles[i];
|
||||
p.y += p.speed * 0.8;
|
||||
|
||||
if (p.y < 0.05 || p.y > 0.95) {
|
||||
if (this.warpParticles.length > 36) {
|
||||
this.warpParticles.splice(i, 1);
|
||||
continue;
|
||||
} else {
|
||||
p.y = p.speed > 0 ? 0.05 : 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
const py = p.y * h;
|
||||
const px = centerX + (Math.sin(p.y * 16 + this.rotorPhase) * (columnWidth * 0.32));
|
||||
|
||||
ctx.fillStyle = `rgba(0, 229, 255, ${p.opacity * (0.5 + this.pulseEnergy * 0.5)})`;
|
||||
ctx.shadowColor = '#00e5ff';
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, p.size * (0.7 + this.pulseEnergy * 0.5), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Industrial Fusion Reactor (Nostromo, Serenity, Rocinante)
|
||||
* Heavy containment walls, incandescent glowing amber plasma core, heat radiating coils
|
||||
*/
|
||||
renderIndustrialReactor() {
|
||||
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
|
||||
const ctx = this.warpCoreCtx;
|
||||
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
|
||||
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
const chamberW = Math.min(80, w * 0.42);
|
||||
|
||||
// Cast iron frame
|
||||
ctx.fillStyle = '#1c150c';
|
||||
ctx.fillRect(centerX - chamberW / 2 - 10, 0, chamberW + 20, h);
|
||||
|
||||
// Hazard warning bands at top and bottom
|
||||
for (let x = centerX - chamberW / 2 - 10; x < centerX + chamberW / 2 + 10; x += 12) {
|
||||
ctx.fillStyle = (x % 24 === 0) ? '#d97706' : '#1a1106';
|
||||
ctx.fillRect(x, 0, 12, 10);
|
||||
ctx.fillRect(x, h - 10, 12, 10);
|
||||
}
|
||||
|
||||
// Incandescent molten amber core
|
||||
const radius = 22 + this.pulseEnergy * 32;
|
||||
const glow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, radius);
|
||||
glow.addColorStop(0, '#ffffff');
|
||||
glow.addColorStop(0.2, '#fef08a');
|
||||
glow.addColorStop(0.5, `rgba(245, 158, 11, ${0.7 + this.pulseEnergy * 0.3})`);
|
||||
glow.addColorStop(1, 'rgba(180, 83, 9, 0)');
|
||||
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Heat induction coil clamps
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const cy = 20 + i * ((h - 40) / 8);
|
||||
ctx.fillStyle = (i % 2 === 0) ? '#f59e0b' : '#78350f';
|
||||
ctx.shadowColor = '#f59e0b';
|
||||
ctx.shadowBlur = this.pulseEnergy > 0.6 ? 10 : 2;
|
||||
ctx.fillRect(centerX - chamberW / 2, cy - 3, chamberW, 6);
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Living Leviathon Bio-Heart (Moya, Lexx, Species 8675309)
|
||||
* Pulsing vascular heart sac with bioluminescent emerald/violet energy and neural veins
|
||||
*/
|
||||
renderBioHeart() {
|
||||
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
|
||||
const ctx = this.warpCoreCtx;
|
||||
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
|
||||
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
|
||||
// Organic vascular expansion
|
||||
const bioScale = 1.0 + Math.sin(this.rotorPhase * 1.2) * 0.12 + this.pulseEnergy * 0.18;
|
||||
const baseR = 35 * bioScale;
|
||||
|
||||
// Outer bioluminescent aura
|
||||
const aura = ctx.createRadialGradient(centerX, centerY, 4, centerX, centerY, baseR * 1.8);
|
||||
aura.addColorStop(0, '#a7f3d0');
|
||||
aura.addColorStop(0.3, `rgba(16, 185, 129, ${0.7 + this.pulseEnergy * 0.3})`);
|
||||
aura.addColorStop(0.7, `rgba(139, 92, 246, ${0.3 + this.pulseEnergy * 0.3})`);
|
||||
aura.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
ctx.fillStyle = aura;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, baseR * 1.8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Pulsing neural veins
|
||||
ctx.strokeStyle = '#34d399';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.shadowColor = '#10b981';
|
||||
ctx.shadowBlur = 8;
|
||||
for (let v = 0; v < 6; v++) {
|
||||
const angle = (v / 6) * Math.PI * 2 + this.rotorPhase * 0.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
const cpX = centerX + Math.cos(angle + 0.5) * (baseR * 0.8);
|
||||
const cpY = centerY + Math.sin(angle + 0.5) * (baseR * 0.8);
|
||||
const endX = centerX + Math.cos(angle) * (baseR * 1.5);
|
||||
const endY = centerY + Math.sin(angle) * (baseR * 1.5);
|
||||
ctx.quadraticCurveTo(cpX, cpY, endX, endY);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retro Oscilloscope & Analog Astrogator (Jupiter 2, Discovery One)
|
||||
* 1950s/60s green phosphor CRT screen with glowing Lissajous audio wave rings
|
||||
*/
|
||||
renderRetroOscilloscope() {
|
||||
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
|
||||
const ctx = this.warpCoreCtx;
|
||||
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
|
||||
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
const crtRadius = Math.min(w, h) * 0.42;
|
||||
|
||||
// Circular CRT bezel
|
||||
ctx.fillStyle = '#052e16';
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, crtRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#22c55e';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Crosshairs
|
||||
ctx.strokeStyle = 'rgba(34, 197, 94, 0.25)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX - crtRadius, centerY);
|
||||
ctx.lineTo(centerX + crtRadius, centerY);
|
||||
ctx.moveTo(centerX, centerY - crtRadius);
|
||||
ctx.lineTo(centerX, centerY + crtRadius);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw a neutral trace before audio initialization, replacing the previous theme.
|
||||
const analyser = this.am.analyser;
|
||||
const bufferLength = analyser ? analyser.fftSize : 128;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
if (analyser) analyser.getByteTimeDomainData(dataArray);
|
||||
else dataArray.fill(128);
|
||||
|
||||
ctx.strokeStyle = '#86efac';
|
||||
ctx.shadowColor = '#22c55e';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
|
||||
const points = 48;
|
||||
for (let i = 0; i < points; i++) {
|
||||
const idx = Math.floor((i / points) * (bufferLength / 2));
|
||||
const v = (dataArray[idx] / 128.0) - 1.0;
|
||||
const angle = (i / points) * Math.PI * 2 + this.coreTime;
|
||||
const r = (crtRadius * 0.65) + (v * 28 * (0.8 + this.pulseEnergy));
|
||||
const x = centerX + Math.cos(angle) * r;
|
||||
const y = centerY + Math.sin(angle) * r;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
// A sweep marker makes rotation legible even when the audio trace is silent.
|
||||
const sweepRadius = (crtRadius * 0.65) + ((dataArray[0] / 128.0) - 1.0) * 28 * (0.8 + this.pulseEnergy);
|
||||
ctx.fillStyle = '#d1fae5';
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX + Math.cos(this.coreTime) * sweepRadius,
|
||||
centerY + Math.sin(this.coreTime) * sweepRadius, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gravity Singularity Core (Event Horizon, Deep Space)
|
||||
* Black hole event horizon with warping gravitational accretion disk
|
||||
*/
|
||||
renderSingularityCore() {
|
||||
if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
|
||||
const ctx = this.warpCoreCtx;
|
||||
const w = this.warpCoreCanvas.width / window.devicePixelRatio;
|
||||
const h = this.warpCoreCanvas.height / window.devicePixelRatio;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const centerX = w / 2;
|
||||
const centerY = h / 2;
|
||||
const diskR = Math.min(w, h) * 0.44;
|
||||
|
||||
// Glowing gravitational accretion disk
|
||||
ctx.save();
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(this.rotorPhase * 0.6);
|
||||
|
||||
const grad = ctx.createRadialGradient(0, 0, 12, 0, 0, diskR);
|
||||
grad.addColorStop(0, '#000000');
|
||||
grad.addColorStop(0.35, '#000000');
|
||||
grad.addColorStop(0.45, `rgba(99, 102, 241, ${0.8 + this.pulseEnergy * 0.2})`);
|
||||
grad.addColorStop(0.7, `rgba(56, 189, 248, ${0.4 + this.pulseEnergy * 0.3})`);
|
||||
grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, diskR, diskR * 0.35, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Pure black event horizon sphere at center
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.strokeStyle = 'rgba(99, 102, 241, 0.8)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.shadowColor = '#6366f1';
|
||||
ctx.shadowBlur = 12 + this.pulseEnergy * 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, 18, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
window.StarshipVisualizer = StarshipVisualizer;
|
||||
|
||||
// Helper: Hex color to RGBA
|
||||
function hexToRgba(hex, alpha = 1) {
|
||||
if (!hex || hex.charAt(0) !== '#') return `rgba(56, 189, 248, ${alpha})`;
|
||||
let c = hex.substring(1);
|
||||
if (c.length === 3) c = c.split('').map(x => x + x).join('');
|
||||
const num = parseInt(c, 16);
|
||||
return `rgba(${(num >> 16) & 255}, ${(num >> 8) & 255}, ${num & 255}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* CINEMATIC OBSERVATION LOUNGE ENGINE (v9g)
|
||||
* ============================================================================
|
||||
* Features:
|
||||
* - 60fps DPI-Aware Deep Celestial Canvas (Parallax 3D Starfield & Warp Tunnel)
|
||||
* - Relativistic Warp Flight vs. Orbital Cruise Impulse Modes
|
||||
* - Procedural Celestial Bodies (Class-M Planet with Atmospheric Glow, Time Vortex, Gas Giants)
|
||||
* - Living Traffic & Encounters (Shuttles, Cruisers, Decloaking Klingon BOP, TARDIX, Freighters)
|
||||
* - Viewport Window Framing Architecture per Universe (Starflight, Industrial, Station, Whataverse, Military)
|
||||
* - Emergency Alert Synchronization (Red/Yellow Alert Klaxon Strobes & Shield Grids)
|
||||
* - Subspace Audio Harmonics Waveform Sill
|
||||
* - Auto-Hiding Interactive Glass Control Dock
|
||||
*/
|
||||
// =========================================================================
|
||||
// OBSERVATION CANVAS MANIFEST (v3co)
|
||||
// =========================================================================
|
||||
// Each universe declares exactly which canvas layers it draws. Default-deny:
|
||||
// anything not listed is OFF. A universe missing from this table gets no canvas at all.
|
||||
// This table is the contract that keeps each theme's OBSERVATION its own experience --
|
||||
// do not add a layer here to "fill space"; give the theme its own bespoke content instead.
|
||||
//
|
||||
// `starfield` entries may carry a per-universe profile so that two universes drawing stars
|
||||
// are still drawing THEIR OWN stars (density, palette, scale), not one shared layer.
|
||||
@@ -0,0 +1,710 @@
|
||||
/*
|
||||
* XZBT Exhibit Contract 5.2 — generic contract core.
|
||||
*
|
||||
* WHY THIS FILE IS GENERIC
|
||||
* ------------------------
|
||||
* Everything in here is domain-free. It knows about the *shape* of the
|
||||
* contract (envelopes, target kinds, revisions, sequences, error codes,
|
||||
* capability lifecycle) and nothing about aquariums, planetariums, haunted
|
||||
* houses, or any other subject matter. It contains no exhibit state, no
|
||||
* exhibit vocabulary, and no transport.
|
||||
*
|
||||
* An exhibit supplies three things and gets a conforming contract surface:
|
||||
*
|
||||
* 1. a target catalog (canonical dotted IDs -> descriptors)
|
||||
* 2. a setter table (target ID -> absolute, idempotent setter)
|
||||
* 3. an action table (target ID -> real impulse implementation)
|
||||
*
|
||||
* The core owns the canonical mutation/invoke chokepoint, so every input
|
||||
* source (native UI, hotkey, host transport) converges on one path and one
|
||||
* set of transaction semantics. That is the whole point of sharing it: the
|
||||
* contract rules are subtle enough that three hand-rolled copies would drift.
|
||||
*
|
||||
* This file is a classic script (no ES modules) and publishes one global.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var CONTRACT_MAJOR = 5;
|
||||
var CONTRACT_MINOR = 2;
|
||||
var XZBT_VERSION = '5.2';
|
||||
|
||||
/* Contract 5.2 §15. The exhibit assigns source at its own trusted
|
||||
* boundary; a source supplied by a caller is never trusted. */
|
||||
var SOURCES = ['ui', 'midi', 'hotkey', 'host', 'scenario', 'internal', 'system'];
|
||||
|
||||
/* Contract 5.2 §24. */
|
||||
var ERROR_CODES = [
|
||||
'UNSUPPORTED_VERSION',
|
||||
'INVALID_MESSAGE',
|
||||
'INVALID_SESSION',
|
||||
'UNKNOWN_TARGET',
|
||||
'INVALID_VALUE',
|
||||
'INVALID_ARGUMENTS',
|
||||
'CAPABILITY_UNAVAILABLE',
|
||||
'TARGET_READ_ONLY',
|
||||
'TARGET_NOT_INVOKABLE',
|
||||
'TARGET_NOT_SETTABLE',
|
||||
'INTERNAL_ERROR'
|
||||
];
|
||||
|
||||
/* Contract 5.2 §17. */
|
||||
var CAPABILITY_STATES = ['unsupported', 'available', 'loading', 'ready', 'busy', 'error'];
|
||||
|
||||
/* Contract 5.2 §16. The base event set is closed; exhibits do not invent
|
||||
* new canonical event types. */
|
||||
var EVENT_TYPES = [
|
||||
'state.changed',
|
||||
'action.executed',
|
||||
'selection.changed',
|
||||
'capability.changed',
|
||||
'registry.changed',
|
||||
'error'
|
||||
];
|
||||
|
||||
var TARGET_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
||||
|
||||
function isPlainObject(v) {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function isFiniteNumber(v) {
|
||||
return typeof v === 'number' && isFinite(v);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Target catalog
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* A catalog is the exhibit's fixed public surface. It is built once and
|
||||
* never reshaped by ordinary context changes (Authoring Guide §D/N):
|
||||
* a target that is temporarily unusable stays discoverable and reports
|
||||
* CAPABILITY_UNAVAILABLE instead of disappearing.
|
||||
*/
|
||||
function Catalog(descriptors) {
|
||||
this._byId = {};
|
||||
this._order = [];
|
||||
for (var i = 0; i < descriptors.length; i++) {
|
||||
var d = descriptors[i];
|
||||
if (!TARGET_ID_PATTERN.test(d.id)) {
|
||||
throw new Error('Invalid canonical target id: ' + d.id);
|
||||
}
|
||||
if (this._byId[d.id]) {
|
||||
throw new Error('Duplicate target id: ' + d.id);
|
||||
}
|
||||
this._byId[d.id] = d;
|
||||
this._order.push(d.id);
|
||||
}
|
||||
}
|
||||
|
||||
Catalog.prototype.has = function (id) {
|
||||
return Object.prototype.hasOwnProperty.call(this._byId, id);
|
||||
};
|
||||
|
||||
Catalog.prototype.get = function (id) {
|
||||
return this.has(id) ? this._byId[id] : null;
|
||||
};
|
||||
|
||||
Catalog.prototype.ids = function () {
|
||||
return this._order.slice();
|
||||
};
|
||||
|
||||
/** Descriptors as published by `describe`, in stable catalog order. */
|
||||
Catalog.prototype.descriptors = function () {
|
||||
var out = [];
|
||||
for (var i = 0; i < this._order.length; i++) {
|
||||
out.push(this._byId[this._order[i]]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Capabilities
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Capabilities are discoverable and stateful (Contract §17). This registry
|
||||
* only records what the exhibit honestly reports; it does not invent
|
||||
* lifecycle transitions. An exhibit that is always ready says so once.
|
||||
*/
|
||||
function CapabilityRegistry() {
|
||||
this._caps = {};
|
||||
this._order = [];
|
||||
}
|
||||
|
||||
CapabilityRegistry.prototype.declare = function (id, state) {
|
||||
if (CAPABILITY_STATES.indexOf(state) === -1) {
|
||||
throw new Error('Unknown capability state: ' + state);
|
||||
}
|
||||
if (!this._caps[id]) this._order.push(id);
|
||||
this._caps[id] = { id: id, state: state };
|
||||
return this;
|
||||
};
|
||||
|
||||
CapabilityRegistry.prototype.stateOf = function (id) {
|
||||
return this._caps[id] ? this._caps[id].state : 'unsupported';
|
||||
};
|
||||
|
||||
CapabilityRegistry.prototype.snapshot = function () {
|
||||
var out = [];
|
||||
for (var i = 0; i < this._order.length; i++) {
|
||||
var c = this._caps[this._order[i]];
|
||||
out.push({ id: c.id, state: c.state });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** True when every required capability is usable right now. */
|
||||
CapabilityRegistry.prototype.allUsable = function (requires) {
|
||||
if (!requires || !requires.length) return true;
|
||||
for (var i = 0; i < requires.length; i++) {
|
||||
var s = this.stateOf(requires[i]);
|
||||
if (s !== 'ready' && s !== 'busy') return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* The contract core
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.identity { product, version, build }
|
||||
* @param {Catalog} options.catalog
|
||||
* @param {CapabilityRegistry} options.capabilities
|
||||
* @param {object} options.setters target id -> function(value) -> {changed:boolean}
|
||||
* @param {object} options.readers target id -> function() -> value
|
||||
* @param {object} options.actions target id -> function(args) -> {ok, code?, message?}
|
||||
* @param {object} [options.availability] target id -> function() -> {ok, code?, message?}
|
||||
* @param {function} [options.onEvent] called with every normalized event
|
||||
*/
|
||||
function ContractCore(options) {
|
||||
this.identity = options.identity;
|
||||
this.catalog = options.catalog;
|
||||
this.capabilities = options.capabilities;
|
||||
this.setters = options.setters || {};
|
||||
this.readers = options.readers || {};
|
||||
this.actions = options.actions || {};
|
||||
this.availability = options.availability || {};
|
||||
this.onEvent = options.onEvent || function () {};
|
||||
|
||||
/* stateRevision tracks committed persistent-state history and does NOT
|
||||
* reset when a host reconnects (Contract §14, §16.1). */
|
||||
this.stateRevision = 0;
|
||||
/* registryRevision tracks the target set and its metadata. The catalog
|
||||
* is fixed, so this starts at 1 and stays there unless the exhibit
|
||||
* genuinely changes its registry. */
|
||||
this.registryRevision = 1;
|
||||
|
||||
/* sequence is session-scoped and resets on a new sessionId. */
|
||||
this.sequence = 0;
|
||||
this.sessionId = null;
|
||||
this._sessionCounter = 0;
|
||||
this._eventLog = [];
|
||||
}
|
||||
|
||||
ContractCore.prototype._emit = function (type, payload) {
|
||||
if (EVENT_TYPES.indexOf(type) === -1) {
|
||||
throw new Error('Not a canonical contract event type: ' + type);
|
||||
}
|
||||
this.sequence += 1;
|
||||
var event = {
|
||||
xzbt: XZBT_VERSION,
|
||||
type: type,
|
||||
sessionId: this.sessionId,
|
||||
sequence: this.sequence,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
for (var k in payload) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, k)) event[k] = payload[k];
|
||||
}
|
||||
this._eventLog.push(event);
|
||||
this.onEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
/** Events emitted so far in the current session (used by tests/harness). */
|
||||
ContractCore.prototype.eventLog = function () {
|
||||
return this._eventLog.slice();
|
||||
};
|
||||
|
||||
ContractCore.prototype._error = function (code, message) {
|
||||
return { ok: false, error: { code: code, message: message } };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Value validation
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
ContractCore.prototype._validateValue = function (descriptor, value) {
|
||||
var kind = descriptor.kind;
|
||||
|
||||
if (kind === 'range') {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return this._error('INVALID_VALUE', 'Range target requires a finite number.');
|
||||
}
|
||||
if (value < descriptor.min || value > descriptor.max) {
|
||||
/* No silent clamping: an out-of-range value is rejected outright
|
||||
* (Authoring Guide §T). */
|
||||
return this._error(
|
||||
'INVALID_VALUE',
|
||||
'Value ' + value + ' is outside [' + descriptor.min + ', ' + descriptor.max + '].'
|
||||
);
|
||||
}
|
||||
if (descriptor.step) {
|
||||
var steps = (value - descriptor.min) / descriptor.step;
|
||||
if (Math.abs(steps - Math.round(steps)) > 1e-9) {
|
||||
return this._error(
|
||||
'INVALID_VALUE',
|
||||
'Value ' + value + ' is not on the declared step of ' + descriptor.step + '.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return { ok: true, value: value };
|
||||
}
|
||||
|
||||
if (kind === 'state') {
|
||||
if (descriptor.valueType === 'boolean') {
|
||||
if (typeof value !== 'boolean') {
|
||||
return this._error('INVALID_VALUE', 'State target requires a boolean.');
|
||||
}
|
||||
return { ok: true, value: value };
|
||||
}
|
||||
if (descriptor.valueType === 'string') {
|
||||
if (typeof value !== 'string') {
|
||||
return this._error('INVALID_VALUE', 'State target requires a string.');
|
||||
}
|
||||
if (descriptor.maxLength && value.length > descriptor.maxLength) {
|
||||
return this._error(
|
||||
'INVALID_VALUE',
|
||||
'String exceeds declared maxLength of ' + descriptor.maxLength + '.'
|
||||
);
|
||||
}
|
||||
return { ok: true, value: value };
|
||||
}
|
||||
if (descriptor.valueType === 'number') {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return this._error('INVALID_VALUE', 'State target requires a finite number.');
|
||||
}
|
||||
return { ok: true, value: value };
|
||||
}
|
||||
return this._error('INVALID_VALUE', 'Unsupported valueType.');
|
||||
}
|
||||
|
||||
if (kind === 'selection') {
|
||||
if (typeof value !== 'string') {
|
||||
return this._error('INVALID_VALUE', 'Selection target requires a string value.');
|
||||
}
|
||||
for (var i = 0; i < descriptor.options.length; i++) {
|
||||
if (descriptor.options[i].value === value) return { ok: true, value: value };
|
||||
}
|
||||
return this._error('INVALID_VALUE', 'Value "' + value + '" is not a declared option.');
|
||||
}
|
||||
|
||||
return this._error('INVALID_VALUE', 'Target kind does not accept values.');
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Canonical mutation path (Authoring Guide §H)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The single chokepoint for every externally visible persistent-state
|
||||
* change, whatever its origin. One call is one mutation transaction:
|
||||
* commit, then increment stateRevision at most once, then emit the
|
||||
* resulting events carrying that revision (Contract §14).
|
||||
*
|
||||
* @param {string} targetId
|
||||
* @param {*} value
|
||||
* @param {string} source assigned by the caller's trusted boundary
|
||||
* @param {object} [context] { correlationId }
|
||||
*/
|
||||
ContractCore.prototype.applyMutation = function (targetId, value, source, context) {
|
||||
context = context || {};
|
||||
if (SOURCES.indexOf(source) === -1) {
|
||||
return this._error('INTERNAL_ERROR', 'Unknown source: ' + source);
|
||||
}
|
||||
|
||||
var descriptor = this.catalog.get(targetId);
|
||||
if (!descriptor) {
|
||||
return this._error('UNKNOWN_TARGET', 'The requested target is not registered.');
|
||||
}
|
||||
if (descriptor.kind === 'impulse') {
|
||||
return this._error('TARGET_NOT_SETTABLE', 'Impulse targets are not settable.');
|
||||
}
|
||||
if (!descriptor.writable) {
|
||||
return this._error('TARGET_READ_ONLY', 'The requested target is read-only.');
|
||||
}
|
||||
if (!this.capabilities.allUsable(descriptor.requires)) {
|
||||
return this._error(
|
||||
'CAPABILITY_UNAVAILABLE',
|
||||
'A capability required by this target is not usable.'
|
||||
);
|
||||
}
|
||||
|
||||
var validated = this._validateValue(descriptor, value);
|
||||
if (!validated.ok) return validated;
|
||||
|
||||
var setter = this.setters[targetId];
|
||||
if (typeof setter !== 'function') {
|
||||
return this._error('INTERNAL_ERROR', 'No setter is bound to this target.');
|
||||
}
|
||||
|
||||
/* The setter is absolute and idempotent: it reports whether anything
|
||||
* actually changed. A no-op must not touch stateRevision (Contract §14.3). */
|
||||
var result = setter(validated.value) || {};
|
||||
if (!result.changed) {
|
||||
return { ok: true, revisionChanged: false, stateRevision: this.stateRevision };
|
||||
}
|
||||
|
||||
this.stateRevision += 1;
|
||||
var revision = this.stateRevision;
|
||||
|
||||
/* A coherent multi-value transaction reports every value it changed so
|
||||
* all of them are emitted under the one shared revision. */
|
||||
var changed = result.changedTargets && result.changedTargets.length
|
||||
? result.changedTargets
|
||||
: [targetId];
|
||||
|
||||
for (var i = 0; i < changed.length; i++) {
|
||||
var id = changed[i];
|
||||
var d = this.catalog.get(id);
|
||||
if (!d || !d.readable) continue;
|
||||
var payload = {
|
||||
stateRevision: revision,
|
||||
target: id,
|
||||
value: this.readValue(id),
|
||||
source: source,
|
||||
/* Always present, null when the change did not originate from a
|
||||
* request. Emitting the key unconditionally keeps the event shape
|
||||
* identical no matter which input path caused the change, so a host
|
||||
* can rely on one schema (Authoring Guide §H). */
|
||||
correlationId: context.correlationId || null
|
||||
};
|
||||
this._emit(d.kind === 'selection' ? 'selection.changed' : 'state.changed', payload);
|
||||
}
|
||||
|
||||
return { ok: true, revisionChanged: true, stateRevision: revision };
|
||||
};
|
||||
|
||||
/**
|
||||
* The single chokepoint for every externally visible action.
|
||||
* Impulses never change persistent state, so they never touch
|
||||
* stateRevision (Contract §10.4, §14).
|
||||
*/
|
||||
ContractCore.prototype.invokeAction = function (targetId, args, source, context) {
|
||||
context = context || {};
|
||||
if (SOURCES.indexOf(source) === -1) {
|
||||
return this._error('INTERNAL_ERROR', 'Unknown source: ' + source);
|
||||
}
|
||||
|
||||
var descriptor = this.catalog.get(targetId);
|
||||
if (!descriptor) {
|
||||
return this._error('UNKNOWN_TARGET', 'The requested target is not registered.');
|
||||
}
|
||||
if (descriptor.kind !== 'impulse') {
|
||||
return this._error('TARGET_NOT_INVOKABLE', 'Only impulse targets are invokable.');
|
||||
}
|
||||
if (!this.capabilities.allUsable(descriptor.requires)) {
|
||||
return this._error(
|
||||
'CAPABILITY_UNAVAILABLE',
|
||||
'A capability required by this target is not usable.'
|
||||
);
|
||||
}
|
||||
|
||||
/* Contextual availability: the target stays discoverable, but may be
|
||||
* temporarily unusable because of exhibit state (Authoring Guide §N). */
|
||||
var gate = this.availability[targetId];
|
||||
if (typeof gate === 'function') {
|
||||
var verdict = gate();
|
||||
if (verdict && !verdict.ok) {
|
||||
return this._error(verdict.code || 'CAPABILITY_UNAVAILABLE', verdict.message || 'Unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
var action = this.actions[targetId];
|
||||
if (typeof action !== 'function') {
|
||||
return this._error('INTERNAL_ERROR', 'No action is bound to this target.');
|
||||
}
|
||||
|
||||
// Fixture-only correction for the owner's authoritative 5.2 clarification.
|
||||
if (!isPlainObject(args)) return this._error('INVALID_VALUE', 'args must be an object.');
|
||||
var declared = descriptor.arguments || [];
|
||||
var names = declared.map(function (arg) { return arg.name; });
|
||||
if (Object.keys(args).some(function (key) { return names.indexOf(key) === -1; })) {
|
||||
return this._error('INVALID_VALUE', 'Undeclared argument.');
|
||||
}
|
||||
for (var a = 0; a < declared.length; a++) {
|
||||
var spec = declared[a];
|
||||
if (!Object.prototype.hasOwnProperty.call(args, spec.name)) {
|
||||
if (spec.required) return this._error('INVALID_VALUE', 'Missing argument: ' + spec.name);
|
||||
continue;
|
||||
}
|
||||
var value = args[spec.name];
|
||||
var validType = spec.type === 'integer' ? Number.isSafeInteger(value)
|
||||
: spec.type === 'number' ? isFiniteNumber(value) : typeof value === spec.type;
|
||||
if (!validType || (spec.enum && spec.enum.indexOf(value) === -1)) {
|
||||
return this._error('INVALID_VALUE', 'Invalid argument: ' + spec.name);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
var units = (value - (spec.min === undefined ? 0 : spec.min)) / spec.step;
|
||||
if ((spec.min !== undefined && value < spec.min) || (spec.max !== undefined && value > spec.max)
|
||||
|| (spec.step !== undefined && Math.abs(units - Math.round(units)) > 1e-7)) {
|
||||
return this._error('INVALID_VALUE', 'Argument outside numeric constraints.');
|
||||
}
|
||||
}
|
||||
if (typeof value === 'string' && ((spec.minLength !== undefined && value.length < spec.minLength)
|
||||
|| (spec.maxLength !== undefined && value.length > spec.maxLength))) {
|
||||
return this._error('INVALID_VALUE', 'Argument outside length constraints.');
|
||||
}
|
||||
}
|
||||
var outcome = action(args);
|
||||
if (!outcome || !outcome.ok) {
|
||||
return this._error(
|
||||
(outcome && outcome.code) || 'INTERNAL_ERROR',
|
||||
(outcome && outcome.message) || 'The action could not be executed.'
|
||||
);
|
||||
}
|
||||
|
||||
var payload = {
|
||||
target: targetId,
|
||||
args: outcome.args || args || {},
|
||||
source: source,
|
||||
correlationId: context.correlationId || null
|
||||
};
|
||||
this._emit('action.executed', payload);
|
||||
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Reads
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
ContractCore.prototype.readValue = function (targetId) {
|
||||
var reader = this.readers[targetId];
|
||||
if (typeof reader === 'function') return reader();
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Contract §13: only readable persistent targets belong in `values`. */
|
||||
ContractCore.prototype.stateSnapshot = function () {
|
||||
var values = {};
|
||||
var ids = this.catalog.ids();
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var d = this.catalog.get(ids[i]);
|
||||
if (d.kind === 'impulse' || !d.readable) continue;
|
||||
values[ids[i]] = this.readValue(ids[i]);
|
||||
}
|
||||
return { stateRevision: this.stateRevision, values: values };
|
||||
};
|
||||
|
||||
ContractCore.prototype.describe = function () {
|
||||
return {
|
||||
exhibit: this.identity,
|
||||
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR },
|
||||
registryRevision: this.registryRevision,
|
||||
stateRevision: this.stateRevision,
|
||||
capabilities: this.capabilities.snapshot(),
|
||||
targets: this.catalog.descriptors()
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Capability transitions
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
ContractCore.prototype.setCapabilityState = function (id, state) {
|
||||
var previous = this.capabilities.stateOf(id);
|
||||
if (previous === state) return false;
|
||||
this.capabilities.declare(id, state);
|
||||
this._emit('capability.changed', { capability: id, state: state, previousState: previous });
|
||||
return true;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Session handling
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
ContractCore.prototype._newSessionId = function () {
|
||||
this._sessionCounter += 1;
|
||||
var rand = Math.floor(Math.random() * 0xffff).toString(16);
|
||||
return 'sess-' + this._sessionCounter.toString(16) + rand;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handshake (Contract §5). A new session resets the event sequence but
|
||||
* deliberately leaves stateRevision alone.
|
||||
*/
|
||||
ContractCore.prototype.handleHello = function (message) {
|
||||
var majors = message.supportedContractMajors;
|
||||
if (Array.isArray(majors) && majors.length && majors.indexOf(CONTRACT_MAJOR) === -1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED_VERSION',
|
||||
message: 'No compatible contract major. This exhibit implements major ' + CONTRACT_MAJOR + '.'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this.sessionId = this._newSessionId();
|
||||
this.sequence = 0;
|
||||
this._eventLog = [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
sessionId: this.sessionId,
|
||||
exhibit: this.identity,
|
||||
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR }
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Request dispatch
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Handle one already-parsed contract request. Transport-agnostic: the
|
||||
* caller decides how the message arrived and what `source` that implies.
|
||||
*
|
||||
* @param {object} message
|
||||
* @param {string} source the authoritative source for this arrival path
|
||||
* @returns {object|null} response envelope, or null when the message is
|
||||
* not addressed to this exhibit at all
|
||||
*/
|
||||
ContractCore.prototype.handleRequest = function (message, source) {
|
||||
if (!isPlainObject(message)) {
|
||||
return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
|
||||
}
|
||||
if (message.xzbt !== XZBT_VERSION) {
|
||||
return this._errorEnvelope(
|
||||
message.requestId || null,
|
||||
message.sessionId || null,
|
||||
'UNSUPPORTED_VERSION',
|
||||
'This exhibit implements contract ' + XZBT_VERSION + '.'
|
||||
);
|
||||
}
|
||||
if (typeof message.type !== 'string') {
|
||||
return this._errorEnvelope(
|
||||
message.requestId || null,
|
||||
message.sessionId || null,
|
||||
'INVALID_MESSAGE',
|
||||
'Message type is required.'
|
||||
);
|
||||
}
|
||||
|
||||
var requestId = typeof message.requestId === 'string' ? message.requestId : null;
|
||||
|
||||
if (message.type === 'hello') {
|
||||
var hello = this.handleHello(message);
|
||||
if (!hello.ok) {
|
||||
return this._errorEnvelope(requestId, null, hello.error.code, hello.error.message);
|
||||
}
|
||||
return this._okEnvelope('hello.result', requestId, hello.result);
|
||||
}
|
||||
|
||||
/* Every other request must carry the session issued by this exhibit. */
|
||||
if (typeof message.sessionId !== 'string' || message.sessionId !== this.sessionId) {
|
||||
return this._errorEnvelope(
|
||||
requestId,
|
||||
typeof message.sessionId === 'string' ? message.sessionId : null,
|
||||
'INVALID_SESSION',
|
||||
'A valid sessionId issued by this exhibit is required.'
|
||||
);
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'describe':
|
||||
return this._okEnvelope('describe.result', requestId, this.describe());
|
||||
|
||||
case 'state.get':
|
||||
return this._okEnvelope('state.result', requestId, this.stateSnapshot());
|
||||
|
||||
case 'set': {
|
||||
if (typeof message.target !== 'string') {
|
||||
return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'set requires a target.');
|
||||
}
|
||||
var setResult = this.applyMutation(message.target, message.value, source, {
|
||||
correlationId: requestId
|
||||
});
|
||||
if (!setResult.ok) {
|
||||
return this._errorEnvelope(
|
||||
requestId, this.sessionId, setResult.error.code, setResult.error.message
|
||||
);
|
||||
}
|
||||
return this._okEnvelope('set.result', requestId, {
|
||||
stateRevision: setResult.stateRevision,
|
||||
changed: !!setResult.revisionChanged
|
||||
});
|
||||
}
|
||||
|
||||
case 'invoke': {
|
||||
if (typeof message.target !== 'string') {
|
||||
return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'invoke requires a target.');
|
||||
}
|
||||
var invokeResult = this.invokeAction(message.target, message.args, source, {
|
||||
correlationId: requestId
|
||||
});
|
||||
if (!invokeResult.ok) {
|
||||
return this._errorEnvelope(
|
||||
requestId, this.sessionId, invokeResult.error.code, invokeResult.error.message
|
||||
);
|
||||
}
|
||||
return this._okEnvelope('invoke.result', requestId, {});
|
||||
}
|
||||
|
||||
default:
|
||||
return this._errorEnvelope(
|
||||
requestId, this.sessionId, 'INVALID_MESSAGE', 'Unsupported message type: ' + message.type
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ContractCore.prototype._okEnvelope = function (type, requestId, payload) {
|
||||
var envelope = {
|
||||
xzbt: XZBT_VERSION,
|
||||
type: type,
|
||||
requestId: requestId,
|
||||
sessionId: this.sessionId,
|
||||
ok: true
|
||||
};
|
||||
for (var k in payload) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, k)) envelope[k] = payload[k];
|
||||
}
|
||||
return envelope;
|
||||
};
|
||||
|
||||
ContractCore.prototype._errorEnvelope = function (requestId, sessionId, code, message) {
|
||||
return {
|
||||
xzbt: XZBT_VERSION,
|
||||
type: 'error',
|
||||
requestId: requestId,
|
||||
sessionId: sessionId,
|
||||
ok: false,
|
||||
error: { code: code, message: message }
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Exports
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
window.XZBTContractCore = {
|
||||
VERSION: XZBT_VERSION,
|
||||
CONTRACT_MAJOR: CONTRACT_MAJOR,
|
||||
CONTRACT_MINOR: CONTRACT_MINOR,
|
||||
SOURCES: SOURCES,
|
||||
ERROR_CODES: ERROR_CODES,
|
||||
CAPABILITY_STATES: CAPABILITY_STATES,
|
||||
EVENT_TYPES: EVENT_TYPES,
|
||||
TARGET_ID_PATTERN: TARGET_ID_PATTERN,
|
||||
Catalog: Catalog,
|
||||
CapabilityRegistry: CapabilityRegistry,
|
||||
ContractCore: ContractCore
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Shared presentation shell for the reference exhibits.
|
||||
*
|
||||
* This is deliberately generic chrome only: page frame, panel layout,
|
||||
* control primitives, and the announcement strip. Every domain-specific
|
||||
* colour, texture, and scene style lives in the exhibit's own style.css.
|
||||
* Nothing here knows what an exhibit is about.
|
||||
*/
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Verdana, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
background: #10131a;
|
||||
color: #e8ecf2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exhibit {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"stage panel"
|
||||
"footer footer";
|
||||
height: 100vh;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.exhibit-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.exhibit-header h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.exhibit-header .subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.exhibit-header .status {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.stage {
|
||||
grid-area: stage;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.stage canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
grid-area: panel;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.panel section + section {
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.control {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.control:last-child { margin-bottom: 0; }
|
||||
|
||||
.control label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.control .readout {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: currentColor;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 5px 6px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: inherit;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
color: inherit;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover { background: rgba(255, 255, 255, 0.14); }
|
||||
button:active { transform: translateY(1px); }
|
||||
|
||||
button[aria-pressed="true"] {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.button-row button { flex: 1 1 auto; }
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-row:last-child { margin-bottom: 0; }
|
||||
|
||||
.exhibit-footer {
|
||||
grid-area: footer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 12px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.exhibit-footer .announcement-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.45;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Announcement text is written with textContent only — never innerHTML. */
|
||||
.exhibit-footer .announcement-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.exhibit-footer .announcement-text.is-idle { opacity: 0.4; font-style: italic; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.exhibit {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"stage"
|
||||
"panel"
|
||||
"footer";
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.stage { min-height: 320px; }
|
||||
body { overflow: auto; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Shared presentation helpers for the reference exhibits.
|
||||
*
|
||||
* Generic DOM plumbing only: element lookup, safe text writing, and the
|
||||
* announcement strip. It has no contract awareness and no domain knowledge,
|
||||
* so an exhibit can use it, ignore it, or replace it without touching the
|
||||
* contract layer.
|
||||
*
|
||||
* The one rule worth stating out loud: every piece of text that reaches the
|
||||
* DOM goes through `textContent`. Nothing in this project ever assigns
|
||||
* `innerHTML`, so a string arriving from a host or a scenario can never
|
||||
* become markup or script (Contract §20, Authoring Guide §O).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
/** Write text safely. Always textContent, never innerHTML. */
|
||||
function setText(node, text) {
|
||||
if (!node) return;
|
||||
node.textContent = text === null || text === undefined ? '' : String(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announcement strip. Text is data: it is truncated to the declared
|
||||
* maximum and written with textContent.
|
||||
*/
|
||||
function Announcer(options) {
|
||||
this.node = options.node;
|
||||
this.idleText = options.idleText || '';
|
||||
this.maxLength = options.maxLength || 120;
|
||||
this._timer = null;
|
||||
this.setText(this.idleText, true);
|
||||
}
|
||||
|
||||
Announcer.prototype.setText = function (text, idle) {
|
||||
if (!this.node) return;
|
||||
var value = text === null || text === undefined ? '' : String(text);
|
||||
if (value.length > this.maxLength) value = value.slice(0, this.maxLength);
|
||||
this.node.textContent = value;
|
||||
if (idle) {
|
||||
this.node.classList.add('is-idle');
|
||||
} else {
|
||||
this.node.classList.remove('is-idle');
|
||||
}
|
||||
};
|
||||
|
||||
/** Show a transient message, then fall back to the idle text. */
|
||||
Announcer.prototype.flash = function (text, ms) {
|
||||
this.setText(text, false);
|
||||
if (this._timer) clearTimeout(this._timer);
|
||||
var self = this;
|
||||
this._timer = setTimeout(function () {
|
||||
self.setText(self.idleText, true);
|
||||
}, ms || 2600);
|
||||
};
|
||||
|
||||
/** Format a number for a readout, with a fixed number of decimals. */
|
||||
function formatNumber(value, decimals) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return '--';
|
||||
return value.toFixed(decimals === undefined ? 2 : decimals);
|
||||
}
|
||||
|
||||
/** Format a 0..1 value as a whole percentage. */
|
||||
function formatPercent(value) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return '--';
|
||||
return Math.round(value * 100) + '%';
|
||||
}
|
||||
|
||||
/** Format a 0..1 value as a whole number of degrees. */
|
||||
function formatDegrees(value) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return '--';
|
||||
return Math.round(value * 360) + '\u00b0';
|
||||
}
|
||||
|
||||
/** Format a 0..1 value as a whole number of minutes. */
|
||||
function formatMinutes(value) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return '--';
|
||||
return Math.round(value * 60) + ' min';
|
||||
}
|
||||
|
||||
window.XZBTShell = {
|
||||
el: el,
|
||||
setText: setText,
|
||||
Announcer: Announcer,
|
||||
formatNumber: formatNumber,
|
||||
formatPercent: formatPercent,
|
||||
formatDegrees: formatDegrees,
|
||||
formatMinutes: formatMinutes
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* XZBT Exhibit Contract 5.2 — same-origin postMessage host transport.
|
||||
*
|
||||
* This is layer 6 of the Authoring Guide's recommended separation, and it is
|
||||
* deliberately the thinnest file in the project. It knows how to move
|
||||
* contract messages across one channel and nothing else: no exhibit
|
||||
* semantics, no target knowledge, no state.
|
||||
*
|
||||
* It is also entirely optional. An exhibit that never receives a `hello`
|
||||
* behaves exactly as it would with this file deleted — that is the
|
||||
* standalone-first rule (Contract §2, Authoring Guide §B).
|
||||
*
|
||||
* Security (Contract §25): both `event.origin` and `event.source` are
|
||||
* validated on every message. The transport also assigns `source = 'host'`
|
||||
* itself; a `source` field inside an incoming message is ignored, never
|
||||
* trusted (Contract §15).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.core an XZBTContractCore.ContractCore
|
||||
* @param {string} [options.origin] expected host origin; defaults to the
|
||||
* document's own origin (same-origin)
|
||||
* @param {function} [options.onMessage] diagnostics hook
|
||||
*/
|
||||
function HostTransport(options) {
|
||||
this.core = options.core;
|
||||
this.expectedOrigin = options.origin || window.location.origin;
|
||||
this.onMessage = options.onMessage || function () {};
|
||||
this.connected = false;
|
||||
this._bound = this._onMessage.bind(this);
|
||||
|
||||
/* Events are pushed, not polled. The core already emits every state
|
||||
* change, action, and capability transition through its onEvent hook;
|
||||
* the transport's job is to forward them to the host. Without this the
|
||||
* host would see responses but never learn that anything changed
|
||||
* (Contract §16). */
|
||||
var self = this;
|
||||
var coreOnEvent = this.core.onEvent;
|
||||
this.core.onEvent = function (event) {
|
||||
if (typeof coreOnEvent === 'function') coreOnEvent(event);
|
||||
if (self.connected) self.send(event);
|
||||
};
|
||||
|
||||
window.addEventListener('message', this._bound);
|
||||
}
|
||||
|
||||
HostTransport.prototype._isTrusted = function (event) {
|
||||
/* Same-origin only. `file://` documents report origin "null", so a
|
||||
* file-opened exhibit simply never accepts host traffic — which is the
|
||||
* correct standalone behaviour, not a failure. */
|
||||
if (event.origin !== this.expectedOrigin) return false;
|
||||
if (event.source !== window.parent) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
HostTransport.prototype._onMessage = function (event) {
|
||||
if (!this._isTrusted(event)) return;
|
||||
|
||||
var message = event.data;
|
||||
if (!message || typeof message !== 'object') return;
|
||||
if (message.xzbt !== window.XZBTContractCore.VERSION) return;
|
||||
|
||||
/* Source is assigned here, at the trusted receiving boundary. */
|
||||
var response = this.core.handleRequest(message, 'host');
|
||||
if (!response) return;
|
||||
|
||||
if (response.type === 'hello.result') this.connected = true;
|
||||
this.onMessage(message, response);
|
||||
this.send(response);
|
||||
};
|
||||
|
||||
HostTransport.prototype.send = function (message) {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage(message, this.expectedOrigin);
|
||||
};
|
||||
|
||||
HostTransport.prototype.destroy = function () {
|
||||
window.removeEventListener('message', this._bound);
|
||||
};
|
||||
|
||||
window.XZBTHostTransport = HostTransport;
|
||||
})();
|
||||
Reference in New Issue
Block a user