Skip to content

Commit d190fb7

Browse files
ten9876claude
andcommitted
Experience: the contester gets a battle station
- Four monitors in an arc: a contest logger at each end (Radio 1 RUN on 40 m, Radio 2 S&P on 20 m, schematic with placeholder calls) and AetherSDR in the middle. - Two radios, keyboard, paddle, a notepad with pencil (QSX notes), an energy-drink can. - The operator hunches forward in a headset with a boom mic, one hand typing a call and the other keying the paddle (animated). - The pile-up: the DX ("XX9ZZ UP 5") and a wall of callers flickering above the desk, hidden while a scenario is open. Stations can now carry animated parts that skip geometry merging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9cb4c51 commit d190fb7

4 files changed

Lines changed: 156 additions & 12 deletions

File tree

‎experience/DESIGN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Evidence comes from headless Firefox render probes at 390×844, 1280×720,
5353
|---|-----------|--------|----------|
5454
| 1 | Truthful | **Pass** | 139 sources; `npm run check` resolves every `[[cite]]`; uncited prices removed; *Modeled* badge on every count; assumptions in-app. |
5555
| 2 | The real app | **Pass** | Four real screenshots are the workbench "with" screen, the hub wall and the station monitors. |
56-
| 3 | The operators | **Pass** | 8 stations (SO2R desk + stacked yagis, DX shack + beam, FT8 shack + hex, rag chewer’s podcast-style studio with a boom-mounted SM7B and desk speakers, picnic table + end-fed, café laptop, accessible shack, experimenter bench), each with a seated operator. |
56+
| 3 | The operators | **Pass** | 8 stations (SO2R battle station — four screens with two loggers, notepad, paddle, a hunched operator and a flickering pile-up — under stacked yagis, DX shack + beam, FT8 shack + hex, rag chewer’s podcast-style studio with a boom-mounted SM7B and desk speakers, picnic table + end-fed, café laptop, accessible shack, experimenter bench), each with a seated operator. |
5757
| 4 | Three ways in | **Pass** | Operators / Use cases / Features tabs; every view cross-links; hash routes for every view, including `/with`. |
5858
| 5 | Benefit you can feel | **Pass** | Windows + typed cables fold into the screenshot; metrics show "2 from 6"; side-by-side steps; flip with the switch or W. |
5959
| 6 | Every Aether feature | **Pass** | All 16 Aether components are in the atlas and in at least one scenario (check output: none unused); roadmap item labelled. |

‎experience/src/data/personas.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const personas = [
55
{
66
id: 'contester', name: 'The Contester', color: '#ffb547',
77
tagline: 'Rate, multipliers, two radios, 48 hours',
8-
look: { desk: 'contest', shots: ['multislice', 'stack3d'], antenna: 'stack' },
8+
look: { desk: 'contest', shots: ['multislice', 'stack3d'], antenna: 'stack', lean: -0.24, reach: [-3.5, 6.5], color: '#ffb547' },
99
who: 'Lives for CQ WW, WPX and Field Day weekends. Runs on one band while searching and pouncing on another, measures success in QSOs per hour, and has spent more time configuring software than they’d like to admit. In CQ WW CW 2025, Assisted single-op entries outnumbered Unassisted 3,112 to 2,260.[[cqww-cw-2025]]',
1010
needs: [
1111
'See two bands at once and switch focus without thinking.',

‎experience/src/world/stations.js‎

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import * as THREE from 'three';
1010
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
11-
import { labelSprite, shotTexture, glowTexture } from './textures.js';
11+
import { labelSprite, shotTexture, glowTexture, loggerTexture, notepadTexture } from './textures.js';
1212

1313
export const RING = 300;
1414

@@ -24,7 +24,7 @@ const box = (w, h, d, color, o) => edged(new THREE.BoxGeometry(w, h, d), color,
2424
const cyl = (rt, rb, h, color, seg = 10) => edged(new THREE.CylinderGeometry(rt, rb, h, seg), color);
2525

2626
function screen(shot, w, color) {
27-
const tex = shotTexture(shot);
27+
const tex = typeof shot === 'string' ? shotTexture(shot) : shot;
2828
const h = w * 0.58;
2929
const grp = new THREE.Group();
3030
const bezel = box(w + 1.2, h + 1.2, 0.8, color);
@@ -47,7 +47,9 @@ function radio(color, w = 11) {
4747

4848
// A seated operator: low-poly, edge-lit, wearing headphones unless the
4949
// station has speakers. `lean` tips the chair back and the upper body with it.
50-
function operator(color, { lean = 0, phones = true } = {}) {
50+
// `reach` (two x positions) puts the hands on the desk in front: the
51+
// contester's keyboard and paddle. `mic` adds a headset boom.
52+
function operator(color, { lean = 0, phones = true, reach = null, mic = false } = {}) {
5153
const grp = new THREE.Group();
5254
const mat = new THREE.MeshStandardMaterial({ color: 0x0f1c2c, roughness: 0.6, metalness: 0.2, emissive: new THREE.Color(color), emissiveIntensity: 0.12 });
5355
const part = (geo) => edged(geo, color, { mat, opacity: 0.7 });
@@ -63,12 +65,34 @@ function operator(color, { lean = 0, phones = true } = {}) {
6365
at(box(7, 9, 1, color, { opacity: 0.5 }), 0, 12, 3.6);
6466
const torso = at(part(new THREE.CylinderGeometry(2.4, 2.8, 8, 8)), 0, 14, 0.8); torso.rotation.x = -0.12;
6567
at(part(new THREE.IcosahedronGeometry(2.1, 1)), 0, 20.2, 0.1);
66-
if (phones) at(new THREE.Mesh(new THREE.TorusGeometry(2.5, 0.35, 8, 20, Math.PI), new THREE.MeshBasicMaterial({ color, toneMapped: false })), 0, 20.4, 0.1);
68+
const glow = new THREE.MeshBasicMaterial({ color, toneMapped: false });
69+
if (phones) at(new THREE.Mesh(new THREE.TorusGeometry(2.5, 0.35, 8, 20, Math.PI), glow), 0, 20.4, 0.1);
70+
// A limb (or boom) from a to b, in the operator's own unrotated frame.
71+
const limb = (a, b, t, m = null) => {
72+
const A = new THREE.Vector3(...a), B = new THREE.Vector3(...b), d = B.clone().sub(A);
73+
const geo = new THREE.BoxGeometry(t, t, d.length());
74+
const o = m ? new THREE.Mesh(geo, m) : part(geo);
75+
at(o, ...A.clone().addScaledVector(d, 0.5).toArray());
76+
o.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), d.normalize());
77+
return o;
78+
};
79+
if (mic) { limb([-2.5, 19.9, 0.1], [-1.6, 18.6, -1.6], 0.28, glow); limb([-1.6, 18.6, -1.6], [-0.3, 18.5, -2.2], 0.28, glow); }
80+
// Where a hand at desk height (y 12.4, 1.5 in front of the desk edge)
81+
// sits in the unrotated frame, given the lean.
82+
const handAt = (x) => {
83+
const y1 = 12.4 - 8.8, z1 = (1.5 - 7) - 0.4, c = Math.cos(lean), sn = Math.sin(lean);
84+
return [x, 8.8 + y1 * c + z1 * sn, 0.4 - y1 * sn + z1 * c];
85+
};
6786
[-1, 1].forEach((s) => {
6887
if (phones) { const cup = at(new THREE.Mesh(new THREE.CylinderGeometry(0.9, 0.9, 0.7, 12), new THREE.MeshBasicMaterial({ color, toneMapped: false })), s * 2.4, 20.2, 0.1); cup.rotation.z = Math.PI / 2; }
6988
const thigh = part(new THREE.BoxGeometry(1.9, 1.8, 6)); thigh.position.set(s * 1.4, 8.6, -2.6); grp.add(thigh);
7089
const shin = part(new THREE.BoxGeometry(1.7, 7, 1.7)); shin.position.set(s * 1.4, 4.4, -5.4); grp.add(shin);
71-
if (lean) {
90+
if (reach) {
91+
// Hunched over the desk: shoulder, elbow out and down, hand on the gear.
92+
const hand = handAt(reach[(s + 1) / 2]), sh = [s * 3.1, 17.3, 0.4];
93+
const elbow = [s * 3.9 + (hand[0] - s * 3.1) * 0.25, 13.6, -1.0];
94+
limb(sh, elbow, 1.5); limb(elbow, hand, 1.4);
95+
} else if (lean) {
7296
// Leaning back: elbows down, hands folded on the stomach.
7397
const arm = at(part(new THREE.BoxGeometry(1.5, 5.5, 1.5)), s * 3.2, 14.4, 0.6); arm.rotation.z = s * 0.12;
7498
const fore = at(part(new THREE.BoxGeometry(4, 1.4, 1.4)), s * 1.6, 11.8, -1.6); fore.rotation.y = s * 0.35;
@@ -156,10 +180,55 @@ function dress(grp, look, color) {
156180
const mon = (shot, x, w = 16, rot = 0) => { const s = screen(shot, w, color); s.position.set(x, 11.6 + 2.5 + w * 0.29 + 2.6, -6.5); s.rotation.y = rot; s.rotation.x = -0.05; grp.add(s); return s; };
157181
const rig = (x, w) => { const r = radio(color, w); r.position.set(x, 13.8, -4); grp.add(r); };
158182

183+
const anims = grp.userData.anims = [];
159184
if (look.desk === 'contest') {
160-
desk(40, 14);
161-
mon(look.shots[0], -9.5, 17, -0.18); mon(look.shots[1] || look.shots[0], 9.5, 17, 0.18);
162-
rig(-11, 10); rig(11, 10);
185+
desk(56, 15);
186+
// Four screens in an arc: a logger at each end (run radio, S&P radio),
187+
// AetherSDR in the middle.
188+
const arc = [[-21.6, 0.42, -3.4], [-7.6, 0.1, -6.5], [7.6, -0.1, -6.5], [21.6, -0.42, -3.4]];
189+
const faces = [
190+
loggerTexture({ title: 'Contest log — Radio 1', band: '40 m', mode: 'RUN', seed: 3 }),
191+
look.shots[0], look.shots[1] || look.shots[0],
192+
loggerTexture({ title: 'Contest log — Radio 2', band: '20 m', mode: 'S&P', accent: '#5de3ff', seed: 8 }),
193+
];
194+
arc.forEach(([x, rot, z], i) => { const m = mon(faces[i], x, 13.5, rot); m.position.z = z; });
195+
rig(-13.6, 8.5); rig(13.6, 8.5);
196+
const kb = box(11, 0.6, 3.6, color); kb.position.set(-3.5, 11.9, 0.6); grp.add(kb);
197+
const paddle = box(2.4, 0.8, 3, color); paddle.position.set(6.5, 12, 1.2); grp.add(paddle);
198+
[-0.35, 0.35].forEach((x) => { const lever = box(0.25, 1.6, 1.2, color); lever.position.set(6.5 + x, 13.1, 0.4); grp.add(lever); });
199+
// Notepad and pencil.
200+
const pad = new THREE.Mesh(new THREE.PlaneGeometry(5, 7), new THREE.MeshBasicMaterial({ map: notepadTexture(), color: 0x8a8a8a, toneMapped: false }));
201+
pad.rotation.set(-Math.PI / 2, 0, 0.35); pad.position.set(-15, 11.66, 1.4); grp.add(pad);
202+
const pencil = new THREE.Group();
203+
const wood = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.22, 5.6, 6), new THREE.MeshBasicMaterial({ color: '#f5c542', toneMapped: false }));
204+
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.22, 0.7, 6), new THREE.MeshBasicMaterial({ color: '#3a2f25', toneMapped: false }));
205+
tip.position.y = -3.15; tip.rotation.x = Math.PI; pencil.add(wood, tip);
206+
pencil.rotation.set(0, 0, Math.PI / 2); pencil.rotation.y = 0.9; pencil.position.set(-14.2, 11.9, 2.2); grp.add(pencil);
207+
const can = cyl(0.85, 0.85, 3, color, 12); can.position.set(18, 13.1, 3); grp.add(can);
208+
// The hands: live, so they can key the paddle and hammer the keyboard.
209+
const handMat = new THREE.MeshStandardMaterial({ color: 0x0f1c2c, roughness: 0.6, emissive: new THREE.Color(color), emissiveIntensity: 0.35 });
210+
const hands = [[-3.5, 12.75, 1.4], [6.5, 13.0, 1.9]].map((pos) => {
211+
const h = edged(new THREE.BoxGeometry(1.5, 0.9, 1.9), color, { mat: handMat });
212+
h.position.set(...pos); h.userData.live = true; grp.add(h); return h;
213+
});
214+
anims.push((t) => {
215+
hands[0].position.y = 12.75 + Math.abs(Math.sin(t * 17)) * 0.35; // typing a call
216+
hands[1].position.x = 6.5 + Math.sin(t * 38) * Math.max(0, Math.sin(t * 2.7)) * 0.3; // keying bursts
217+
});
218+
// The pile-up: the DX and a wall of callers flickering above the desk.
219+
// Placeholder calls only.
220+
const calls = ['XX1ABC', 'XX5KZ', 'XX2DEF', 'XX7RT', 'XX3MNO', 'XX4WB', 'XX8JQ', 'XX6PL'];
221+
const dx = labelSprite('XX9ZZ UP 5', { height: 3.4, color: '#ffffff', border: 'rgba(255,255,255,0.6)', mono: true });
222+
dx.position.set(0, 39, -9); dx.userData.hideInScenario = true; grp.add(dx);
223+
const callers = calls.map((c, i) => {
224+
const sp = labelSprite(c, { height: 2.6, color: look.color || '#ffb547', mono: true });
225+
sp.position.set(-27 + (i % 4) * 16 + (i > 3 ? 8 : 0), i > 3 ? 30 : 34.5, -8 - (i % 3) * 2.5);
226+
sp.material.opacity = 0; sp.userData.hideInScenario = true; grp.add(sp); return sp;
227+
});
228+
anims.push((t) => {
229+
callers.forEach((sp, i) => { const k = Math.max(0, Math.sin(t * (2.3 + i * 0.37) + i * 1.9)); sp.material.opacity = k * k; });
230+
dx.material.opacity = 0.75 + 0.25 * Math.sin(t * 1.3);
231+
});
163232
} else if (look.desk === 'picnic') {
164233
const top = box(30, 1, 13, color); top.position.set(0, 10, -2); grp.add(top);
165234
[-1, 1].forEach((s) => { const bench = box(30, 0.8, 4, color, { opacity: 0.6 }); bench.position.set(0, 6, -2 + s * 10); grp.add(bench); const a = box(1, 10, 12, color, { opacity: 0.5 }); a.position.set(s * 12, 5, -2); grp.add(a); });
@@ -190,7 +259,7 @@ function dress(grp, look, color) {
190259
rig(12, 9);
191260
}
192261
// The operator faces the hub (-z), screens in front of them.
193-
const op = operator(color, { lean: look.lean || 0, phones: look.phones !== false });
262+
const op = operator(color, { lean: look.lean || 0, phones: look.phones !== false, reach: look.reach || null, mic: !!look.reach });
194263
op.position.set(0, look.desk === 'picnic' ? -1 : 0, look.desk === 'picnic' ? 8 : 7);
195264
grp.add(op);
196265
const ant = antenna(look.antenna, color);
@@ -208,7 +277,9 @@ function mergeProps(props, color) {
208277
const rel = (o) => inv.clone().multiply(o.matrixWorld);
209278
const lines = [], solids = [], figures = [], dead = [];
210279
let figureMat = null;
280+
const live = (o) => { for (; o && o !== props; o = o.parent) if (o.userData.live) return true; return false; };
211281
props.traverse((o) => {
282+
if (live(o)) return;
212283
if (o.isLineSegments || o.isLine) {
213284
let g = o.geometry.clone().applyMatrix4(rel(o));
214285
if (!o.isLineSegments) { // strip -> segment pairs
@@ -282,7 +353,7 @@ export function createStations(engine, personas, onSelect) {
282353
grp.add(hit);
283354
const extras = [];
284355
grp.traverse((o) => { if (o.userData.hideInScenario) extras.push(o); });
285-
const st = { persona: p, group: grp, angle: a, rim, halo, label, extras, hover: false, active: false };
356+
const st = { persona: p, group: grp, angle: a, rim, halo, label, extras, anims: props.userData.anims || [], hover: false, active: false };
286357
engine.addPickable(hit, {
287358
kind: 'persona', id: p.id, title: p.name,
288359
onClick: () => onSelect(p.id),
@@ -297,6 +368,7 @@ export function createStations(engine, personas, onSelect) {
297368
const want = s.active ? 0.07 : s.hover ? 0.06 : 0.0;
298369
s.halo.material.opacity += (want - s.halo.material.opacity) * (1 - Math.exp(-5 * dt));
299370
s.rim.material.opacity = 0.55 + 0.35 * Math.sin(t * 2 + s.angle * 3) * (s.active ? 1 : 0.4);
371+
for (const f of s.anims) f(t);
300372
}
301373
});
302374

‎experience/src/world/textures.js‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,78 @@ export function toolWindowTexture({ name, role, os = '', kind = 'list', accent =
131131
return canvasTexture(c);
132132
}
133133

134+
// A contest logger window for the station monitors: entry line with the call
135+
// being typed, the recent log, a band map, and a rate meter. Schematic, with
136+
// placeholder calls only — it stands for "a logger", not a product.
137+
export function loggerTexture({ title, band, mode, accent = '#ffb547', seed = 1 }) {
138+
const W = 800, H = 460;
139+
const c = document.createElement('canvas');
140+
c.width = W; c.height = H;
141+
const g = c.getContext('2d');
142+
const rnd = mulberry(seed * 7919);
143+
const L = 'ABCDEFGHJKLMNOPQRSTUVWXYZ';
144+
const call = () => 'XX' + Math.floor(rnd() * 10) + L[Math.floor(rnd() * 25)] + L[Math.floor(rnd() * 25)] + (rnd() > 0.4 ? L[Math.floor(rnd() * 25)] : '');
145+
g.fillStyle = '#0d141f'; g.fillRect(0, 0, W, H);
146+
g.fillStyle = '#1d2738'; g.fillRect(0, 0, W, 46);
147+
g.fillStyle = '#eaf2fb'; g.font = `650 24px ${FONT}`; g.textBaseline = 'middle';
148+
g.fillText(title, 16, 24);
149+
g.fillStyle = accent; g.font = `700 22px ${MONO}`; g.textAlign = 'right';
150+
g.fillText(`${band} · ${mode}`, W - 16, 24); g.textAlign = 'left';
151+
// entry line
152+
g.fillStyle = '#16233a'; g.fillRect(14, 60, 500, 58);
153+
g.strokeStyle = accent; g.lineWidth = 3; g.strokeRect(14, 60, 500, 58);
154+
g.fillStyle = '#fff'; g.font = `700 34px ${MONO}`;
155+
const typing = call().slice(0, 4);
156+
g.fillText(typing, 30, 90);
157+
const tw = g.measureText(typing).width;
158+
g.fillStyle = accent; g.fillRect(32 + tw, 72, 14, 36);
159+
g.fillStyle = '#8598b4'; g.font = `500 22px ${MONO}`; g.fillText('599 14', 330, 90);
160+
// log rows
161+
g.font = `500 20px ${MONO}`;
162+
for (let i = 0; i < 9; i++) {
163+
const y = 146 + i * 32;
164+
g.fillStyle = i % 2 ? 'rgba(133,152,180,0.06)' : 'rgba(133,152,180,0.12)'; g.fillRect(14, y - 14, 500, 30);
165+
g.fillStyle = '#9fb3cc';
166+
const mm = String(59 - i * 2).padStart(2, '0');
167+
g.fillText(`23${mm}`, 24, y + 1);
168+
g.fillStyle = '#eaf2fb'; g.fillText(call().padEnd(8), 110, y + 1);
169+
g.fillStyle = '#9fb3cc'; g.fillText(`599 ${String(3 + Math.floor(rnd() * 37)).padStart(2, '0')}`, 260, y + 1);
170+
if (rnd() > 0.7) { g.fillStyle = '#ffd166'; g.fillText('MULT', 400, y + 1); }
171+
}
172+
// band map
173+
g.fillStyle = '#0b111b'; g.fillRect(530, 60, 256, 330);
174+
g.strokeStyle = 'rgba(133,152,180,0.35)'; g.lineWidth = 2; g.strokeRect(530, 60, 256, 330);
175+
g.font = `500 18px ${MONO}`;
176+
for (let i = 0; i < 11; i++) {
177+
const y = 82 + i * 29;
178+
const f = (band === '20 m' ? 14000 : 7000) + 4 + i * 3 + Math.floor(rnd() * 3);
179+
g.fillStyle = '#5d6f88'; g.fillText(`${f}.${Math.floor(rnd() * 10)}`, 540, y);
180+
const k = rnd();
181+
g.fillStyle = k > 0.75 ? '#ff6b5e' : k > 0.45 ? '#ffd166' : '#8ef7a8';
182+
g.fillText(call(), 650, y);
183+
}
184+
// rate meter
185+
g.fillStyle = '#16233a'; g.fillRect(14, 420, 772, 30);
186+
g.fillStyle = accent; g.fillRect(14, 420, 772 * 0.72, 30);
187+
g.fillStyle = '#05080d'; g.font = `700 20px ${MONO}`; g.fillText(`RATE ${110 + Math.floor(rnd() * 60)}/h`, 26, 436);
188+
return canvasTexture(c);
189+
}
190+
191+
// A spiral notepad page: ruled lines and scribbled calls.
192+
export function notepadTexture() {
193+
const c = document.createElement('canvas');
194+
c.width = 256; c.height = 360;
195+
const g = c.getContext('2d');
196+
g.fillStyle = '#f2ead3'; g.fillRect(0, 0, 256, 360);
197+
g.strokeStyle = 'rgba(80,120,190,0.45)'; g.lineWidth = 2;
198+
for (let y = 50; y < 360; y += 26) { g.beginPath(); g.moveTo(0, y); g.lineTo(256, y); g.stroke(); }
199+
g.strokeStyle = 'rgba(210,70,70,0.6)'; g.beginPath(); g.moveTo(36, 0); g.lineTo(36, 360); g.stroke();
200+
g.fillStyle = '#2a2f3a'; g.font = `italic 600 24px ${FONT}`;
201+
['XX9ZZ up 5?', '14.195 / 14.200', 'QSX 14.201', 'mult! 40m', 'dupe XX1AB'].forEach((t, i) => g.fillText(t, 46, 44 + i * 52));
202+
g.strokeStyle = '#2a2f3a'; g.lineWidth = 3; g.beginPath(); g.moveTo(46, 74); g.lineTo(200, 66); g.stroke();
203+
return canvasTexture(c);
204+
}
205+
134206
function mulberry(a) {
135207
return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
136208
}

0 commit comments

Comments
 (0)