Skip to content

Commit ebf427b

Browse files
claudemhanavan4-prog
authored andcommitted
The field: a track across the Ember Wastes, and twelve things the war left by it
A worn track runs from the arrival portal to Ashminster and out from it to each landmark and west toward the witches' ring, with fingerposts at the forks and waymarker stones along it. Twelve things lie beside it (fallen standards, barrows, siege trenches, a field chapel, the Order's burnt baggage, a cracked bell, a witch-bottle, rowan and red thread), each searched once a day for a little coin and a piece of the war's story. Finding all twelve is walking the field: a purse, a title and a daybook line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014upby61fMmizgg2cMkKvqW
1 parent c162f67 commit ebf427b

18 files changed

Lines changed: 429 additions & 5 deletions

‎client/ember-scene.js‎

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,32 @@ const ASH_SPOTS = {
8383
marks: { x: TX - 525, y: TY + 440, name: 'marks cut in the stone', icon: '✴️', hint: 'look at the marks cut by the breach' },
8484
frith: { x: TX + 70, y: TY + 1710, name: 'the south frith cross', icon: '✝️', hint: 'read the frith cross' },
8585
};
86+
// The field (data/emberField.js; test/ember-field.test.js keeps this copy in
87+
// step): the worn track and its forks, and the twelve things the war left by it.
88+
const FIELD_TRACK = [[[10000,18350],[9900,16800],[10100,15200],[10000,14400]],[[10000,9300],[10200,7500],[9800,5600],[10000,4300]],[[8300,10900],[7200,9600],[6200,7900],[5800,7300]],[[11700,10800],[12900,9900],[13900,8900]],[[10500,18250],[12800,17600],[14700,16700],[15300,16300]],[[8300,11200],[6500,12100],[4800,12800],[3950,13200]]];
89+
const FIELD_POSTS = [{"x":10260,"y":18200,"arms":["Ashminster ↑","Emberfall ↗"]},{"x":10180,"y":14420,"arms":["Ashminster ↑","the portal ↓"]},{"x":8250,"y":11050,"arms":["Charhold ↖","the witches' ring ←"]},{"x":10180,"y":9250,"arms":["the Cinder Bazaar ↑"]},{"x":11750,"y":10950,"arms":["the Great Ossuary ↗"]}];
90+
const FIELD_FINDS = [
91+
{"id":"standard_south","name":"A fallen standard","icon":"🚩","model":"em_standard","x":9720,"y":16150,"size":200,"turn":0.4,"hint":"search the fallen standard"},
92+
{"id":"bell","name":"A cracked bell","icon":"🔔","model":"em_bell","x":10260,"y":15150,"size":90,"turn":0.5,"hint":"look at the cracked bell"},
93+
{"id":"barrow_north","name":"A knight's barrow","icon":"⚱️","model":"em_barrow","x":9600,"y":5000,"size":280,"turn":1.2,"hint":"kneel at the barrow"},
94+
{"id":"trench_north","name":"A siege trench","icon":"⛏️","model":"em_trench","x":10350,"y":6650,"size":330,"turn":-0.2,"hint":"climb down into the trench"},
95+
{"id":"barrow_west","name":"A knight's barrow","icon":"⚱️","model":"em_barrow","x":6950,"y":9150,"size":280,"turn":-0.6,"hint":"kneel at the barrow"},
96+
{"id":"stakes_west","name":"The last line of stakes","icon":"⛏️","model":"em_trench","x":5950,"y":7650,"size":330,"turn":0.9,"hint":"walk the line of stakes"},
97+
{"id":"standard_east","name":"A fallen standard","icon":"🚩","model":"em_standard","x":12650,"y":10300,"size":200,"turn":-1.1,"hint":"search the fallen standard"},
98+
{"id":"long_grave","name":"The long grave","icon":"🪦","model":"em_barrow","x":13550,"y":9350,"size":520,"turn":0.6,"hint":"stand at the long grave"},
99+
{"id":"chapel","name":"The field chapel","icon":"⛪","model":"em_chapel","x":13350,"y":17450,"size":380,"turn":0.3,"hint":"go into the field chapel"},
100+
{"id":"wagons","name":"The Order's baggage","icon":"🛞","model":"em_barricade","x":14350,"y":16850,"size":380,"turn":1.8,"hint":"search the burnt wagons"},
101+
{"id":"bottle","name":"A witch-bottle at the crossroads","icon":"🏺","model":"em_bottle","x":6050,"y":12350,"size":160,"turn":2.2,"hint":"look at the witch-bottle"},
102+
{"id":"rowan","name":"Rowan and red thread","icon":"🧶","model":"em_rowan","x":4650,"y":12980,"size":130,"turn":0.2,"hint":"look at what is tied to the post"},
103+
];
104+
function nearTrack(x, z, r) {
105+
for (const run of FIELD_TRACK) for (let i = 0; i < run.length - 1; i++) {
106+
const [ax, ay] = run[i], [bx, by] = run[i + 1], L2 = (bx - ax) ** 2 + (by - ay) ** 2;
107+
const u = Math.max(0, Math.min(1, ((x - ax) * (bx - ax) + (z - ay) * (by - ay)) / L2));
108+
if (Math.hypot(x - (ax + (bx - ax) * u), z - (ay + (by - ay) * u)) < r) return true;
109+
}
110+
return FIELD_FINDS.some((f) => Math.hypot(x - f.x, z - f.y) < r + 120) || FIELD_POSTS.some((p) => Math.hypot(x - p.x, z - p.y) < r);
111+
}
86112
// How much of each piece is solid: a square about its middle, as a share of its size (a house is walked round, not into).
87113
const ASH_SOLID = { em_house_a: 0.4, em_house_b: 0.42, em_house_c: 0.36, em_ash_cross: 0.36, prop_well: 0.42, em_frith_cross: 0.18, em_barricade: 0 };
88114
// What of the minster is solid, in the model's own plan (x along the nave,
@@ -227,7 +253,8 @@ function buildEmberScene() {
227253
['rock1', 90, 'fit'], ['rock2', 110, 'fit'], ['rock3', 100, 'fit']
228254
];
229255
// nothing scattered stands in the minster or its yard
230-
const inTown = (x, z) => ASH_GROUND.some((g) => Math.hypot(x - g.x, z - g.y) < g.r);
256+
// the town, and the track and what lies by it, are kept clear of the scatter
257+
const inTown = (x, z) => ASH_GROUND.some((g) => Math.hypot(x - g.x, z - g.y) < g.r) || nearTrack(x, z, 240);
231258
for (let i = spots.length - 1; i >= 0; i--) if (inTown(spots[i][0], spots[i][1])) spots.splice(i, 1);
232259
const canKK = KK && KK.staticInstance;
233260
if (canKK) {
@@ -503,8 +530,65 @@ function buildEmberScene() {
503530
const pit = new THREE.Mesh(new THREE.CircleGeometry(r * 0.7, 16), new THREE.MeshBasicMaterial({ color: 0x0a0504 }));
504531
pit.rotation.x = -Math.PI / 2; pit.position.set(TX + dx, 2.6, TY + dy); scene.add(pit);
505532
}
533+
// ── the field: the worn track out from the portal and from Ashminster, a
534+
// fingerpost at each fork, waymarker stones along it, and the twelve things
535+
// the war left beside it (data/emberField.js)
536+
const trackMat = new THREE.MeshLambertMaterial({ color: 0x3a1c13, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 });
537+
const rutMat = new THREE.MeshLambertMaterial({ color: 0x24100b, polygonOffset: true, polygonOffsetFactor: -3, polygonOffsetUnits: -3 });
538+
const strip = (ax, ay, bx, by, w, mat, y) => {
539+
const len = Math.hypot(bx - ax, by - ay);
540+
const m = new THREE.Mesh(new THREE.PlaneGeometry(len + w * 0.6, w), mat);
541+
m.rotation.x = -Math.PI / 2; m.rotation.z = -Math.atan2(by - ay, bx - ax);
542+
m.position.set((ax + bx) / 2, y, (ay + by) / 2);
543+
scene.add(m);
544+
};
545+
const markerSpots = [];
546+
for (const run of FIELD_TRACK) {
547+
let carry = 700;
548+
for (let i = 0; i < run.length - 1; i++) {
549+
const [ax, ay] = run[i], [bx, by] = run[i + 1];
550+
strip(ax, ay, bx, by, 210, trackMat, 2.6);
551+
const len = Math.hypot(bx - ax, by - ay), ux = (bx - ax) / len, uy = (by - ay) / len;
552+
for (const side of [-1, 1]) strip(ax - uy * side * 38, ay + ux * side * 38, bx - uy * side * 38, by + ux * side * 38, 16, rutMat, 2.8); // cart ruts
553+
for (let d = carry; d < len; d += 1400) { const side = markerSpots.length % 2 ? 1 : -1; markerSpots.push([ax + ux * d - uy * side * 135, ay + uy * d + ux * side * 135]); }
554+
carry = (carry - len) % 1400; if (carry < 0) carry += 1400;
555+
}
556+
}
557+
const fieldKiosks = [];
558+
const fieldPlaced = new Set();
559+
const buildField = () => {
560+
if (!(KK && KK.has && KK.staticInstance)) return;
561+
markerSpots.forEach(([x, y], i) => {
562+
const k = 'm' + i; if (fieldPlaced.has(k) || !KK.has('wl_waymarker')) return;
563+
const g = KK.staticInstance('wl_waymarker', 110, 'height'); if (!g) return;
564+
fieldPlaced.add(k); scorch(g, 0.12); g.rotation.y = i * 1.7; g.position.set(x, 0, y); scene.add(g);
565+
});
566+
FIELD_POSTS.forEach((p, i) => {
567+
const k = 'p' + i; if (fieldPlaced.has(k) || !KK.has('em_fingerpost')) return;
568+
const g = KK.staticInstance('em_fingerpost', 175, 'height'); if (!g) return;
569+
fieldPlaced.add(k); g.rotation.y = i * 0.9; g.position.set(p.x, 0, p.y); scene.add(g);
570+
});
571+
FIELD_FINDS.forEach((f) => {
572+
if (fieldPlaced.has(f.id) || !KK.has(f.model)) return;
573+
const g = KK.staticInstance(f.model, f.size, 'fit'); if (!g) return;
574+
fieldPlaced.add(f.id);
575+
if (f.model === 'em_barricade' || f.model === 'em_chapel') scorch(g, 0.1);
576+
g.rotation.y = f.turn; g.position.set(f.x, 0, f.y); scene.add(g);
577+
});
578+
};
579+
for (const p of FIELD_POSTS) if (typeof makeNpcNameSprite === 'function') { const l = makeNpcNameSprite('🪧 ' + p.arms.join(' · ')); l.position.set(p.x, 215, p.y); scene.add(l); }
580+
for (const f of FIELD_FINDS) {
581+
fieldKiosks.push({ x: f.x, z: f.y, npc: 'field', fieldId: f.id, hint: f.hint, radius: 150 });
582+
if (/barrow|chapel/.test(f.model)) { const h = f.size * 0.32; scene.userData.walls.push({ x: f.x - h, y: f.y - h, w: h * 2, h: h * 2 }); }
583+
if (typeof makeNpcNameSprite === 'function') { const l = makeNpcNameSprite(`${f.icon} ${f.name}`); l.position.set(f.x, Math.max(130, f.size * 0.55), f.y); scene.add(l); }
584+
}
585+
buildField();
586+
if (KK && fieldPlaced.size < markerSpots.length + FIELD_POSTS.length + FIELD_FINDS.length) {
587+
if (KK.promise && typeof KK.promise.then === 'function') KK.promise.then(buildField).catch(() => {});
588+
if (KK.onAssetLoad) KK.onAssetLoad(buildField);
589+
}
506590
// what can be stood at and used, each with its name over it
507-
const ashKiosks = [];
591+
const ashKiosks = [...fieldKiosks];
508592
for (const id in ASH_SPOTS) {
509593
const sp = ASH_SPOTS[id];
510594
ashKiosks.push({ x: sp.x, z: sp.y, npc: 'ash', ashId: id, hint: sp.hint, radius: 130 });

‎client/main.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,9 @@ const KK = (() => {
470470
em_minster: 'kk/home/minster.glb', // Ashminster, in the Ember Wastes (data/emberTown.js)
471471
em_house_a: 'kk/home/ash_house_a.glb', em_house_b: 'kk/home/ash_house_b.glb', em_house_c: 'kk/home/ash_house_c.glb',
472472
em_ash_cross: 'kk/home/ash_cross.glb', em_barricade: 'kk/home/ash_barricade.glb', em_frith_cross: 'kk/home/frith_cross.glb',
473+
// the field along the Wastes' track (data/emberField.js)
474+
em_standard: 'kk/home/field_standard.glb', em_barrow: 'kk/home/field_barrow.glb', em_trench: 'kk/home/field_trench.glb', em_chapel: 'kk/home/field_chapel.glb',
475+
em_bottle: 'kk/home/field_bottle.glb', em_bell: 'kk/home/field_bell.glb', em_rowan: 'kk/home/rowan_post.glb', em_fingerpost: 'kk/home/fingerpost.glb',
473476
wd_kshelf: 'kk/home/keep_shelf.glb', wd_kbone: 'kk/home/keep_bone.glb', wd_krope: 'kk/home/keep_bellrope.glb', wd_kcobalt: 'kk/home/keep_cobalt.glb', wd_kclaw: 'kk/home/keep_claw.glb',
474477
wd_besom: 'kk/home/besom.glb', wd_cream: 'kk/home/cream_bowl.glb', tw_skep: 'kk/home/bee_skep.glb',
475478
wd_kern: 'kk/home/kern_baby.glb', wd_sheaf: 'kk/home/sheaf_hung.glb',
@@ -3148,6 +3151,7 @@ function onWsMessage(ev) {
31483151
if (msg.type === 'whisper') { receiveWhisper(msg); return; }
31493152
if (msg.type === 'whisper_error') { setUnlockToast(msg.message, { level: 'important' }); return; }
31503153
if (msg.type === 'ash_quest') { openQuestDialogue(msg.npcId, msg.npcName); return; } // the anchoress, once she has said her piece (lib/ashminster.js)
3154+
if (msg.type === 'field_state') { fieldDone = new Set(msg.done || []); return; }
31513155
if (msg.type === 'ash_state') { ashSanctuaryUntil = msg.sanctuaryUntil || 0; return; }
31523156
if (msg.type === 'village_ward') { wardState = msg; applyVillageWard(msg); return; }
31533157
if (msg.type === 'candle_state') { setCorpseCandle(msg); return; }
@@ -3276,6 +3280,7 @@ function onWsMessage(ev) {
32763280

32773281
if (msg.type === 'ember_wastes_entered') {
32783282
emberLandmarks = msg.landmarks || [];
3283+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'field_state' }));
32793284
emberDiscovered = {};
32803285
if (me) { me.room = 'ember_wastes'; me.x = msg.spawn.x; me.y = msg.spawn.y; }
32813286
swapToEmberMap();
@@ -4293,6 +4298,7 @@ let msPacksCatalog = null; // packId -> { ms, cents, name } from init
42934298
// ── Session L state ──────────────────────────────────────────────────────────
42944299
let rareRoomCatalog = {}; // kind -> { icon, name, how, tier, lore, source, note } (data/rareRooms.js)
42954300
let lastRareRoom = null; // the rare room of the dungeon or Delve floor you are in, from the last dungeon_finds
4301+
let fieldDone = new Set(); // the field's finds searched today (lib/emberField.js)
42964302
let ashSanctuaryUntil = 0; // sanctuary at Ashminster, till when (lib/ashminster.js)
42974303
let wardState = { lit: [], warded: false }; // the village's bound fires tonight (lib/villageWard.js)
42984304
let waysideDone = new Set(); // which of the road's three you have used today (data/wayside.js)
@@ -19350,6 +19356,7 @@ function tryInteract() {
1935019356
if (kiosk && kiosk.npc === 'wayside') { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'wayside_use', id: kiosk.waysideId })); return; }
1935119357
if (kiosk && kiosk.npc === 'wardstone') { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ward_use', id: kiosk.wardId })); return; }
1935219358
if (kiosk && kiosk.npc === 'ash') { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ash_use', id: kiosk.ashId })); return; }
19359+
if (kiosk && kiosk.npc === 'field') { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'field_use', id: kiosk.fieldId })); return; }
1935319360
if (kiosk && kiosk.npc === 'packman') { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'npc_shop_open', npcId: 'npc_packman' })); return; }
1935419361
if (kiosk && kiosk.npc === 'quest') { openQuestDialogue(kiosk.npcId, kiosk.npcName); return; }
1935519362
if (kiosk && kiosk.npc === 'hint') { openNpcHintTalk(kiosk.npcId); return; }
@@ -19673,6 +19680,12 @@ function updateInteractHint() {
1967319680
document.getElementById('interactHintText').textContent = `${interactVerb()} read the plaque`;
1967419681
return;
1967519682
}
19683+
if (kiosk && kiosk.npc === 'field') {
19684+
// the field (data/emberField.js): once a day, and then it is read again
19685+
hint.classList.remove('hidden');
19686+
document.getElementById('interactHintText').textContent = `${interactVerb()} ${fieldDone.has(kiosk.fieldId) ? 'read it again' : kiosk.hint}`;
19687+
return;
19688+
}
1967619689
if (kiosk && kiosk.npc === 'ash') {
1967719690
// Ashminster, in the Ember Wastes (data/emberTown.js): the knocker, the squint, the marks, a frith cross
1967819691
hint.classList.remove('hidden');

0 commit comments

Comments
 (0)