WUXIA: 100 Days After — full game
Single-file ink-painting wuxia sect-survival RPG. Lead a sect through 100 days: martial arts combos, cultivation, tactical combat, faction war, chronicle endings. - src/ 13 modules (data, sim, combat, render, UI, app shell) - wuxia.html self-contained build (no dependencies) - tools/build.js bundler - tests: headless 100-day sims + jsdom UI smoke + real-browser Chromium click-through (playwright)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
nohup.out
|
||||
@@ -0,0 +1,77 @@
|
||||
# WUXIA: 100 DAYS AFTER — 武林百日后
|
||||
|
||||
A single-file, browser-based wuxia sect-survival RPG in the style of a Chinese ink
|
||||
painting. Lead a small martial-arts school through exactly **100 days** of the
|
||||
jianghu — recruit disciples, learn (or steal) techniques, combine martial arts into
|
||||
devastating sets, survive faction war, and decide what your legend becomes.
|
||||
|
||||
**Everything ships in one file: [`wuxia.html`](wuxia.html)** (~390 KB, no dependencies,
|
||||
no network). Open it directly in any modern browser (`file://` works), or serve it:
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8080
|
||||
# → http://localhost:8080/wuxia.html
|
||||
```
|
||||
|
||||
## The game
|
||||
|
||||
- **Core loop:** PLAN (limited AP) → TRAVEL an procedurally-drawn jianghu → EXPLORE /
|
||||
gather / recruit / spy → EVENTS with real consequences → turn-based tactical COMBAT →
|
||||
TRAIN disciples & assign sect duties → manage gold/food/medicine → ADVANCE DAY.
|
||||
- **Martial arts combinations:** equip up to 4 arts. Certain sets fuse into **combos**
|
||||
(13 total) — e.g. *Iron Palm + Lightning Step + Drunken Fist* unlocks the
|
||||
**Drunken Thunder** ultimate. Forbidden pairings (*Nine Yin + Phantom Steps*) grant
|
||||
power but inflict round-by-round backlash.
|
||||
- **Cultivation realms:** Mortal → Legend, with risky breakthroughs (failure injures;
|
||||
catastrophic failure can kill). Pills and high Medicine help.
|
||||
- **Living characters:** traits, loyalty, relationships, memories, romance; disciples
|
||||
train assigned arts, gain scars, and can die permanently.
|
||||
- **9 factions & 2 rival sects:** reputation on five axes (Honor/Fear/Mercy/Ambition/
|
||||
Deception), fame titles, war that erupts mid-run, raids on your mountain, and a
|
||||
scripted final invasion around day 92.
|
||||
- **Endings:** 11 chronicle-driven endings (Legend, New Era, Conqueror, Demonic,
|
||||
Hermit, Betrayer, Martyr…) computed from what you actually did.
|
||||
- **Presentation:** canvas isometric ink-painting rendering (weather, day/night,
|
||||
parallax mountains), procedural portraits & event scene art, cinematic brush-stroke
|
||||
technique FX, generative pentatonic soundtrack + synthesized SFX (WebAudio),
|
||||
autosave/slots/export codes, 11 achievements, 4 difficulties.
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
wuxia.html ← the game (self-contained build output; also copied to index.html)
|
||||
src/
|
||||
00_boot.js W namespace, seeded RNG, utils, event bus, constants
|
||||
05_audio.js generative music + SFX engine (WebAudio)
|
||||
10_data_arts.js 47 martial arts, 13 combos, realms
|
||||
12_data_world.js backgrounds, factions, items, buildings, enemies, locations…
|
||||
14_data_events.js ~40 data-driven events (world / travel / sect)
|
||||
20_state.js state factory, world gen, save/load/export
|
||||
22_sim.js simulation API: actions, days, events, meetings, breakthroughs
|
||||
24_combat.js tactical grid combat (AI, statuses, combo ultimates)
|
||||
30_render_world.js isometric ink renderer
|
||||
32_render_fx.js portraits, scene art, cinematic FX
|
||||
40_ui.js all screens, HUD, panels, modals
|
||||
50_app.js boot, main loop, input, achievements, flow orchestration
|
||||
style.css ink/lacquer theme
|
||||
tools/build.js bundles src/* into wuxia.html
|
||||
test/headless.js full-game simulations (random play, N seeds)
|
||||
test/ui_smoke.js jsdom boot + click-through smoke test of the real UI
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
node tools/build.js # rebuild wuxia.html after editing src/
|
||||
node test/headless.js 40 # simulate 40 complete runs headlessly
|
||||
node test/ui_smoke.js # boot the built file in jsdom and drive the UI
|
||||
```
|
||||
|
||||
## Tips for new sect leaders
|
||||
|
||||
- Rest when hurt; dead disciples do not come back. Medicine heals injuries fast.
|
||||
- Spar with wandering masters — losing teaches you their art anyway.
|
||||
- Recruit early: assigned disciples generate inner-force growth every day.
|
||||
- Watch your food. Hungry sects lose morale, then people.
|
||||
- When the war comes (around day 45–60), neutrality is also a choice… but someone
|
||||
always remembers who stood aside.
|
||||
+6808
File diff suppressed because it is too large
Load Diff
+101
@@ -0,0 +1,101 @@
|
||||
/* =========================================================================
|
||||
WUXIA: 100 DAYS AFTER — core boot: namespace, RNG, utils, event bus
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const G = (typeof window !== 'undefined') ? window : globalThis;
|
||||
const W = G.W = G.W || {};
|
||||
W.HEADLESS = (typeof document === 'undefined');
|
||||
W.VERSION = '1.0.0';
|
||||
|
||||
/* ---------------- seeded RNG (mulberry32) ---------------- */
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
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;
|
||||
};
|
||||
}
|
||||
W._rng = mulberry32(Date.now() & 0xffffffff);
|
||||
W.seedRng = function (seed) { W._rng = mulberry32(seed | 0); W._rngSeed = seed | 0; };
|
||||
W.rng = function () { return W._rng(); };
|
||||
W.ri = function (a, b) { return a + Math.floor(W.rng() * (b - a + 1)); };
|
||||
W.rf = function (a, b) { return a + W.rng() * (b - a); };
|
||||
W.pick = function (arr) { return arr[Math.floor(W.rng() * arr.length)]; };
|
||||
W.pickW = function (pairs) { // [[item, weight],...]
|
||||
let tot = 0; for (const p of pairs) tot += Math.max(0, p[1]);
|
||||
let r = W.rng() * tot;
|
||||
for (const p of pairs) { r -= Math.max(0, p[1]); if (r <= 0) return p[0]; }
|
||||
return pairs[pairs.length - 1][0];
|
||||
};
|
||||
W.shuffle = function (arr) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(W.rng() * (i + 1)); const t = a[i]; a[i] = a[j]; a[j] = t; }
|
||||
return a;
|
||||
};
|
||||
W.chance = function (p) { return W.rng() < p; };
|
||||
|
||||
/* ---------------- math / misc utils ---------------- */
|
||||
const U = W.U = {
|
||||
clamp: (v, a, b) => v < a ? a : (v > b ? b : v),
|
||||
lerp: (a, b, t) => a + (b - a) * t,
|
||||
dist: (x1, y1, x2, y2) => Math.max(Math.abs(x1 - x2), Math.abs(y1 - y2)),
|
||||
dist2: (x1, y1, x2, y2) => Math.hypot(x1 - x2, y1 - y2),
|
||||
sign: v => v < 0 ? -1 : (v > 0 ? 1 : 0),
|
||||
cap: s => s ? s.charAt(0).toUpperCase() + s.slice(1) : s,
|
||||
esc: s => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
||||
clone: o => JSON.parse(JSON.stringify(o)),
|
||||
sum: arr => arr.reduce((a, b) => a + b, 0),
|
||||
avg: arr => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0,
|
||||
round: (v, d = 0) => { const m = Math.pow(10, d); return Math.round(v * m) / m; },
|
||||
// deterministic hash string -> uint
|
||||
hash: s => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; },
|
||||
// deterministic per-key rng (doesn't advance global rng)
|
||||
drand: key => { let h = U.hash(String(key)); return mulberry32(h)(); },
|
||||
fmtSigned: v => (v >= 0 ? '+' : '') + Math.round(v),
|
||||
plural: (n, s) => n + ' ' + s + (n === 1 ? '' : 's'),
|
||||
};
|
||||
|
||||
/* ---------------- tiny event bus ---------------- */
|
||||
const BUS = W.BUS = {
|
||||
map: {},
|
||||
on(ev, fn) { (BUS.map[ev] = BUS.map[ev] || []).push(fn); return fn; },
|
||||
off(ev, fn) { const a = BUS.map[ev]; if (a) { const i = a.indexOf(fn); if (i >= 0) a.splice(i, 1); } },
|
||||
emit(ev, data) { const a = BUS.map[ev]; if (a) for (const fn of a.slice()) { try { fn(data); } catch (e) { if (!W.HEADLESS) console.error('[bus]', ev, e); } } }
|
||||
};
|
||||
|
||||
/* ---------------- shared game constants ---------------- */
|
||||
W.C = {
|
||||
DAYS: 100,
|
||||
RES: ['gold', 'food', 'medicine', 'wood', 'iron'],
|
||||
RES_N: { gold: 'Gold', food: 'Food', medicine: 'Medicine', wood: 'Wood', iron: 'Iron' },
|
||||
RES_CN: { gold: '金', food: '粮', medicine: '药', wood: '木', iron: '铁' },
|
||||
SEASONS: [
|
||||
{ id: 'spring', n: 'Spring', cn: '春', start: 1 },
|
||||
{ id: 'summer', n: 'Summer', cn: '夏', start: 26 },
|
||||
{ id: 'autumn', n: 'Autumn', cn: '秋', start: 56 },
|
||||
{ id: 'winter', n: 'Winter', cn: '冬', start: 81 },
|
||||
],
|
||||
PHASES: [
|
||||
{ id: 'morning', n: 'Morning', cn: '晨' },
|
||||
{ id: 'afternoon', n: 'Afternoon', cn: '午' },
|
||||
{ id: 'evening', n: 'Evening', cn: '暮' },
|
||||
{ id: 'night', n: 'Night', cn: '夜' },
|
||||
],
|
||||
DIFF: {
|
||||
wanderer: { n: 'Wanderer', cn: '游侠', desc: 'A gentle road. The jianghu is merciful.', enemy: 0.8, food: 0.8, event: 0.85, break: 0.10 },
|
||||
jianghu: { n: 'Jianghu', cn: '江湖', desc: 'The river and lakes, as they are.', enemy: 1.0, food: 1.0, event: 1.0, break: 0 },
|
||||
master: { n: 'Martial Master', cn: '宗师', desc: 'Enemies strike harder. Food runs thin.', enemy: 1.3, food: 1.25, event: 1.15, break: -0.10 },
|
||||
heaven: { n: "Heaven's Trial", cn: '天劫', desc: 'Heaven itself tests your sect.', enemy: 1.65, food: 1.5, event: 1.3, break: -0.2 },
|
||||
},
|
||||
};
|
||||
W.seasonOf = day => { const s = W.C.SEASONS; let cur = s[0]; for (const x of s) if (day >= x.start) cur = x; return cur; };
|
||||
|
||||
// storage helpers (localStorage guarded)
|
||||
const store = {
|
||||
get(k, d) { try { const v = localStorage.getItem(k); return v == null ? d : JSON.parse(v); } catch (e) { return d; } },
|
||||
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { } },
|
||||
del(k) { try { localStorage.removeItem(k); } catch (e) { } }
|
||||
};
|
||||
W.store = store;
|
||||
})();
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/* =========================================================================
|
||||
Procedural audio: Chinese-pentatonic generative score + SFX (WebAudio)
|
||||
========================================================================= */
|
||||
(function () {
|
||||
if (W.HEADLESS) { W.audio = { init() { }, setMood() { }, sfx() { }, setVol() { }, tick() { } }; return; }
|
||||
|
||||
const A = W.audio = {
|
||||
ctx: null, master: null, musicG: null, sfxG: null,
|
||||
mood: null, moodName: '', started: false,
|
||||
vol: { music: 0.6, sfx: 0.8 },
|
||||
nextBeat: 0, beat: 0, timer: null,
|
||||
};
|
||||
|
||||
const MOODS = {
|
||||
title: { bpm: 52, root: 220, scale: [0, 2, 4, 7, 9, 12, 14], pluck: 0.5, erhu: 0.35, flute: 0, drum: 0, bright: 0.5 },
|
||||
sect: { bpm: 56, root: 196, scale: [0, 2, 4, 7, 9, 12], pluck: 0.55, erhu: 0.3, flute: 0.15, drum: 0, bright: 0.6 },
|
||||
travel: { bpm: 66, root: 220, scale: [0, 3, 5, 7, 10, 12], pluck: 0.6, erhu: 0.3, flute: 0.1, drum: 0.08, bright: 0.55 },
|
||||
town: { bpm: 72, root: 233, scale: [0, 2, 4, 7, 9, 12], pluck: 0.65, erhu: 0.25, flute: 0.2, drum: 0.1, bright: 0.7 },
|
||||
night: { bpm: 44, root: 174, scale: [0, 3, 5, 7, 10], pluck: 0.3, erhu: 0.4, flute: 0.35, drum: 0, bright: 0.25 },
|
||||
event: { bpm: 50, root: 185, scale: [0, 2, 3, 7, 8], pluck: 0.25, erhu: 0.5, flute: 0.1, drum: 0.05, bright: 0.35 },
|
||||
combat: { bpm: 132, root: 220, scale: [0, 3, 5, 7, 10, 12], pluck: 0.5, erhu: 0.2, flute: 0, drum: 0.5, bright: 0.5 },
|
||||
dark: { bpm: 40, root: 146, scale: [0, 1, 5, 6, 10], pluck: 0.2, erhu: 0.45, flute: 0.1, drum: 0.05, bright: 0.15 },
|
||||
};
|
||||
|
||||
function ensure() {
|
||||
if (A.ctx) return true;
|
||||
try {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
A.ctx = new AC();
|
||||
A.master = A.ctx.createGain(); A.master.gain.value = 0.9; A.master.connect(A.ctx.destination);
|
||||
A.musicG = A.ctx.createGain(); A.musicG.gain.value = A.vol.music; A.musicG.connect(A.master);
|
||||
A.sfxG = A.ctx.createGain(); A.sfxG.gain.value = A.vol.sfx; A.sfxG.connect(A.master);
|
||||
// noise buffer
|
||||
const len = A.ctx.sampleRate * 2;
|
||||
A.noiseBuf = A.ctx.createBuffer(1, len, A.ctx.sampleRate);
|
||||
const d = A.noiseBuf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
A.init = function () {
|
||||
if (!ensure()) return;
|
||||
if (A.ctx.state === 'suspended') A.ctx.resume();
|
||||
if (!A.started) { A.started = true; A.nextBeat = A.ctx.currentTime + 0.1; A.timer = setInterval(A.tick, 120); }
|
||||
};
|
||||
|
||||
A.setVol = function (music, sfx) {
|
||||
if (music != null) A.vol.music = music;
|
||||
if (sfx != null) A.vol.sfx = sfx;
|
||||
if (A.musicG) A.musicG.gain.value = A.vol.music;
|
||||
if (A.sfxG) A.sfxG.gain.value = A.vol.sfx;
|
||||
};
|
||||
|
||||
A.setMood = function (name) {
|
||||
if (!MOODS[name] || A.moodName === name) return;
|
||||
A.moodName = name; A.mood = MOODS[name]; A.beat = 0;
|
||||
};
|
||||
|
||||
/* ------- instruments ------- */
|
||||
function pluck(freq, t, dur, g, bright) {
|
||||
const c = A.ctx, o1 = c.createOscillator(), o2 = c.createOscillator(), f = c.createBiquadFilter(), gn = c.createGain();
|
||||
o1.type = 'triangle'; o2.type = 'sine'; o1.frequency.value = freq; o2.frequency.value = freq * 2.001;
|
||||
f.type = 'lowpass'; f.frequency.value = 900 + bright * 2600; f.Q.value = 0.7;
|
||||
gn.gain.setValueAtTime(0.0001, t);
|
||||
gn.gain.exponentialRampToValueAtTime(g, t + 0.008);
|
||||
gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
o1.connect(f); o2.connect(f); f.connect(gn); gn.connect(A.musicG);
|
||||
o1.start(t); o2.start(t); o1.stop(t + dur + 0.05); o2.stop(t + dur + 0.05);
|
||||
}
|
||||
function erhu(freq, t, dur, g) {
|
||||
const c = A.ctx, o = c.createOscillator(), f = c.createBiquadFilter(), gn = c.createGain(), lfo = c.createOscillator(), lg = c.createGain();
|
||||
o.type = 'sawtooth'; o.frequency.setValueAtTime(freq * 0.98, t);
|
||||
o.frequency.linearRampToValueAtTime(freq, t + Math.min(0.12, dur * 0.3));
|
||||
f.type = 'lowpass'; f.frequency.value = 1500; f.Q.value = 2;
|
||||
lfo.frequency.value = 5.2; lg.gain.value = freq * 0.008;
|
||||
lfo.connect(lg); lg.connect(o.frequency);
|
||||
gn.gain.setValueAtTime(0.0001, t);
|
||||
gn.gain.linearRampToValueAtTime(g, t + dur * 0.25);
|
||||
gn.gain.linearRampToValueAtTime(0.0001, t + dur);
|
||||
o.connect(f); f.connect(gn); gn.connect(A.musicG);
|
||||
o.start(t); lfo.start(t); o.stop(t + dur + 0.05); lfo.stop(t + dur + 0.05);
|
||||
}
|
||||
function flute(freq, t, dur, g) {
|
||||
const c = A.ctx, o = c.createOscillator(), gn = c.createGain(), f = c.createBiquadFilter(), n = c.createBufferSource(), nf = c.createBiquadFilter(), ng = c.createGain();
|
||||
o.type = 'sine'; o.frequency.value = freq;
|
||||
f.type = 'lowpass'; f.frequency.value = 1800;
|
||||
gn.gain.setValueAtTime(0.0001, t);
|
||||
gn.gain.linearRampToValueAtTime(g, t + dur * 0.3);
|
||||
gn.gain.linearRampToValueAtTime(0.0001, t + dur);
|
||||
n.buffer = A.noiseBuf; nf.type = 'bandpass'; nf.frequency.value = freq * 2; ng.gain.value = g * 0.15;
|
||||
o.connect(f); f.connect(gn); gn.connect(A.musicG);
|
||||
n.connect(nf); nf.connect(ng); ng.connect(A.musicG);
|
||||
o.start(t); n.start(t); o.stop(t + dur + 0.05); n.stop(t + dur + 0.05);
|
||||
}
|
||||
function drum(t, g, low) {
|
||||
const c = A.ctx, o = c.createOscillator(), gn = c.createGain(), n = c.createBufferSource(), nf = c.createBiquadFilter(), ng = c.createGain();
|
||||
o.type = 'sine'; o.frequency.setValueAtTime(low ? 90 : 160, t); o.frequency.exponentialRampToValueAtTime(40, t + 0.18);
|
||||
gn.gain.setValueAtTime(g, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 0.22);
|
||||
o.connect(gn); gn.connect(A.musicG); o.start(t); o.stop(t + 0.25);
|
||||
n.buffer = A.noiseBuf; nf.type = 'bandpass'; nf.frequency.value = low ? 200 : 900; ng.gain.setValueAtTime(g * 0.5, t); ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);
|
||||
n.connect(nf); nf.connect(ng); ng.connect(A.musicG); n.start(t); n.stop(t + 0.15);
|
||||
}
|
||||
|
||||
A.tick = function () {
|
||||
if (!A.ctx || !A.mood || A.ctx.state !== 'running') return;
|
||||
const m = A.mood, spb = 60 / m.bpm, now = A.ctx.currentTime;
|
||||
while (A.nextBeat < now + 0.4) {
|
||||
const t = A.nextBeat, b = A.beat;
|
||||
const sc = m.scale, deg = sc[Math.floor(W.rng() * sc.length)];
|
||||
const oct = W.chance(0.2) ? 2 : 1;
|
||||
if (W.chance(m.pluck * 0.8)) pluck(m.root * oct * Math.pow(2, deg / 12), t, spb * W.rf(1.5, 3), 0.16, m.bright);
|
||||
if (b % 4 === 0 && W.chance(m.erhu)) erhu(m.root * Math.pow(2, sc[Math.floor(W.rng() * 3)] / 12), t, spb * W.rf(2.5, 4.5), 0.075);
|
||||
if (b % 8 === 4 && W.chance(m.flute)) flute(m.root * 2 * Math.pow(2, sc[0] / 12), t, spb * 3, 0.05);
|
||||
if (m.drum > 0) {
|
||||
if (b % 4 === 0) drum(t, 0.12 * m.drum * 2, true);
|
||||
if (b % 2 === 1 && W.chance(0.5)) drum(t, 0.05 * m.drum * 2, false);
|
||||
}
|
||||
A.nextBeat += spb; A.beat++;
|
||||
}
|
||||
};
|
||||
|
||||
/* ------- SFX ------- */
|
||||
const SFX = {
|
||||
click(t) { blip(t, 1800, 0.05, 0.12, 'square'); },
|
||||
hover(t) { blip(t, 2400, 0.03, 0.05, 'sine'); },
|
||||
seal(t) { thud(t, 0.25, 120); noise(t, 0.1, 3000, 0.1); },
|
||||
brush(t) { noise(t, 0.22, 1400, 0.12, 'bandpass'); },
|
||||
paper(t) { noise(t, 0.15, 5000, 0.06, 'highpass'); },
|
||||
coin(t) { blip(t, 1300, 0.09, 0.12, 'triangle'); blip(t + 0.07, 1750, 0.12, 0.1, 'triangle'); },
|
||||
eat(t) { noise(t, 0.08, 800, 0.08, 'lowpass'); },
|
||||
sword(t) { noise(t, 0.12, 4200, 0.16, 'highpass'); blip(t, 2800, 0.15, 0.08, 'sawtooth', true); },
|
||||
clash(t) { blip(t, 3200, 0.18, 0.14, 'square', true); noise(t, 0.1, 5000, 0.14, 'highpass'); },
|
||||
hit(t) { thud(t, 0.3, 150); noise(t, 0.08, 900, 0.12, 'lowpass'); },
|
||||
crit(t) { thud(t, 0.4, 100); noise(t, 0.16, 2500, 0.2, 'bandpass'); blip(t + 0.03, 220, 0.25, 0.15, 'sawtooth', true); },
|
||||
block(t) { blip(t, 500, 0.1, 0.14, 'square'); noise(t, 0.06, 2000, 0.08, 'bandpass'); },
|
||||
heal(t) { arpeggio(t, [523, 659, 784], 0.09, 0.09, 'sine'); },
|
||||
poison(t) { noise(t, 0.3, 400, 0.1, 'lowpass'); blip(t + 0.1, 180, 0.2, 0.08, 'sawtooth', true); },
|
||||
thunder(t) { noise(t, 0.5, 1200, 0.3, 'lowpass'); thud(t, 0.4, 70); },
|
||||
bell(t) { bell(t, 440, 0.3); },
|
||||
gong(t) { bell(t, 98, 0.5); bell(t + 0.02, 147, 0.4, 0.5); },
|
||||
levelup(t) { arpeggio(t, [392, 523, 659, 784], 0.1, 0.12, 'triangle'); },
|
||||
whoosh(t) { noise(t, 0.25, 900, 0.12, 'bandpass', 0.5); },
|
||||
death(t) { thud(t, 0.4, 80); blip(t + 0.1, 140, 0.5, 0.1, 'sawtooth', true); },
|
||||
win(t) { arpeggio(t, [523, 659, 784, 1046], 0.12, 0.14, 'triangle'); bell(t + 0.5, 1046, 0.2, 0.3); },
|
||||
lose(t) { arpeggio(t, [392, 330, 262, 196], 0.16, 0.12, 'sine'); thud(t + 0.7, 0.3, 70); },
|
||||
rain_on(t) { },
|
||||
};
|
||||
function blip(t, freq, dur, g, type, slide) {
|
||||
const c = A.ctx, o = c.createOscillator(), gn = c.createGain(), f = c.createBiquadFilter();
|
||||
o.type = type || 'sine'; o.frequency.setValueAtTime(freq, t);
|
||||
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq * 0.25), t + dur);
|
||||
f.type = 'lowpass'; f.frequency.value = 6000;
|
||||
gn.gain.setValueAtTime(g, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
o.connect(f); f.connect(gn); gn.connect(A.sfxG); o.start(t); o.stop(t + dur + 0.05);
|
||||
}
|
||||
function thud(t, g, freq) {
|
||||
const c = A.ctx, o = c.createOscillator(), gn = c.createGain();
|
||||
o.type = 'sine'; o.frequency.setValueAtTime(freq * 2.2, t); o.frequency.exponentialRampToValueAtTime(freq * 0.5, t + 0.16);
|
||||
gn.gain.setValueAtTime(g, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 0.2);
|
||||
o.connect(gn); gn.connect(A.sfxG); o.start(t); o.stop(t + 0.25);
|
||||
}
|
||||
function noise(t, dur, freq, g, type, sweep) {
|
||||
const c = A.ctx, n = c.createBufferSource(), f = c.createBiquadFilter(), gn = c.createGain();
|
||||
n.buffer = A.noiseBuf; n.loop = true;
|
||||
f.type = type || 'bandpass'; f.frequency.setValueAtTime(freq, t); f.Q.value = 0.8;
|
||||
if (sweep) f.frequency.exponentialRampToValueAtTime(freq * (0.4 + sweep), t + dur);
|
||||
gn.gain.setValueAtTime(0.0001, t); gn.gain.linearRampToValueAtTime(g, t + dur * 0.2); gn.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
n.connect(f); f.connect(gn); gn.connect(A.sfxG); n.start(t); n.stop(t + dur + 0.05);
|
||||
}
|
||||
function bell(t, freq, g, mul) {
|
||||
const c = A.ctx, partials = [1, 2.01, 2.9, 4.2], base = g || 0.2;
|
||||
for (let i = 0; i < partials.length; i++) {
|
||||
const o = c.createOscillator(), gn = c.createGain();
|
||||
o.type = 'sine'; o.frequency.value = freq * partials[i] * (mul || 1);
|
||||
const gg = base / (i + 1.5);
|
||||
gn.gain.setValueAtTime(gg, t); gn.gain.exponentialRampToValueAtTime(0.0001, t + 1.6 - i * 0.3);
|
||||
o.connect(gn); gn.connect(A.sfxG); o.start(t); o.stop(t + 1.8);
|
||||
}
|
||||
}
|
||||
function arpeggio(t, freqs, step, g, type) {
|
||||
freqs.forEach((f, i) => blip(t + i * step, f, step * 2.2, g, type || 'sine'));
|
||||
}
|
||||
|
||||
A.sfx = function (name) {
|
||||
if (!A.ctx || A.ctx.state !== 'running' || !SFX[name]) return;
|
||||
try { SFX[name](A.ctx.currentTime + 0.001); } catch (e) { }
|
||||
};
|
||||
|
||||
// ambient loops (rain/wind) as filtered noise with slow LFO
|
||||
let ambSrc = null, ambFilter = null, ambGain = null, ambKind = '';
|
||||
A.ambient = function (kind) {
|
||||
if (!A.ctx) return;
|
||||
if (kind === ambKind) return;
|
||||
ambKind = kind;
|
||||
if (ambSrc) { try { ambSrc.stop(); } catch (e) { } ambSrc = null; }
|
||||
if (!kind) return;
|
||||
const c = A.ctx;
|
||||
ambSrc = c.createBufferSource(); ambSrc.buffer = A.noiseBuf; ambSrc.loop = true;
|
||||
ambFilter = c.createBiquadFilter(); ambGain = c.createGain();
|
||||
if (kind === 'rain') { ambFilter.type = 'bandpass'; ambFilter.frequency.value = 2600; ambFilter.Q.value = 0.4; ambGain.gain.value = 0.055; }
|
||||
else if (kind === 'storm') { ambFilter.type = 'bandpass'; ambFilter.frequency.value = 1500; ambFilter.Q.value = 0.3; ambGain.gain.value = 0.09; }
|
||||
else if (kind === 'wind') { ambFilter.type = 'lowpass'; ambFilter.frequency.value = 500; ambGain.gain.value = 0.05; }
|
||||
else if (kind === 'river') { ambFilter.type = 'bandpass'; ambFilter.frequency.value = 5200; ambFilter.Q.value = 0.2; ambGain.gain.value = 0.03; }
|
||||
else return;
|
||||
ambSrc.connect(ambFilter); ambFilter.connect(ambGain); ambGain.connect(A.master);
|
||||
ambSrc.start();
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,247 @@
|
||||
/* =========================================================================
|
||||
Martial arts: 40+ techniques, tags, passives, and COMBINATION system
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const A = {};
|
||||
/* schema: cmb = combat technique {qi,cd,rng,aoe,pow,kind,fx,fxT,eff,self}
|
||||
pas = passive bonuses curse = forbidden drawback
|
||||
stat = which skill it trains, learn = difficulty (vs intellect) */
|
||||
function art(id, n, cn, cat, tier, tags, stat, learn, d, pas, cmb, opt) {
|
||||
A[id] = Object.assign({ id, n, cn, cat, tier, tags, stat, learn, d, pas: pas || {}, cmb: cmb || null }, opt || {});
|
||||
}
|
||||
|
||||
/* ---------------- EXTERNAL ---------------- */
|
||||
art('plum_fist', 'Plum Blossom Fist', '梅花拳', 'external', 1, ['fist'], 'unarmed', 6,
|
||||
'A village fist style. Honest, plain, and where every legend begins.', { atk: 2 },
|
||||
{ qi: 0, cd: 0, rng: 1, pow: 1.25, kind: 'phys', fx: 'fist', fxT: 1 });
|
||||
art('basic_sword', 'Basic Sword Arts', '基础剑法', 'external', 1, ['sword'], 'sword', 8,
|
||||
'Thirteen forms of orthodox swordwork. The root of ten thousand styles.', { atk: 3 },
|
||||
{ qi: 0, cd: 0, rng: 1, pow: 1.35, kind: 'phys', fx: 'slash', fxT: 1 });
|
||||
art('iron_palm', 'Iron Palm', '铁掌', 'external', 2, ['palm', 'iron', 'yang'], 'unarmed', 16,
|
||||
'Years of striking sand and iron harden the palms into hammers.', { atk: 4, def: 1 },
|
||||
{ qi: 8, cd: 1, rng: 1, pow: 1.65, kind: 'phys', fx: 'palm', fxT: 2, eff: [{ k: 'stun', ch: 0.25, dur: 1 }] });
|
||||
art('tiger_claw', 'Tiger Claw', '虎爪手', 'external', 3, ['claw'], 'claw', 22,
|
||||
'Rends through guard and flesh alike, leaving wounds that will not close.', { atk: 5 },
|
||||
{ qi: 10, cd: 1, rng: 1, pow: 1.8, kind: 'phys', fx: 'claw', fxT: 2, eff: [{ k: 'bleed', ch: 0.45, dur: 3, val: 4 }] });
|
||||
art('wind_saber', 'Wind Saber Arts', '狂风刀法', 'external', 2, ['saber', 'wind'], 'saber', 14,
|
||||
'A saber drawn like a gust — by the time you hear it, the cut is done.', { atk: 3, spd: 1 },
|
||||
{ qi: 7, cd: 1, rng: 1, pow: 1.55, kind: 'phys', fx: 'slash', fxT: 2, eff: [{ k: 'bleed', ch: 0.3, dur: 2, val: 3 }] });
|
||||
art('mountain_cleaver', 'Mountain-Cleaving Saber', '开山刀法', 'external', 4, ['saber', 'iron'], 'saber', 34,
|
||||
'One heavy cut, said to split a gate beam in the old wars.', { atk: 7 },
|
||||
{ qi: 16, cd: 2, rng: 1, pow: 2.25, kind: 'phys', fx: 'slash', fxT: 3, eff: [{ k: 'stun', ch: 0.2, dur: 1 }] });
|
||||
art('iron_body', 'Golden Bell Body', '金钟罩', 'external', 2, ['iron', 'yang'], 'unarmed', 18,
|
||||
'Breath and muscle tempered until blades ring against you like a bell.', { def: 8, hp: 20 },
|
||||
{ qi: 8, cd: 2, rng: 0, pow: 0, kind: 'buff', fx: 'buff', fxT: 1, eff: [{ k: 'defUp', ch: 1, dur: 3, val: 8 }] });
|
||||
art('bajiquan', 'Baji Fist', '八极拳', 'external', 3, ['fist', 'iron'], 'unarmed', 24,
|
||||
'Brutal close-range impacts. No flourish, only the sound of breaking.', { atk: 5, def: 2 },
|
||||
{ qi: 10, cd: 1, rng: 1, pow: 1.75, kind: 'phys', fx: 'fist', fxT: 2, eff: [{ k: 'stun', ch: 0.3, dur: 1 }] });
|
||||
art('drunken_fist', 'Drunken Fist', '醉拳', 'external', 3, ['fist', 'drunken'], 'unarmed', 26,
|
||||
'Staggering, laughing, falling — and impossible to hit.', { atk: 4, dodge: 0.06 },
|
||||
{ qi: 9, cd: 1, rng: 1, pow: 1.55, kind: 'phys', fx: 'drunken', fxT: 2, eff: [{ k: 'stun', ch: 0.35, dur: 1 }] });
|
||||
art('spear_yang', 'Yang Family Spear', '杨家枪', 'external', 3, ['spear'], 'spear', 22,
|
||||
'The spear arts of a general\'s house. Reach beyond the blade.', { atk: 5 },
|
||||
{ qi: 9, cd: 1, rng: 2, pow: 1.8, kind: 'phys', fx: 'spear', fxT: 2 });
|
||||
art('staff_dog', 'Dog-Beating Staff', '打狗棒法', 'external', 3, ['staff'], 'staff', 26,
|
||||
'The Beggar Sect\'s crown technique. Thirty-six ways to humble the proud.', { atk: 4, def: 2 },
|
||||
{ qi: 10, cd: 1, rng: 2, pow: 1.6, kind: 'phys', fx: 'staff', fxT: 2, eff: [{ k: 'root', ch: 0.35, dur: 1 }] });
|
||||
art('flowing_sword', 'Flowing Cloud Sword', '流云剑法', 'external', 3, ['sword', 'water', 'wind'], 'sword', 28,
|
||||
'The blade moves like cloud over water — soft, then sudden as a storm.', { atk: 5, spd: 1 },
|
||||
{ qi: 11, cd: 1, rng: 1, pow: 1.8, kind: 'phys', fx: 'slash', fxT: 2, eff: [{ k: 'bleed', ch: 0.35, dur: 2, val: 4 }] });
|
||||
art('heart_sword', 'Heart Sword Intent', '心剑诀', 'external', 5, ['sword', 'qi'], 'sword', 48,
|
||||
'The sword is no longer in the hand. It is already at your throat.', { atk: 8, crit: 0.05 },
|
||||
{ qi: 20, cd: 2, rng: 2, pow: 2.6, kind: 'qi', fx: 'slash', fxT: 3, pierce: true });
|
||||
art('hidden_knife', 'Flying Dagger Arts', '飞刀绝技', 'hidden', 3, ['hidden'], 'hidden', 26,
|
||||
'A plain little dagger, thrown once, without mercy. 例不虚发.', { atk: 4, crit: 0.08 },
|
||||
{ qi: 10, cd: 1, rng: 4, pow: 1.9, kind: 'phys', fx: 'needle', fxT: 2, crit: 0.2 });
|
||||
art('poison_needle', 'Poison Needles', '淬毒银针', 'hidden', 2, ['hidden', 'needle', 'poison'], 'hidden', 16,
|
||||
'Needles quenched in snake-venom and hate. A scratch is enough.', { atk: 2, crit: 0.04 },
|
||||
{ qi: 6, cd: 1, rng: 3, pow: 1.15, kind: 'phys', fx: 'needle', fxT: 1, eff: [{ k: 'poison', ch: 0.65, dur: 4, val: 4 }] });
|
||||
art('sleeve_arrow', 'Sleeve Arrows', '袖箭', 'hidden', 1, ['hidden'], 'hidden', 10,
|
||||
'A spring-loaded dart hidden in the sleeve. Cowardly — and effective.', { atk: 2 },
|
||||
{ qi: 4, cd: 1, rng: 3, pow: 1.25, kind: 'phys', fx: 'needle', fxT: 1 });
|
||||
art('rain_needle', 'Rain of Flying Flowers', '雨打飞花针', 'hidden', 4, ['hidden', 'needle', 'poison'], 'hidden', 40,
|
||||
'A handful of needles scattered like blossom in the wind.', { atk: 5, crit: 0.06 },
|
||||
{ qi: 16, cd: 2, rng: 4, aoe: 1, pow: 1.5, kind: 'phys', fx: 'needle', fxT: 3, eff: [{ k: 'poison', ch: 0.5, dur: 3, val: 5 }] });
|
||||
|
||||
/* ---------------- INTERNAL ---------------- */
|
||||
art('qi_circ', 'Qi Circulation', '吐纳术', 'internal', 1, ['qi'], 'internal', 6,
|
||||
'First breathing method of every school: draw the breath, guide the blood.', { qi: 15, qreg: 2 },
|
||||
{ qi: 0, cd: 2, rng: 0, pow: 0.9, kind: 'heal', fx: 'heal', fxT: 1 });
|
||||
art('yang_art', 'Pure Yang Art', '纯阳功', 'internal', 2, ['yang', 'qi'], 'internal', 16,
|
||||
'Yang qi floods the meridians; the body warms like a furnace in snow.', { hp: 25, atk: 3 },
|
||||
{ qi: 8, cd: 2, rng: 0, pow: 0, kind: 'buff', fx: 'buff', fxT: 1, eff: [{ k: 'atkUp', ch: 1, dur: 3, val: 6 }] });
|
||||
art('yin_art', 'Mysterious Yin Art', '玄阴功', 'internal', 2, ['yin', 'qi'], 'internal', 16,
|
||||
'Cold, quiet power that gathers in the bones like frost.', { qi: 20, atk: 2 },
|
||||
{ qi: 8, cd: 2, rng: 2, pow: 1.3, kind: 'qi', fx: 'drain', fxT: 1, eff: [{ k: 'slow', ch: 0.3, dur: 2 }] });
|
||||
art('heal_qi', 'Springtime Healing Qi', '回春诀', 'internal', 2, ['heal', 'qi'], 'internal', 18,
|
||||
'Gentle qi that knits flesh and settles blood. Every sect wants a healer.', { qi: 12, qreg: 1 },
|
||||
{ qi: 14, cd: 1, rng: 3, pow: 2.0, kind: 'heal', fx: 'heal', fxT: 2 });
|
||||
art('turtle_breath', 'Turtle Breath', '龟息功', 'internal', 2, ['turtle', 'qi'], 'internal', 20,
|
||||
'Slow as a tortoise, patient as a mountain. Wounds close on their own.', { hp: 30, qreg: 2, dodge: 0.02 },
|
||||
{ qi: 0, cd: 3, rng: 0, pow: 0.6, kind: 'heal', fx: 'heal', fxT: 1 });
|
||||
art('thunder_qi', 'Thunder Refinement', '雷音淬体', 'internal', 3, ['lightning', 'qi'], 'internal', 28,
|
||||
'Each breath cracks like dry thunder; the nerves fire faster than thought.', { atk: 4, spd: 1 },
|
||||
{ qi: 12, cd: 2, rng: 2, pow: 1.6, kind: 'qi', fx: 'thunder', fxT: 2, eff: [{ k: 'stun', ch: 0.25, dur: 1 }] });
|
||||
art('cold_qi', 'Cold Qi Art', '寒冰真气', 'internal', 3, ['cold', 'yin'], 'internal', 30,
|
||||
'Frost gathers where you point. Blood forgets how to flow.', { qi: 18, atk: 3 },
|
||||
{ qi: 13, cd: 1, rng: 3, pow: 1.5, kind: 'qi', fx: 'cold', fxT: 2, eff: [{ k: 'slow', ch: 0.6, dur: 2 }] });
|
||||
art('poison_qi', 'Rotting Bone Art', '腐骨毒功', 'internal', 3, ['poison', 'demonic'], 'internal', 30,
|
||||
'Turn your own qi to venom. The sects call it crooked practice.', { atk: 3, qi: 14 },
|
||||
{ qi: 12, cd: 2, rng: 3, aoe: 1, pow: 1.2, kind: 'qi', fx: 'poison', fxT: 2, eff: [{ k: 'poison', ch: 0.8, dur: 4, val: 6 }] });
|
||||
art('nine_yin', 'Nine Yin Scripture', '九阴真经', 'internal', 5, ['yin', 'qi'], 'internal', 55,
|
||||
'The lost classic of the old wars. Yin qi sharp as a winter moon.', { atk: 6, qi: 30, crit: 0.06 },
|
||||
{ qi: 18, cd: 2, rng: 2, pow: 2.4, kind: 'qi', fx: 'drain', fxT: 3, eff: [{ k: 'drain', ch: 1, dur: 0, val: 0.4 }] });
|
||||
art('nine_yang', 'Nine Yang Scripture', '九阳神功', 'internal', 5, ['yang', 'qi'], 'internal', 55,
|
||||
'A sun burning inside the body. Poison cannot touch it; wounds close fast.', { hp: 60, qreg: 5, def: 4 },
|
||||
{ qi: 16, cd: 2, rng: 0, aoe: 2, pow: 1.6, kind: 'heal', fx: 'heal', fxT: 3, cure: true });
|
||||
art('demonic_qi', 'Heart-Devouring Art', '噬心功', 'internal', 4, ['demonic', 'yin'], 'internal', 40,
|
||||
'Feed on the qi of others. Your own heart is the price.', { atk: 6, lifesteal: 0.15, qi: 16 },
|
||||
{ qi: 12, cd: 1, rng: 2, pow: 1.9, kind: 'qi', fx: 'drain', fxT: 2, eff: [{ k: 'drain', ch: 1, dur: 0, val: 0.5 }] },
|
||||
{ curse: { hpPerTurn: 2, txt: 'The art feeds on its wielder.' } });
|
||||
art('blood_demon', 'Blood Demon Art', '血魔功', 'forbidden', 5, ['blood', 'demonic'], 'internal', 60,
|
||||
'A forbidden classic of the Demon Cult. Strength bought with blood.', { atk: 10, spd: 2, lifesteal: 0.25 },
|
||||
{ qi: 14, cd: 1, rng: 1, pow: 2.5, kind: 'phys', fx: 'blood', fxT: 3, eff: [{ k: 'bleed', ch: 0.6, dur: 3, val: 6 }] },
|
||||
{ curse: { hpPerTurn: 4, txt: 'Your blood boils. Every battle takes its toll.' } });
|
||||
art('soul_devour', 'Soul-Devouring Scripture', '摄魂大法', 'forbidden', 5, ['soul', 'demonic'], 'internal', 58,
|
||||
'The eyes alone terrify. Weak-willed men drop their blades and kneel.', { atk: 7, crit: 0.08, qi: 24 },
|
||||
{ qi: 18, cd: 2, rng: 3, aoe: 1, pow: 1.8, kind: 'qi', fx: 'drain', fxT: 3, eff: [{ k: 'fear', ch: 0.55, dur: 2 }, { k: 'drain', ch: 1, dur: 0, val: 0.5 }] },
|
||||
{ curse: { hpPerTurn: 2, deviation: 0.03, txt: 'Whispers gather at the edge of your mind.' } });
|
||||
art('ashura_palm', 'Ashura World-Ending Palm', '修罗灭世掌', 'forbidden', 5, ['palm', 'demonic'], 'unarmed', 62,
|
||||
'Sixty-four palms of the war god. The ground remembers each one.', { atk: 9, def: 2 },
|
||||
{ qi: 22, cd: 2, rng: 1, aoe: 1, pow: 3.0, kind: 'phys', fx: 'palm', fxT: 3, eff: [{ k: 'stun', ch: 0.35, dur: 1 }] },
|
||||
{ curse: { hpPerTurn: 5, morale: true, txt: 'The Asura wakes when you fight. It is hard to send back.' } });
|
||||
art('severed_heaven', 'Heaven-Severing Sword', '断天剑诀', 'forbidden', 5, ['sword', 'demonic'], 'sword', 66,
|
||||
'A dead master\'s final work: seven strokes, each a funeral.', { atk: 11, crit: 0.1 },
|
||||
{ qi: 24, cd: 2, rng: 2, pow: 3.4, kind: 'qi', fx: 'slash', fxT: 3, pierce: true, eff: [{ k: 'bleed', ch: 0.7, dur: 3, val: 8 }] },
|
||||
{ curse: { hpPerTurn: 4, deviation: 0.04, txt: 'The sword dreams of cutting heaven. It practices on you.' } });
|
||||
|
||||
/* ---------------- LIGHTNESS ---------------- */
|
||||
art('cloud_step', 'Cloud Step', '云端步', 'lightness', 2, ['step', 'cloud'], 'lightness', 14,
|
||||
'Footwork soft as cloud. You seem to drift rather than walk.', { spd: 3, dodge: 0.03 });
|
||||
art('water_walk', 'Water-Walking Skill', '踏浪行', 'lightness', 2, ['step', 'water'], 'lightness', 18,
|
||||
'The old monks cross rivers on a reed. You settle for the surface.', { spd: 2, dodge: 0.02 });
|
||||
art('shadow_step', 'Shadow Step', '影袭步', 'lightness', 3, ['step', 'shadow'], 'lightness', 26,
|
||||
'Move inside the blind spot of a man\'s own shadow.', { spd: 4, crit: 0.05 });
|
||||
art('flying_swallow', 'Flying Swallow Skill', '飞燕诀', 'lightness', 3, ['step', 'swallow'], 'lightness', 28,
|
||||
'Roof-tiles and swallows. The guards never even look up.', { spd: 5, dodge: 0.05 });
|
||||
art('lightning_step', 'Lightning Step', '雷电步', 'lightness', 4, ['step', 'lightning'], 'lightness', 40,
|
||||
'Three paces become one. The eye cannot follow; the thunder arrives late.', { spd: 6, dodge: 0.04 });
|
||||
|
||||
/* combo-only ultimates */
|
||||
function ult(id, n, cn, d, cmb) { A[id] = Object.assign({ id, n, cn, cat: 'ultimate', tier: 5, tags: [], stat: null, learn: 0, d, pas: {} }, { cmb }); }
|
||||
ult('ult_drunken_thunder', 'Drunken Thunder Falls', '醉雷崩拳', 'Iron Palm + Lightning Step + Drunken Fist: a staggering, roaring storm of blows.', { qi: 25, cd: 4, rng: 1, aoe: 1, pow: 2.8, kind: 'phys', fx: 'thunder', fxT: 3, eff: [{ k: 'stun', ch: 0.6, dur: 1 }] });
|
||||
ult('ult_phantom', 'Nine Yin Phantom', '九阴幽影', 'Strike from nine shadows at once; each wound festers.', { qi: 22, cd: 4, rng: 1, pow: 2.6, kind: 'qi', fx: 'shadow', fxT: 3, eff: [{ k: 'poison', ch: 1, dur: 4, val: 8 }, { k: 'drain', ch: 1, dur: 0, val: 0.6 }] });
|
||||
ult('ult_vajra', 'Vajra Tiger Roar', '金刚虎煞', 'A roar that stills the battlefield, then the claws fall.', { qi: 24, cd: 4, rng: 1, aoe: 1, pow: 2.7, kind: 'phys', fx: 'palm', fxT: 3, eff: [{ k: 'stun', ch: 0.4, dur: 1 }] });
|
||||
ult('ult_cold_domain', 'Cold Cloud Sword Domain', '寒云剑域', 'Snow falls upward. Everything the cloud touches is cut.', { qi: 26, cd: 4, rng: 3, aoe: 2, pow: 2.2, kind: 'qi', fx: 'cold', fxT: 3, eff: [{ k: 'slow', ch: 0.8, dur: 3 }] });
|
||||
ult('ult_blood_asura', 'Blood Asura Descends', '血修罗临世', 'The Asura wears your skin for one breath. Nothing in reach survives it.', { qi: 30, cd: 5, rng: 1, aoe: 1, pow: 3.6, kind: 'phys', fx: 'blood', fxT: 3, lifesteal: 0.5 });
|
||||
ult('ult_swallow', 'Swallow Skewers the Storm', '燕雷刺', 'One step, one flash, one dagger between the ribs.', { qi: 20, cd: 4, rng: 4, pow: 2.9, kind: 'phys', fx: 'needle', fxT: 3, crit: 0.5 });
|
||||
ult('ult_thunder_spear', 'Thunder Spear: Sky-Rending', '惊雷枪·裂空', 'The spear becomes the lightning bolt it was named for.', { qi: 24, cd: 4, rng: 3, aoe: 1, pow: 2.9, kind: 'phys', fx: 'thunder', fxT: 3, pierce: true, eff: [{ k: 'stun', ch: 0.45, dur: 1 }] });
|
||||
ult('ult_dog_staff', 'King of Beggars\' 36 Staff', '丐王三十六棒', 'Thirty-six strikes in the time of one breath, each one a joke at your expense.', { qi: 22, cd: 4, rng: 2, pow: 2.5, kind: 'phys', fx: 'staff', fxT: 3, eff: [{ k: 'root', ch: 0.6, dur: 1 }, { k: 'stun', ch: 0.3, dur: 1 }] });
|
||||
ult('ult_heart_shadow', 'Heart Sword: Silent Verdict', '心剑·无声判', 'The verdict falls before the blade is seen.', { qi: 26, cd: 4, rng: 2, pow: 3.2, kind: 'qi', fx: 'slash', fxT: 3, pierce: true, crit: 0.35 });
|
||||
|
||||
W.ARTS = A;
|
||||
W.artById = id => A[id];
|
||||
|
||||
/* ---------------- COMBINATION SYSTEM ----------------
|
||||
A combo is active when ALL of its required arts are EQUIPPED (max 4 slots). */
|
||||
W.COMBOS = [
|
||||
{
|
||||
id: 'qi_fist', n: 'Qi-Tempered Fist', cn: '炼气拳', tier: 1,
|
||||
need: ['plum_fist', 'qi_circ'],
|
||||
d: 'Breath behind every blow. A humble style, sharpened.',
|
||||
bonus: { atk: 3, qi: 10 }, unstable: null, ult: null,
|
||||
},
|
||||
{
|
||||
id: 'drunken_thunder', n: 'Drunken Thunder Fist', cn: '醉雷崩拳', tier: 4,
|
||||
need: ['iron_palm', 'lightning_step', 'drunken_fist'],
|
||||
d: 'A staggering drunk whose palms land like thunderbolts. Impossible to read, worse to block.',
|
||||
bonus: { atk: 8, crit: 0.08, dodge: 0.04 }, unstable: null, ult: 'ult_drunken_thunder',
|
||||
},
|
||||
{
|
||||
id: 'nine_yin_phantom', n: 'Nine Yin Phantom', cn: '九阴幽影', tier: 4,
|
||||
need: ['nine_yin', 'shadow_step', 'poison_needle'],
|
||||
d: 'Nine shadows, nine needles, one scripture. The victim dies of cold and venom, unsure which struck first.',
|
||||
bonus: { crit: 0.12, spd: 2, atk: 3 }, unstable: { ch: 0.2, hp: 4, qi: 6, txt: 'Yin qi gnaws your meridians' }, ult: 'ult_phantom',
|
||||
},
|
||||
{
|
||||
id: 'vajra_tiger', n: 'Vajra Tiger', cn: '金刚虎煞', tier: 3,
|
||||
need: ['iron_body', 'tiger_claw', 'yang_art'],
|
||||
d: 'A walking fortress with the heart of a tiger. Slow, inevitable, terrifying.',
|
||||
bonus: { atk: 8, def: 8, hp: 30, spd: -1 }, unstable: null, ult: 'ult_vajra',
|
||||
},
|
||||
{
|
||||
id: 'cold_cloud', n: 'Cold Cloud Sword Domain', cn: '寒云剑域', tier: 4,
|
||||
need: ['flowing_sword', 'cloud_step', 'cold_qi'],
|
||||
d: 'Your sword domain carries snow. Foes within it grow slow, then still.',
|
||||
bonus: { atk: 5, spd: 2 }, unstable: null, ult: 'ult_cold_domain',
|
||||
},
|
||||
{
|
||||
id: 'blood_asura', n: 'Blood Asura', cn: '血修罗', tier: 5,
|
||||
need: ['blood_demon', 'ashura_palm', 'shadow_step'],
|
||||
d: 'The forbidden path entire. The jianghu will speak your name in whispers — if you keep your mind.',
|
||||
bonus: { atk: 14, lifesteal: 0.15, spd: 2 },
|
||||
unstable: { ch: 0.3, hp: 8, qi: 8, deviation: 0.05, txt: 'The Asura strains at its chains' }, ult: 'ult_blood_asura',
|
||||
},
|
||||
{
|
||||
id: 'undying_golden', n: 'Undying Vajra Body', cn: '不灭金身', tier: 3,
|
||||
need: ['heal_qi', 'turtle_breath', 'iron_body'],
|
||||
d: 'Wounds close as they open. Sieges end before you do.',
|
||||
bonus: { def: 6, hp: 40, regen: 4 }, unstable: null, ult: null,
|
||||
},
|
||||
{
|
||||
id: 'yin_yang', n: 'Yin-Yang Harmony', cn: '阴阳双济', tier: 5,
|
||||
need: ['nine_yin', 'nine_yang'],
|
||||
d: 'The two supreme scriptures reconcile in one body. Instability itself is harmonized.',
|
||||
bonus: { atk: 6, qi: 40, qreg: 6, hp: 30 }, harmony: true, unstable: null, ult: null,
|
||||
},
|
||||
{
|
||||
id: 'swallow_thunder', n: 'Swallow-Thunder Strike', cn: '燕雷刺', tier: 4,
|
||||
need: ['lightning_step', 'flying_swallow', 'hidden_knife'],
|
||||
d: 'Assassination perfected: arrive with the thunder, leave with the swallow.',
|
||||
bonus: { spd: 8, crit: 0.15 }, unstable: null, ult: 'ult_swallow',
|
||||
},
|
||||
{
|
||||
id: 'thunder_spear', n: 'Thunder Spear', cn: '惊雷枪', tier: 3,
|
||||
need: ['spear_yang', 'thunder_qi'],
|
||||
d: 'Every thrust carries a thunderclap. Ranks break at the first one.',
|
||||
bonus: { atk: 6 }, unstable: null, ult: 'ult_thunder_spear',
|
||||
},
|
||||
{
|
||||
id: 'beggar_king', n: 'King of Beggars', cn: '丐帮双绝', tier: 3,
|
||||
need: ['staff_dog', 'drunken_fist'],
|
||||
d: 'The Beggar Sect\'s two jewels in one body. Wine gourd optional but traditional.',
|
||||
bonus: { def: 4, spd: 2, atk: 3 }, unstable: null, ult: 'ult_dog_staff',
|
||||
},
|
||||
{
|
||||
id: 'heart_shadow', n: 'Heart-Sword Shadow Kill', cn: '心剑影杀', tier: 4,
|
||||
need: ['heart_sword', 'shadow_step'],
|
||||
d: 'The sword arrives before the thought of drawing it. There is no defense against a verdict.',
|
||||
bonus: { atk: 7, crit: 0.1, spd: 2 }, unstable: null, ult: 'ult_heart_shadow',
|
||||
},
|
||||
{
|
||||
id: 'rotting_plague', n: 'Rotting Plague', cn: '腐骨毒瘴', tier: 3,
|
||||
need: ['poison_qi', 'poison_needle'],
|
||||
d: 'Where you stand, the air turns green and things do not heal.',
|
||||
bonus: { atk: 4, poisonAll: true }, unstable: { ch: 0.12, hp: 3, qi: 4, txt: 'Your own venom seeps inward' }, ult: null,
|
||||
},
|
||||
];
|
||||
|
||||
W.REALMS = [
|
||||
{ n: 'Mortal', cn: '凡人', need: 0 },
|
||||
{ n: 'Qi Gathering', cn: '聚气', need: 60 },
|
||||
{ n: 'Meridian Opening', cn: '通脉', need: 160 },
|
||||
{ n: 'Foundation', cn: '筑基', need: 320 },
|
||||
{ n: 'Inner Master', cn: '内宗', need: 560 },
|
||||
{ n: 'Grandmaster', cn: '宗师', need: 900 },
|
||||
{ n: 'Martial Saint', cn: '武圣', need: 1400 },
|
||||
{ n: 'Legend', cn: '传说', need: 2100 },
|
||||
];
|
||||
|
||||
/* -------- helpers -------- */
|
||||
W.combosFor = function (equippedIds) {
|
||||
const set = new Set(equippedIds);
|
||||
return W.COMBOS.filter(c => c.need.every(a => set.has(a)));
|
||||
};
|
||||
W.unstableCombos = function (combos) { return combos.filter(c => c.unstable && !(combos.some(x => x.harmony))); };
|
||||
W.equipLimit = () => 4;
|
||||
})();
|
||||
@@ -0,0 +1,270 @@
|
||||
/* =========================================================================
|
||||
World content: backgrounds, factions, items, buildings, enemies,
|
||||
traits, hidden identities, locations, themes, weather
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const M = {};
|
||||
|
||||
/* ---------------- player backgrounds ---------------- */
|
||||
M.BACKGROUNDS = [
|
||||
{ id: 'orphan', n: 'Street Orphan', cn: '孤儿', d: 'Raised in the gutters of Qinghe Town. The beggars taught you to watch, the streets taught you to strike.',
|
||||
mods: { martial: 6, charm: -2, spirit: 4 }, arts: ['plum_fist'], gold: 40, res: {}, trait: 'streetwise', fame: 0, rels: { beggar: 10 } },
|
||||
{ id: 'noble', n: 'Fallen Noble', cn: '落魄世家', d: 'Your family estate burned in a night. You kept the sword and the name, nothing else.',
|
||||
mods: { intellect: 8, leadership: 6, martial: -2 }, arts: ['basic_sword'], gold: 160, res: { wood: 10 }, trait: 'proud', fame: 5, rels: { orthodox: 10, imperial: 5 } },
|
||||
{ id: 'soldier', n: 'Old Soldier', cn: '老兵', d: 'Twelve years on the frontier. You left when the orders stopped making sense.',
|
||||
mods: { martial: 8, leadership: 4, spirit: 3 }, arts: ['spear_yang'], gold: 90, res: { iron: 10 }, trait: 'stoic', fame: 3, rels: { imperial: 15, bandit: -20 } },
|
||||
{ id: 'physician', n: 'Wandering Physician', cn: '游方郎中', d: 'A medicine chest, a steady hand, and villages that remember kindness.',
|
||||
mods: { medicine: 14, intellect: 5, martial: -3 }, arts: ['heal_qi'], gold: 110, res: { medicine: 12 }, trait: 'kind', fame: 2, rels: {} },
|
||||
{ id: 'beggar', n: 'Beggar Sect Disciple', cn: '丐帮弟子', d: 'The sect of the begging bowl knows everything worth knowing — for a price.',
|
||||
mods: { martial: 4, charm: 6, spirit: 2 }, arts: ['staff_dog'], gold: 50, res: { food: 10 }, trait: 'cunning', fame: 1, rels: { beggar: 25 } },
|
||||
{ id: 'disciple', n: 'Heir of a Ruined Sect', cn: '没落弟子', d: 'Your master died defending the gate. A handful of survivors still call you "Senior".',
|
||||
mods: { martial: 7, spirit: 6 }, arts: ['basic_sword', 'qi_circ'], gold: 80, res: { wood: 15 }, trait: 'righteous', fame: 4, rels: { orthodox: 15 } },
|
||||
{ id: 'assassin', n: 'Retired Assassin', cn: '退役杀手', d: 'Shadow Tower says you are dead. Keep it that way.',
|
||||
mods: { martial: 9, intellect: 4, charm: -4 }, arts: ['poison_needle', 'shadow_step'], gold: 130, res: { medicine: 4 }, trait: 'secretive', fame: 0, rels: { shadow: -10, imperial: -10 } },
|
||||
{ id: 'merchant', n: 'Merchant Apprentice', cn: '行商学徒', d: 'You can price a sword, a life, and a lie within a breath of each other.',
|
||||
mods: { intellect: 7, charm: 7, martial: -4 }, arts: ['sleeve_arrow'], gold: 220, res: { food: 15 }, trait: 'shrewd', fame: 1, rels: { guild: 20 } },
|
||||
{ id: 'scholar', n: 'Failed Scholar', cn: '落第书生', d: 'Three times you failed the imperial exam. The fourth time, you read something else entirely.',
|
||||
mods: { intellect: 12, medicine: 4, martial: -5 }, arts: ['qi_circ'], gold: 100, res: { wood: 5 }, trait: 'scholarly', fame: 0, rels: { imperial: -5, taiyi: 10 } },
|
||||
{ id: 'exiled', n: 'Exiled Senior Disciple', cn: '被逐高徒', d: 'They cast you out for reading what was locked away. Your cultivation was crippled — it is healing.',
|
||||
mods: { martial: 5, spirit: 9, intellect: 5 }, arts: ['yin_art'], gold: 70, res: {}, trait: 'vengeful', fame: 3, rels: { orthodox: -15 } },
|
||||
];
|
||||
|
||||
/* ---------------- jianghu factions ---------------- */
|
||||
M.FACTIONS = {
|
||||
orthodox: { n: 'Orthodox Alliance', cn: '正道盟', color: '#3d6b8f', ideology: 'Order, orthodoxy, the suppression of crooked arts.', leader: 'Alliance Chief Yue Songfeng',
|
||||
d: 'An alliance of respectable sects. They protect the jianghu — and decide who counts as respectable.' },
|
||||
demon: { n: 'Demon Cult', cn: '魔教', color: '#7d2a4a', ideology: 'Freedom through power; forbidden arts without shame.', leader: 'Cult Master Mo Yanye',
|
||||
d: 'The old enemy of the orthodox. Their arts are strong, their price is your soul.' },
|
||||
beggar: { n: 'Beggar Sect', cn: '丐帮', color: '#8a7a52', ideology: 'The streets belong to those who sleep on them.', leader: 'Elder Nine Flags',
|
||||
d: 'Beggars in every city, eyes in every alley. Information is their currency.' },
|
||||
tianlong: { n: 'Tianlong Monastery', cn: '天龙寺', color: '#a3622e', ideology: 'Compassion, discipline, and fists like temple bells.', leader: 'Abbot Kongwen',
|
||||
d: 'Warrior monks of the mountain monastery. Slow to anger, impossible to move.' },
|
||||
taiyi: { n: 'Taiyi Daoist Sect', cn: '太一道宗', color: '#4a7d64', ideology: 'The sword as brushstroke; the Dao as ink.', leader: 'Sword Immortal Qingxu',
|
||||
d: 'Daoists of the high peaks. Their swordsmanship is said to be half meditation.' },
|
||||
shadow: { n: 'Shadow Tower', cn: '暗影楼', color: '#55486b', ideology: 'Everything has a price. Everything has an exit.', leader: 'Tower Mistress Yan Shisan',
|
||||
d: 'Assassins, poisoners, information brokers. Officially, they do not exist.' },
|
||||
imperial: { n: 'Imperial Court', cn: '朝廷', color: '#8f6a2f', ideology: 'The law of the dragon throne reaches every river.', leader: 'Commandant Wei Zhong',
|
||||
d: 'Soldiers and censors. The court tolerates the jianghu until it does not.' },
|
||||
bandit: { n: 'Blackwind Alliance', cn: '黑风寨', color: '#6b4a3d', ideology: 'The mountain road has a toll. Pay it.', leader: 'King of Blackwind, Hu Laoba',
|
||||
d: 'Bandits, smugglers and deserters. Weak one at a time, endless in numbers.' },
|
||||
guild: { n: 'River Merchant Guild', cn: '漕帮商会', color: '#2f6b5e', ideology: 'Rivers carry boats; boats carry profit.', leader: 'Guildmaster Bai Ruyi',
|
||||
d: 'They own the docks, the caravans and most of the debts. Money is their martial art.' },
|
||||
};
|
||||
|
||||
/* ---------------- items ---------------- */
|
||||
const I = {};
|
||||
function item(id, n, cn, type, d, eff) { I[id] = Object.assign({ id, n, cn, type, d }, eff || {}); }
|
||||
item('medicine', 'Herbal Medicine', '草药', 'use', 'Field dressings and bitter decoctions. Restores 35 health.', { heal: 35 });
|
||||
item('great_medicine', 'Century Ginseng Paste', '百年参膏', 'use', 'A paste of mountain ginseng. Restores 120 health.', { heal: 120 });
|
||||
item('antidote', 'Jade Antidote Pill', '玉露解毒丹', 'use', 'Cures poison and venom.', { cure: true });
|
||||
item('qi_pill', 'Qi Gathering Pill', '聚气丹', 'use', 'Refined qi in a pill. +30 inner force.', { inner: 30 });
|
||||
item('breakthrough_pill', 'Marrow-Cleansing Pill', '洗髓丹', 'use', 'Steadies the meridians before a breakthrough. Greatly aids the attempt.', { breakAid: true });
|
||||
item('herb_rare', 'Snow Lotus', '雪莲', 'material', 'A rare herb prized by physicians and alchemists. Sells high.', { value: 60 });
|
||||
item('herb_spirit', 'Spirit Herb', '灵草', 'material', 'Faintly luminous. Alchemists will pay well.', { value: 40 });
|
||||
item('iron_sword', 'Iron Sword', '铁剑', 'weapon', 'A plain, honest blade. +4 attack.', { atk: 4, value: 40 });
|
||||
item('fine_saber', 'Ring-Pommel Saber', '环首刀', 'weapon', 'Military issue, well balanced. +7 attack.', { atk: 7, value: 90 });
|
||||
item('green_jade_sword', 'Green Jade Sword', '青玉剑', 'weapon', 'The blade sings faintly. +11 attack.', { atk: 11, value: 220 });
|
||||
item('war_spear', 'Dragon-Head Spear', '龙头枪', 'weapon', 'A general\'s spear. +12 attack.', { atk: 12, value: 240 });
|
||||
item('monk_staff', 'Iron-Head Monk Staff', '铁头禅杖', 'weapon', 'Heavy as sin. +10 attack.', { atk: 10, value: 200 });
|
||||
item('tiger_claws', 'Tiger Claw Gauntlets', '虎爪套', 'weapon', 'Steel talons over the knuckles. +9 attack.', { atk: 9, value: 180 });
|
||||
item('leather_armor', 'Oiled Leather Armor', '油皮甲', 'armor', 'Turns a shallow cut. +4 defense.', { def: 4, value: 70 });
|
||||
item('chain_mail', 'Mountain-Ring Mail', '山纹锁子甲', 'armor', 'Worn under the robe. +8 defense.', { def: 8, value: 170 });
|
||||
item('cloud_robe', 'Cloud-Pattern Robe', '云纹道袍', 'armor', 'Woven with silver thread. +6 defense, light.', { def: 6, spd: 1, value: 150 });
|
||||
item('jade_seal', 'Jade Seal of an Old Sect', '古派玉印', 'treasure', 'Proof of legitimacy from a vanished sect. Worth much — or more as a symbol.', { value: 200, fame: 6 });
|
||||
item('dragon_coin', 'Coin of the Dragon Vault', '龙纹古钱', 'treasure', 'Struck by a dynasty that no longer exists.', { value: 260 });
|
||||
item('ink_tome', 'Illuminated Manual Page', '残页', 'treasure', 'Half a page of calligraphy on cultivation. Scholars weep over less.', { value: 120, fame: 2 });
|
||||
item('wine_gourd', 'Wine Gourd', '酒葫芦', 'use', 'Strong sorghum wine. Restores morale and loosens tongues.', { morale: 8 });
|
||||
item('tea_cakes', 'Pressed Tea', '茶砖', 'trade', 'Traveler\'s tea. Everyone in the jianghu drinks it.', { value: 25 });
|
||||
item('map_fragment', 'Weathered Map Fragment', '残图', 'quest', 'A corner of some older map. Where is the rest?', { quest: true });
|
||||
M.ITEMS = I;
|
||||
|
||||
/* manual items (art manuals) generated procedurally in state.js */
|
||||
|
||||
/* ---------------- sect buildings ---------------- */
|
||||
M.BUILDINGS = [
|
||||
{ id: 'hall', n: 'Main Hall', cn: '大殿', cost: { wood: 30, gold: 40 }, days: 3, req: null,
|
||||
d: 'The heart of the sect. Ancestor tablets, meetings, judgments.', eff: { apMax: 1, discCap: 2 } },
|
||||
{ id: 'yard', n: 'Training Yard', cn: '演武场', cost: { wood: 40, iron: 10, gold: 30 }, days: 3, req: null,
|
||||
d: 'Wooden posts, sand pits, a bell for dawn practice.', eff: { trainMul: 0.25, apMax: 0 } },
|
||||
{ id: 'library', n: 'Scripture Library', cn: '藏经阁', cost: { wood: 45, gold: 70 }, days: 4, req: { hall: 1 },
|
||||
d: 'Shelves of manuals and marginalia. Arts are learned faster here.', eff: { learnMul: 0.35, discCap: 1 } },
|
||||
{ id: 'medhall', n: 'Medicine Hall', cn: '药堂', cost: { wood: 35, iron: 8, gold: 60 }, days: 3, req: { hall: 1 },
|
||||
d: 'Herb drawers, mortars, a physician\'s cot. Healing improves.', eff: { healMul: 0.3, medCraft: true } },
|
||||
{ id: 'forge', n: 'Forge', cn: '铁匠铺', cost: { wood: 25, iron: 25, gold: 80 }, days: 4, req: { hall: 1 },
|
||||
d: 'Bellows and anvils. Weapons can be reforged and armor fitted.', eff: { forge: true } },
|
||||
{ id: 'meditation', n: 'Meditation Chamber', cn: '静室', cost: { wood: 30, gold: 60 }, days: 3, req: { hall: 1 },
|
||||
d: 'A silent stone room where incense marks the hours.', eff: { medMul: 0.3, breakAid: 0.08 } },
|
||||
{ id: 'kitchen', n: 'Kitchen', cn: '膳堂', cost: { wood: 30, gold: 35 }, days: 2, req: null,
|
||||
d: 'Big pots, bigger appetites, and clever use of scraps.', eff: { foodSave: 0.2, moralePerDay: 0.3 } },
|
||||
{ id: 'dormitory', n: 'Disciples\' Quarters', cn: '弟子舍', cost: { wood: 45, gold: 30 }, days: 3, req: { hall: 1 },
|
||||
d: 'Warm beds mean loyal disciples.', eff: { discCap: 3, moralePerDay: 0.2 } },
|
||||
{ id: 'garden', n: 'Herb Garden', cn: '药圃', cost: { wood: 20, gold: 40 }, days: 3, req: null,
|
||||
d: 'Rows of medicinal herbs under straw mats.', eff: { medicinePerDay: 0.5 } },
|
||||
{ id: 'walls', n: 'Defensive Walls', cn: '寨墙', cost: { wood: 60, iron: 20, gold: 60 }, days: 5, req: { hall: 1 },
|
||||
d: 'Palisade and stone footing. Attackers regret the climb.', eff: { defense: 15 } },
|
||||
{ id: 'watchtower', n: 'Watchtower', cn: '哨塔', cost: { wood: 35, iron: 10, gold: 45 }, days: 3, req: { walls: 1 },
|
||||
d: 'See the raid before the raid sees you.', eff: { defense: 8, ambushGuard: true } },
|
||||
{ id: 'chamber', n: 'Secret Chamber', cn: '密室', cost: { wood: 30, iron: 15, gold: 100 }, days: 4, req: { hall: 1 },
|
||||
d: 'Behind the ancestor shelf. For texts the orthodox would burn.', eff: { forbidden: true } },
|
||||
];
|
||||
M.buildingById = id => M.BUILDINGS.find(b => b.id === id);
|
||||
|
||||
/* ---------------- personality traits ---------------- */
|
||||
M.TRAITS = {
|
||||
brave: { n: 'Brave', cn: '勇', d: 'First through the gate.' },
|
||||
cautious: { n: 'Cautious', cn: '慎', d: 'Counts exits before sitting down.' },
|
||||
proud: { n: 'Proud', cn: '傲', d: 'Never forgets a slight.' },
|
||||
kind: { n: 'Kind', cn: '仁', d: 'Feeds strays, human or otherwise.' },
|
||||
cruel: { n: 'Cruel', cn: '狠', d: 'Enjoys the work a little too much.' },
|
||||
loyal: { n: 'Loyal', cn: '义', d: 'Would follow into a burning building.' },
|
||||
ambitious: { n: 'Ambitious', cn: '野心', d: 'Studies your chair while bowing to you.' },
|
||||
lazy: { n: 'Lazy', cn: '懒', d: 'Trains when watched. Naps otherwise.' },
|
||||
zealous: { n: 'Zealous', cn: '痴', d: 'Practice is prayer.' },
|
||||
merciful: { n: 'Merciful', cn: '慈', d: 'Stay their hand at the killing blow.' },
|
||||
vengeful: { n: 'Vengeful', cn: '仇', d: 'Keeps a list. Checks it twice.' },
|
||||
scholarly: { n: 'Scholarly', cn: '文', d: 'Reads manuals like novels.' },
|
||||
gluttonous: { n: 'Gluttonous', cn: '馋', d: 'Eats for three. Cooks for five.' },
|
||||
stoic: { n: 'Stoic', cn: '忍', d: 'Pain is reported, not shown.' },
|
||||
flirtatious: { n: 'Flirtatious', cn: '俏', d: 'Compliments land like thrown knives.' },
|
||||
hot_headed: { n: 'Hot-headed', cn: '烈', d: 'Words first, thought later, apology never.' },
|
||||
secretive: { n: 'Secretive', cn: '隐', d: 'Has a past. Will not discuss it.' },
|
||||
righteous: { n: 'Righteous', cn: '正', d: 'Injustice is a personal invitation.' },
|
||||
cunning: { n: 'Cunning', cn: '狡', d: 'Wins fights before they start.' },
|
||||
gentle: { n: 'Gentle', cn: '雅', d: 'Soft-spoken until the sword speaks.' },
|
||||
streetwise: { n: 'Streetwise', cn: '滑', d: 'Knows every fence and back door.' },
|
||||
shrewd: { n: 'Shrewd', cn: '精', d: 'Smells profit in a rumor.' },
|
||||
};
|
||||
|
||||
/* ---------------- hidden identities for recruits ---------------- */
|
||||
M.HIDDEN = [
|
||||
{ id: 'grandmaster', n: 'a former Grandmaster', d: 'Crippled meridians conceal deep mastery.', reveal: 'Their form during crisis betrays decades of training.',
|
||||
eff: { martial: 22, spirit: 10 } },
|
||||
{ id: 'spy_demon', n: 'a Demon Cult plant', d: 'Sent to study your sect from inside.', reveal: 'A tattoo of the black lotus, low on the spine.',
|
||||
eff: { loyaltyDrain: true } },
|
||||
{ id: 'spy_imperial', n: 'a court informant', d: 'Paid by Commandant Wei for names and numbers.', reveal: 'Neat official cipher in their belongings.',
|
||||
eff: { intelImperial: true } },
|
||||
{ id: 'runaway', n: 'a runaway noble heir', d: 'Fled an arranged marriage and a cold house.', reveal: 'Calluses all wrong for a farmhand — a swordsman\'s hands.',
|
||||
eff: { intellect: 6, charm: 6 } },
|
||||
{ id: 'killer', n: 'a serial killer', d: 'Joined to hide among the righteous.', reveal: 'Goes missing on nights someone dies nearby.',
|
||||
eff: { moraleDrain: true } },
|
||||
{ id: 'orphan_heir', n: 'the last disciple of a ruined sect', d: 'Carries a manual they cannot read alone.', reveal: 'Recites forms from a dead tradition in their sleep.',
|
||||
eff: { bonusManual: true } },
|
||||
];
|
||||
|
||||
/* ---------------- names ---------------- */
|
||||
const surnames = ['Lin', 'Zhao', 'Han', 'Xiao', 'Mu', 'Yan', 'Gu', 'Shen', 'Lu', 'Wei', 'Qin', 'Chu', 'Bai', 'Su', 'Ye', 'Fang', 'Tang', 'Xue', 'Pei', 'Yun'];
|
||||
const givenM = ['Ming', 'Feng', 'Yuan', 'Kang', 'Zhi', 'Heng', 'Bo', 'Chuan', 'Dong', 'Lie', 'Shan', 'Tie', 'Wu', 'Xing', 'Yao', 'Zhong', 'Qi', 'Lei', 'Jun', 'Hei'];
|
||||
const givenF = ['Yue', 'Xue', 'Mei', 'Lian', 'Shuang', 'Ying', 'Zhen', 'Qiao', 'Nan', 'Ru', 'Xi', 'Luo', 'Wan', 'Qiu', 'Chan', 'Hong', 'Yu', 'Ling', 'Yao', 'Zhi'];
|
||||
M.genName = gender => {
|
||||
const giv = gender === 'f' ? givenF : givenM;
|
||||
return W.pick(surnames) + ' ' + W.pick(giv);
|
||||
};
|
||||
M.CN_SURNAMES = ['林', '赵', '韩', '萧', '穆', '严', '顾', '沈', '陆', '魏', '秦', '楚', '白', '苏', '叶', '方', '唐', '薛', '裴', '云'];
|
||||
M.cnName = () => W.pick(M.CN_SURNAMES) + W.pick(['明', '风', '岳', '雪', '霜', '燕', '真', '桥', '岚', '溪', '寒', '松', '远', '尘', '澜']);
|
||||
M.EPITHETS = ['the Quiet', 'the Unlucky', 'of the Nine Winds', 'Iron-Sleeve', 'the Laughing', 'Half-Moon', 'the Patient', 'Red-Tassel', 'the Wanderer', 'Cold-Eye', 'the Kind', 'Broken-Hilt'];
|
||||
|
||||
/* ---------------- enemies ---------------- */
|
||||
const ENEMIES = {};
|
||||
function foe(id, n, cn, tier, hp, atk, def, spd, arts, loot, opts) {
|
||||
ENEMIES[id] = Object.assign({ id, n, cn, tier, hp, atk, def, spd, arts: arts || [], loot: loot || {} }, opts || {});
|
||||
}
|
||||
foe('wolf', 'Grey Wolf', '灰狼', 1, 34, 8, 1, 5, [], { gold: [2, 8] }, { beast: true });
|
||||
foe('wolfpack', 'Starved Wolf', '饿狼', 1, 28, 7, 1, 6, [], { gold: [1, 5] }, { beast: true });
|
||||
foe('bandit', 'Bandit Scout', '黑风探子', 1, 42, 10, 2, 3, ['plum_fist'], { gold: [8, 20], items: ['tea_cakes'] });
|
||||
foe('bandit_vet', 'Bandit Veteran', '老匪', 2, 62, 14, 4, 3, ['wind_saber'], { gold: [15, 35] });
|
||||
foe('bandit_chief', 'Bandit Chief', '寨主', 3, 110, 19, 6, 4, ['mountain_cleaver'], { gold: [40, 90], items: ['fine_saber'] }, { boss: true });
|
||||
foe('assassin', 'Shadow Tower Blade', '暗影杀手', 3, 70, 18, 3, 7, ['poison_needle', 'shadow_step'], { gold: [30, 60], items: ['medicine'] });
|
||||
foe('cultist', 'Demon Cult Zealot', '魔教教众', 2, 58, 13, 3, 4, ['yin_art'], { gold: [12, 26] });
|
||||
foe('cult_adept', 'Cult Blood Adept', '血煞使者', 4, 105, 21, 5, 5, ['blood_demon'], { gold: [50, 110] }, { boss: false });
|
||||
foe('soldier', 'Imperial Soldier', '官军兵卒', 2, 66, 13, 5, 3, ['basic_sword'], { gold: [10, 24] });
|
||||
foe('officer', 'Imperial Officer', '军官', 4, 120, 20, 8, 4, ['spear_yang', 'iron_body'], { gold: [45, 95], items: ['chain_mail'] }, { boss: true });
|
||||
foe('rival_disciple', 'Rival Sect Disciple', '敌派弟子', 2, 60, 13, 4, 4, ['basic_sword'], { gold: [8, 18] });
|
||||
foe('rival_elder', 'Rival Sect Elder', '敌派长老', 4, 130, 22, 7, 4, ['flowing_sword', 'yang_art'], { gold: [60, 120], items: ['green_jade_sword'] }, { boss: true });
|
||||
foe('tomb_guard', 'Tomb Guardian', '墓守傀儡', 4, 150, 23, 9, 3, ['iron_body', 'bajiquan'], { gold: [70, 140], items: ['jade_seal'] }, { boss: true, undead: true });
|
||||
foe('master_foe', 'Wandering Master', '江湖高手', 5, 170, 27, 8, 6, ['heart_sword', 'lightning_step'], { gold: [90, 180] }, { boss: true });
|
||||
foe('warlord', 'Cult Warlord', '魔教法王', 6, 230, 30, 10, 5, ['ashura_palm', 'blood_demon'], { gold: [150, 300] }, { boss: true });
|
||||
|
||||
/* ---------------- location themes (render palettes) ---------------- */
|
||||
M.THEMES = {
|
||||
sect: { sky: ['#dfe6df', '#b9c7bb'], mtn: '#8fa393', ground: '#a8b494', groundAlt: '#93a07f', path: '#c2b08a', water: '#7f98a0', props: 'sect', fog: '#e8ece6' },
|
||||
town: { sky: ['#e6ddca', '#c9bd9e'], mtn: '#a89a7c', ground: '#b3a888', groundAlt: '#a2967a', path: '#c8b691', water: '#7f98a0', props: 'town', fog: '#e3dbc8' },
|
||||
village: { sky: ['#e3e0cd', '#c4c3a4'], mtn: '#9aa383', ground: '#aab68d', groundAlt: '#96a37b', path: '#c2b08a', water: '#84a0a4', props: 'village', fog: '#e5e4d2' },
|
||||
bamboo: { sky: ['#dde4d2', '#adc2a4'], mtn: '#7d997f', ground: '#94ab84', groundAlt: '#839972', path: '#b5ad85', water: '#7f98a0', props: 'bamboo', fog: '#d8e2cf' },
|
||||
temple: { sky: ['#e2dccd', '#c0b49a'], mtn: '#98876d', ground: '#a89f87', groundAlt: '#948c74', path: '#cfc0a0', water: '#7f98a0', props: 'temple', fog: '#e2dccd' },
|
||||
tomb: { sky: ['#d8d5cc', '#a8a49c'], mtn: '#7e7a72', ground: '#999488', groundAlt: '#878276', path: '#b0a894', water: '#75797c', props: 'tomb', fog: '#cfccc4' },
|
||||
valley: { sky: ['#d9cfd8', '#a793ac'], mtn: '#7a6580', ground: '#8f8494', groundAlt: '#7d7282', path: '#a394a0', water: '#7a6d84', props: 'valley', fog: '#c9bccb' },
|
||||
mountain: { sky: ['#dee0da', '#adb2ae'], mtn: '#7f8880', ground: '#9aa094', groundAlt: '#868c80', path: '#b5ad92', water: '#7f98a0', props: 'mountain', fog: '#d9dbd5' },
|
||||
river: { sky: ['#dfe4de', '#b4c2ba'], mtn: '#8ba192', ground: '#a3b391', groundAlt: '#8ea17c', path: '#c2b08a', water: '#7fa2a8', props: 'river', fog: '#dfe4de' },
|
||||
camp: { sky: ['#e0d6c4', '#bcae92'], mtn: '#96856a', ground: '#a89c82', groundAlt: '#948a70', path: '#bfae88', water: '#7f98a0', props: 'camp', fog: '#ddd3bf' },
|
||||
city: { sky: ['#e5dcc6', '#c6b795'], mtn: '#a3947a', ground: '#b0a68c', groundAlt: '#9c9278', path: '#cdbb94', water: '#7f98a0', props: 'city', fog: '#e3dac4' },
|
||||
};
|
||||
|
||||
/* ---------------- locations ---------------- */
|
||||
// danger 1..5 ; faction owner ; secrets discovered via exploring
|
||||
M.LOCATIONS = [
|
||||
{ id: 'home', n: 'Azure Cloud Sect', cn: '青云门', type: 'sect', theme: 'sect', danger: 0, faction: 'yours', x: 50, y: 62,
|
||||
d: 'A mountain stronghold with cracked gates and a proud name. Yours now.', secrets: [], pool: ['sect_event'] },
|
||||
{ id: 'qinghe', n: 'Qinghe Town', cn: '清河镇', type: 'town', theme: 'town', danger: 1, faction: 'guild', x: 41, y: 54,
|
||||
d: 'A river town of tea houses, merchants and rumors sold by the bowl.', secrets: [{ id: 'qinghe_cache', steps: 2, reward: { gold: 120 }, txt: 'A merchant\'s buried strongbox behind the incense shop' }], pool: ['wounded_swordsman', 'tea_house_rumor', 'merchant_trouble'] },
|
||||
{ id: 'willow', n: 'Willow Creek Village', cn: '柳溪村', type: 'village', theme: 'village', danger: 1, faction: null, x: 33, y: 66,
|
||||
d: 'Willows, rice paddies, and people who still bow to traveling swords.', secrets: [{ id: 'willow_well', steps: 2, reward: { item: 'herb_spirit', medicine: 4 }, txt: 'Sweet-tasting herbs growing wild behind the shrine' }], pool: ['village_plague', 'wolf_problem', 'grateful_farmer'] },
|
||||
{ id: 'pine', n: 'Pine Grove Village', cn: '松林村', type: 'village', theme: 'village', danger: 1, faction: null, x: 57, y: 70,
|
||||
d: 'Charcoal burners and hunters under old pines.', secrets: [{ id: 'pine_hunter', steps: 3, reward: { art: 'hunter_grap' }, txt: 'The old hunter\'s grappling method, taught to a willing student' }], pool: ['wolf_problem', 'bandit_pressure', 'grateful_farmer'] },
|
||||
{ id: 'bamboo_sea', n: 'Bamboo Sea', cn: '竹海', type: 'wild', theme: 'bamboo', danger: 2, faction: null, x: 46, y: 44,
|
||||
d: 'A green ocean of bamboo. Sound dies a few paces in.', secrets: [{ id: 'bamboo_hut', steps: 3, reward: { art: 'cloud_step' }, txt: 'A hermit\'s hut, its owner gone, footnotes on lightness left in charcoal' }], pool: ['bamboo_hermit', 'wolf_problem', 'sword_mound'] },
|
||||
{ id: 'tianlong', n: 'Tianlong Monastery', cn: '天龙寺', type: 'temple', theme: 'temple', danger: 2, faction: 'tianlong', x: 63, y: 47,
|
||||
d: 'Incense, bell-song, and monks who train as they pray.', secrets: [{ id: 'tianlong_scripture', steps: 3, reward: { art: 'iron_body' }, txt: 'The abbot permits you to copy the Golden Bell manual' }], pool: ['monk_test', 'pilgrim_story', 'incense_bell'] },
|
||||
{ id: 'taiyi', n: 'Taiyi Daoist Sect', cn: '太一道宗', type: 'temple', theme: 'mountain', danger: 3, faction: 'taiyi', x: 30, y: 38,
|
||||
d: 'Daoist peaks above the clouds. Their swords argue philosophy.', secrets: [{ id: 'taiyi_manual', steps: 4, reward: { art: 'flowing_sword' }, txt: 'A sword manual left open on the cliff stone, as if waiting' }], pool: ['duel_invitation', 'daoist_riddle', 'sword_mound'] },
|
||||
{ id: 'beggar_post', n: 'Beggar Post', cn: '丐帮分舵', type: 'camp', theme: 'camp', danger: 1, faction: 'beggar', x: 47, y: 59,
|
||||
d: 'A yard of patched tents behind the fish market. Every ear in the province.', secrets: [{ id: 'beggar_favor', steps: 2, reward: { art: 'drunken_fist' }, txt: 'Elder Nine Flags teaches you three staff-forms over a shared jug' }], pool: ['beggar_deal', 'rumor_network', 'street_kid'] },
|
||||
{ id: 'blackwind', n: 'Blackwind Ridge', cn: '黑风寨', type: 'camp', theme: 'camp', danger: 3, faction: 'bandit', x: 66, y: 63,
|
||||
d: 'Palisades, watch fires, and stolen banners. The toll collectors of the mountain road.', secrets: [{ id: 'blackwind_loot', steps: 2, reward: { gold: 200 }, txt: 'The chiefs\' strongbox under the firewood' }], pool: ['bandit_pressure', 'captured_scout', 'bandit_offer'] },
|
||||
{ id: 'luoyun', n: 'Luoyun Valley', cn: '落云谷', type: 'valley', theme: 'valley', danger: 4, faction: 'demon', x: 76, y: 38,
|
||||
d: 'Purple mist and black banners. The Demon Cult\'s eastern gate.', secrets: [{ id: 'luoyun_altar', steps: 4, reward: { art: 'demonic_qi', flag: 'read_dark_text' }, txt: 'An unattended altar text, bound in skin that is not deer' }], pool: ['cult_recruiter', 'blood_moon', 'cult_patrol'] },
|
||||
{ id: 'ancient_tomb', n: 'Ancient Tomb', cn: '古墓', type: 'tomb', theme: 'tomb', danger: 4, faction: null, x: 22, y: 52,
|
||||
d: 'A tomb older than any living sect. The air inside remembers being breathed.', secrets: [{ id: 'tomb_inner', steps: 4, reward: { art: 'nine_yin', flag: 'found_nine_yin' }, txt: 'At the coffin\'s head, a lacquer case: the Nine Yin Scripture' }], pool: ['tomb_ghost', 'sword_mound', 'coffin_choice'] },
|
||||
{ id: 'hidden_valley', n: 'Hidden Valley', cn: '隐谷', type: 'valley', theme: 'valley', danger: 2, faction: null, x: 18, y: 30, hidden: true,
|
||||
d: 'A valley that appears only when the mist leans the right way. Someone gardens here.', secrets: [{ id: 'valley_master', steps: 3, reward: { art: 'nine_yang' }, txt: 'The gardener sets down his hoe and shows you the Yang scripture breathing pattern' }], pool: ['hidden_valley', 'garden_master', 'sword_mound'] },
|
||||
{ id: 'imperial_city', n: 'Yongning Capital', cn: '永宁京城', type: 'city', theme: 'city', danger: 3, faction: 'imperial', x: 58, y: 26,
|
||||
d: 'Drum towers, curfew bells, and edicts read aloud at the gates.', secrets: [{ id: 'city_archives', steps: 3, reward: { item: 'ink_tome', gold: 100 }, txt: 'A bribed clerk lets you copy a sealed file' }], pool: ['imperial_edict', 'court_intrigue', 'arena_invite'] },
|
||||
{ id: 'arena_city', n: 'Leiting Arena City', cn: '雷庭擂台城', type: 'city', theme: 'city', danger: 2, faction: 'guild', x: 70, y: 53,
|
||||
d: 'A whole town built around fighting rings, betting stalls and bone-setters.', secrets: [{ id: 'arena_champion', steps: 2, reward: { gold: 250, fame: 8 }, txt: 'You hold the ring through nine challenges' }], pool: ['arena_invite', 'gambling_den', 'challenger'] },
|
||||
{ id: 'herb_valley', n: 'Medicine King Valley', cn: '药王谷', type: 'valley', theme: 'bamboo', danger: 2, faction: null, x: 36, y: 24, hidden: true,
|
||||
d: 'Terraces of rare herbs, tended by someone who does not welcome visitors.', secrets: [{ id: 'valley_herbs', steps: 2, reward: { item: 'herb_rare', medicine: 10 }, txt: 'Snow lotus, left to dry on warm stones' }], pool: ['herb_gather', 'snake_alarm', 'physician_debt'] },
|
||||
{ id: 'river_town', n: 'Canglang River Town', cn: '沧浪水镇', type: 'town', theme: 'river', danger: 1, faction: 'guild', x: 52, y: 78,
|
||||
d: 'Boathouses, fish markets, and ferries that ask no questions.', secrets: [{ id: 'river_wreck', steps: 3, reward: { item: 'dragon_coin', gold: 60 }, txt: 'A salvage crew sells you what the river gave back' }], pool: ['river_wreck_story', 'smuggler_offer', 'ferry_night'] },
|
||||
{ id: 'ruined_sect', n: 'Ruins of Iron Lotus Sect', cn: '铁莲废墟', type: 'tomb', theme: 'tomb', danger: 3, faction: null, x: 24, y: 42,
|
||||
d: 'Burned beams and a shattered plaque. Whatever killed this sect was not in a hurry.', secrets: [{ id: 'lotus_forge', steps: 3, reward: { item: 'tiger_claws' }, txt: 'Beneath the collapsed forge, a smith\'s last work' }], pool: ['ruin_exploration', 'survivor_found', 'ghost_of_lotus'] },
|
||||
{ id: 'shadow_market', n: 'Night Market', cn: '鬼市', type: 'town', theme: 'town', danger: 3, faction: 'shadow', x: 40, y: 16, hidden: true,
|
||||
d: 'Open only between midnight and the fourth watch. Goods with no questions attached.', secrets: [{ id: 'night_manual', steps: 3, reward: { art: 'rain_needle' }, txt: 'A hooded seller trades a needle-manual for silence about his face' }], pool: ['shadow_deal', 'assassin_shadow', 'stolen_relic'] },
|
||||
];
|
||||
M.locById = id => M.LOCATIONS.find(l => l.id === id);
|
||||
// hunter's grappling art exists only as a reward alias
|
||||
W.ARTS.hunter_grap = { id: 'hunter_grap', n: 'Beast-Grappling Method', cn: '搏兽术', cat: 'external', tier: 2, tags: ['claw'], stat: 'unarmed', learn: 14, d: 'A hunter\'s method of taking down beasts barehanded.', pas: { atk: 3, hp: 10 }, cmb: { qi: 6, cd: 1, rng: 1, pow: 1.45, kind: 'phys', fx: 'claw', fxT: 1, eff: [{ k: 'root', ch: 0.25, dur: 1 }] } };
|
||||
|
||||
/* ---------------- weather ---------------- */
|
||||
M.WEATHERS = {
|
||||
clear: { n: 'Clear', cn: '晴', icon: '☀', travel: 1, particle: null, w: { spring: 30, summer: 32, autumn: 30, winter: 24 } },
|
||||
wind: { n: 'Wind', cn: '风', icon: '🌬', travel: 1, particle: 'leaves', w: { spring: 16, summer: 10, autumn: 20, winter: 14 } },
|
||||
rain: { n: 'Rain', cn: '雨', icon: '🌧', travel: 0.85, fireMod: -0.1, waterMod: 0.2, particle: 'rain', ambient: 'rain', w: { spring: 22, summer: 20, autumn: 16, winter: 8 } },
|
||||
storm: { n: 'Storm', cn: '暴雨', icon: '⛈', travel: 0.6, fireMod: -0.2, waterMod: 0.35, particle: 'storm', ambient: 'storm', w: { spring: 6, summer: 12, autumn: 5, winter: 2 } },
|
||||
fog: { n: 'Fog', cn: '雾', icon: '🌫', travel: 0.9, ambush: 0.15, particle: 'fog', w: { spring: 12, summer: 6, autumn: 14, winter: 10 } },
|
||||
snow: { n: 'Snow', cn: '雪', icon: '❄', travel: 0.8, cold: true, particle: 'snow', w: { spring: 0, summer: 0, autumn: 4, winter: 30 } },
|
||||
};
|
||||
|
||||
/* ---------------- reputation titles ---------------- */
|
||||
M.FAME_TITLES = [
|
||||
[0, 'Unknown', '无名'], [15, 'Known', '小有名气'], [35, 'Respected', '受人敬仰'],
|
||||
[60, 'Famous', '名动一方'], [90, 'Legendary', '威震江湖'], [130, 'Immortal Name', '流芳百世'],
|
||||
];
|
||||
M.infamousTitle = f => f > 90 ? 'Dread Demon' : (f > 50 ? 'Infamous' : 'Notorious');
|
||||
|
||||
Object.assign(W, { BACKGROUNDS: M.BACKGROUNDS, FACTIONS: M.FACTIONS, ITEMS: M.ITEMS, BUILDINGS: M.BUILDINGS, TRAITS: M.TRAITS, HIDDEN: M.HIDDEN, ENEMIES: ENEMIES, THEMES: M.THEMES, LOCATIONS: M.LOCATIONS, WEATHERS: M.WEATHERS, FAME_TITLES: M.FAME_TITLES,
|
||||
genName: M.genName, cnName: M.cnName, EPITHETS: M.EPITHETS, locById: M.locById, buildingById: M.buildingById });
|
||||
W.bgById = id => M.BACKGROUNDS.find(b => b.id === id);
|
||||
W.itemById = id => M.ITEMS[id];
|
||||
W.enemyById = id => ENEMIES[id];
|
||||
W.traitById = id => M.TRAITS[id];
|
||||
W.weatherById = id => M.WEATHERS[id] || M.WEATHERS.clear;
|
||||
})();
|
||||
@@ -0,0 +1,513 @@
|
||||
/* =========================================================================
|
||||
Data-driven events: dramatic moments with requirements & consequences.
|
||||
Each choice's `do(st)` returns an effect object applied by W.sim.applyFx.
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const E = [];
|
||||
function ev(o) { E.push(o); }
|
||||
|
||||
/* ===================== WORLD EVENTS ===================== */
|
||||
|
||||
ev({
|
||||
id: 'wounded_swordsman', n: 'The Wounded Swordsman', cn: '桥头血客', cat: 'world', loc: ['town', 'village'], weight: 10, once: true,
|
||||
scene: 'bridge_rain', speaker: () => ({ name: 'Wounded Swordsman', cn: '血衣人', expr: 'injured' }),
|
||||
text: st => ['Rain falls in grey sheets over the old bridge.', 'A man in blood-soaked robes lies against the railing, one hand still on his broken sword. Three pursuers\' arrows stick out of the mud around him.', '"Water..." he breathes. "They are still... looking."'],
|
||||
choices: [
|
||||
{
|
||||
t: 'Save him', cost: { medicine: 2 },
|
||||
fx: () => ({
|
||||
txt: 'You work by lantern light until the bleeding stops. He gives a name that is certainly false — and a debt that is certainly real.',
|
||||
medicine: -2, fame: 3, mercy: 6, honor: 4, flag: 'wounded_saved', recruitChance: 0.5,
|
||||
post: st => { st.flags.wounded_day = st.day; },
|
||||
}),
|
||||
},
|
||||
{
|
||||
t: 'Question him first', req: st => true,
|
||||
fx: st => {
|
||||
const ok = W.sim.statCheck(st, 'intellect', 55);
|
||||
if (ok) return {
|
||||
txt: '"Blackwind," he coughes. "They took a caravan... and a child. The toll was silence." You bind his wounds and let him vanish into the rain — with your question answered.',
|
||||
fame: 2, intel: 1, flag: 'know_caravan', rumor: 'Blackwind Ridge holds a kidnapped child from the last caravan.',
|
||||
};
|
||||
return {
|
||||
txt: 'He laughs at your questions, coughs blood, and dies facing the road. Whatever he knew goes with him.',
|
||||
fear: 2, flag: 'swordsman_dead',
|
||||
};
|
||||
},
|
||||
},
|
||||
{ t: 'Turn him away', fx: () => ({ txt: 'You walk on. The rain does not care, and neither, you tell yourself, should you. Somewhere behind you, the night gets quieter.', mercy: -6, honor: -3 }) },
|
||||
{
|
||||
t: 'End his suffering', req: st => !st.flags.mercy_path,
|
||||
fx: () => ({ txt: 'It is quick, at least. You close his eyes and take his sword-ring as proof the deed is done — someone may be paying for it.', fear: 8, mercy: -12, honor: -6, gold: 40, flag: 'killed_swordsman' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'swordsman_debt', n: 'A Debt Repaid', cn: '还债', cat: 'world', weight: 12, once: true,
|
||||
when: st => st.flags.wounded_saved && st.day > (st.flags.wounded_day || 0) + 12,
|
||||
scene: 'town_night', speaker: () => ({ name: 'The Man Who Owes', cn: '报恩人', expr: 'determined' }),
|
||||
text: st => ['He finds you at dusk, walking easily now, carrying two wine jars.', '"You bought my life cheap, friend. Blackwind killed my brothers; you kept me alive to collect." He sets down the jars. "Ask. Anything within my reach."', '"Or," his eyes flick to your disciples, "let me stand with you. My school is dead. Yours need not be."'],
|
||||
choices: [
|
||||
{ t: 'Welcome him into the sect', fx: () => ({ txt: 'He bows to your hall — the first bow he has owed anyone in twenty years.', recruit: { power: 2.2, trait: 'vengeful', martialBonus: 8 }, relSect: 4, flag: 'owe_recruit' }) },
|
||||
{ t: 'Ask about Blackwind\'s patrols', fx: () => ({ txt: 'By midnight you know their watch rotations, the chiefs\' drinking habits, and which palisade post is manned by a boy who sleeps.', intel: 1, rumor: 'Blackwind\'s east palisade is thinly guarded before dawn.', flag: 'blackwind_intel' }) },
|
||||
{ t: 'Only the wine, thank you', fx: () => ({ txt: 'You share the jar under the eaves. Some debts are paid simply by letting them exist.', morale: 5, relSect: 1 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'tea_house_rumor', n: 'Whispers Over Tea', cn: '茶肆风波', cat: 'world', loc: ['town', 'city'], weight: 14,
|
||||
scene: 'teahouse', speaker: () => ({ name: 'One-Eared Storyteller', cn: '说书人', expr: 'neutral' }),
|
||||
text: () => ['The tea house roars with laughter — then hushes as the storyteller leans over his table.', '"...and they say the Demon Cult has sent riders east. Buying herbs, buying blades... buying men. And who prospers when the rivers boil? Why, whoever prepared early."'],
|
||||
choices: [
|
||||
{ t: 'Buy him wine and listen longer', cost: { gold: 20 }, fx: () => ({ txt: 'Three jars later you have names, routes and one piece of gold-plated nonsense. Mostly useful.', gold: -20, rumorPool: 2, intel: 1 }) },
|
||||
{ t: 'Ask about the ruins of Iron Lotus', fx: st => W.chance(0.6) ? { txt: '"Iron Lotus?" His voice drops. "They were destroyed overnight, gate to ancestor hall. No bodies burned though — carried out. By whom, nobody says." ', rumor: 'No bodies remained at the Iron Lotus ruin.', flag: 'rumor_lotus' } : { txt: '"Every ruin has ten stories and nine are lies. The tenth costs more than wine."', rumor: null } },
|
||||
{ t: 'Leave quietly', fx: () => ({ txt: 'Rumors are like river fish. Catching them is a skill; eating every one is folly.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'wolf_problem', n: 'Wolves at the Fence', cn: '狼患', cat: 'world', loc: ['village', 'wild'], weight: 12,
|
||||
scene: 'village_dusk', speaker: () => ({ name: 'Village Headman', cn: '村长', expr: 'sad' }),
|
||||
text: () => ['The headman wrings his hat in his hands.', '"Starved wolves, honored guest. They took two goats and — heaven forgive me — we found little Ming\'s shoe at the treeline. We can pay little. But we will remember."'],
|
||||
choices: [
|
||||
{
|
||||
t: 'Hunt the pack',
|
||||
fx: () => ({ txt: 'You track them by the treeline as evening falls. The pack turns to fight — starving and done running.', combat: { enemies: ['wolfpack', 'wolfpack', 'wolf'], context: 'hunt' }, rewardFame: 4, mercy: 3, flag: 'protected_village' }),
|
||||
},
|
||||
{ t: 'Teach them to build fire-pits', cost: { wood: 8 }, fx: () => ({ txt: 'A night of work, a fence of fire. It is not a sword, but it is kinder and lasts longer.', wood: -8, fame: 3, mercy: 5, faction: { orthodox: 2 }, flag: 'protected_village' }) },
|
||||
{ t: 'Demand proper payment first', req: st => false, fx: () => ({ txt: '' }) },
|
||||
{ t: 'Refuse — you have your own troubles', fx: () => ({ txt: 'Their faces close like doors. In the jianghu, remembered refusals outlive remembered kindness.', fame: -3, mercy: -4 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'village_plague', n: 'Fever in the Village', cn: '村疫', cat: 'world', loc: ['village', 'town'], weight: 9, once: true,
|
||||
scene: 'village_rain', speaker: () => ({ name: 'Frightened Mother', cn: '村妇', expr: 'sad' }),
|
||||
text: () => ['Half the houses have yellow paper talismans pasted on their doors — the kind that mean fever inside.', '"It came up the river with the grain," a mother whispers, her son burning against her shoulder. "The herbalist fled. Please. Anyone."'],
|
||||
choices: [
|
||||
{
|
||||
t: 'Treat the sick yourself', req: st => W.sim.playerMedicine(st) >= 30 || W.sectHas(st, 'medhall'),
|
||||
fx: () => ({ txt: 'Two days of bitter decoctions and cold compresses. You lose count of the cots. On the third morning, the fevers break.', medicine: -4, fame: 8, mercy: 10, honor: 5, faction: { orthodox: 3 }, flag: 'cured_plague', healParty: true, morale: 6 }),
|
||||
},
|
||||
{
|
||||
t: 'Send a disciple with instructions', cost: { medicine: 3 },
|
||||
fx: st => { const c = W.sim.pickOtherDisciple(st); return { txt: `${c ? c.name : 'Your messenger'} rides through the rain with recipes and hope. It is enough, mostly.`, medicine: -3, fame: 4, mercy: 5, relDisciple: c ? { id: c.id, d: 6 } : null }; },
|
||||
},
|
||||
{ t: 'Sell what medicine you can spare', fx: () => ({ txt: 'Desperation has prices too. You try not to meet anyone\'s eyes as you count the coins.', gold: 60, mercy: -10, honor: -8, fame: -2, deception: 4 }) },
|
||||
{ t: 'Move on quickly', fx: () => ({ txt: 'You are three li down the road before you stop hearing the coughing.', mercy: -6 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'bandit_toll', n: 'Toll Collectors', cn: '拦路匪', cat: 'travel', weight: 16,
|
||||
scene: 'mountain_pass', speaker: () => ({ name: 'Bandit Lieutenant', cn: '匪首', expr: 'angry' }),
|
||||
text: () => ['A fallen pine bars the pass. Men rise from the rocks on both sides, spears casual, eyes not.', '"Mountain road policy," the lieutenant smiles. "Ten taels a head. Or that nice sword. Policy is flexible."'],
|
||||
choices: [
|
||||
{ t: 'Pay the toll', cost: { gold: 30 }, fx: () => ({ txt: 'Coin quiets everything except your own thoughts. They wave you through with theatrical courtesy.', gold: -30, deception: 2 }) },
|
||||
{ t: 'Refuse and fight', fx: () => ({ txt: '"Policy," you agree, loosening your blade in its sheath, "is about to change."', combat: { enemies: ['bandit', 'bandit', 'bandit_vet'], context: 'road' }, winFame: 4, winFear: 4 }) },
|
||||
{
|
||||
t: 'Bluff: claim Shadow Tower protection', req: st => st.rep.fame >= 10,
|
||||
fx: st => W.chance(0.65) ? { txt: 'The lieutenant\'s smile freezes. Nobody collects a toll twice — especially not from the Tower\'s clients. The pine is dragged aside in silence.', deception: 5, fear: 3 } : { txt: '"Shadow Tower?" A pause — then laughter and leveled spears. "Then the Tower won\'t miss you."', combat: { enemies: ['bandit', 'bandit', 'bandit_vet'], context: 'road' }, winFear: 5 },
|
||||
},
|
||||
{ t: 'Take the long way around', fx: st => ({ txt: 'You back out of the pass with your purse and pride intact. The detour costs daylight.', travelDelay: 1 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'monk_test', n: 'The Abbot\'s Question', cn: '高僧问道', cat: 'world', loc: ['temple'], weight: 10, once: true,
|
||||
scene: 'temple_court', speaker: () => ({ name: 'Abbot Kongwen', cn: '空闻方丈', expr: 'neutral' }),
|
||||
text: () => ['The abbot of Tianlong pours tea himself, which surprises you more than any sermon could.', '"Your hands have taken lives," he says mildly, not asking. "So has the rain, in floods. Tell me — when you fight, do you strike to end the man, or to end the fighting?"'],
|
||||
choices: [
|
||||
{ t: '"To end the fighting."', fx: () => ({ txt: 'The abbot nods slowly. "Then there is yet room in your fist for mercy." He teaches you a breathing form used to still rage before battle.', art: 'turtle_breath', faction: { tianlong: 8 }, honor: 5, mercy: 5, spirit: 1 }) },
|
||||
{ t: '"To end the man. Quickly, cleanly."', fx: () => ({ txt: '"Honest," the abbot sighs. "Brutally honest. Heaven weighs honesty too." He grants you a warding charm against malice — his, not yours.', item: 'medicine', faction: { tianlong: 2 }, honor: 1 }) },
|
||||
{ t: 'Say nothing and drink the tea', fx: () => ({ txt: 'The silence stretches, comfortable as old cloth. At the gate he presses a warm bun into your hand. Monks.', morale: 3, faction: { tianlong: 1 } }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'duel_invitation', n: 'A Duel of Courtesy', cn: '论剑帖', cat: 'world', loc: ['temple', 'city', 'town'], weight: 11,
|
||||
scene: 'arena', speaker: st => ({ name: 'Daoist Qingxu\'s Disciple', cn: '青虚弟子', expr: 'proud' }),
|
||||
text: () => ['A young Daoist blocks your path with perfect courtesy and a naked sword.', '"My master saw your footwork from the peak and found it... interesting. I am instructed to test whether interesting survives contact. No grudge. First touch wins."'],
|
||||
choices: [
|
||||
{ t: 'Accept the friendly duel', fx: () => ({ txt: 'He salutes. Bamboo leaves fall between you.', combat: { enemies: ['master_foe'], context: 'duel', powerMul: 0.75, noLoot: true }, winArt: null, winReward: { fame: 6, faction: { taiyi: 6 }, morale: 4 }, losePenalty: { fame: -1 } }) },
|
||||
{ t: 'Decline with grace', fx: () => ({ txt: '"Another season," you promise. He nods, satisfied that you know what you are not yet.', nothing: true, faction: { taiyi: 1 } }) },
|
||||
{ t: 'Mock Taiyi swordsmanship', fx: () => ({ txt: 'His expression cools by exactly one degree. "When you learn what a sword is for," he says, sheathing it, "Taiyi will be here."', combat: { enemies: ['rival_elder'], context: 'duel', powerMul: 0.85, noLoot: true }, winReward: { fame: 8, fear: 4 }, faction: { taiyi: -8 } }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'cult_recruiter', n: 'The Black Lotus Offer', cn: '魔教使者', cat: 'world', weight: 9, once: true,
|
||||
scene: 'valley_mist', speaker: () => ({ name: 'Cult Envoy', cn: '魔使', expr: 'determined' }),
|
||||
text: () => ['She waits where the mist pools deepest, black lotus pinned at her collar.', '"Orthodox sects will use your strength and resent it. We offer brotherhood, power without apology—" she lets a red glow crawl across her knuckles, "—and only ask that you stop pretending."', '"The valley is open to you. The valley is patient."'],
|
||||
choices: [
|
||||
{ t: 'Hear the Demon Cult\'s terms', fx: () => ({ txt: 'Terms: shelter, forbidden manuals at cost, and one favor per year, unnamed. The ink is black; so is the intent. You do not sign today.', faction: { demon: 6, orthodox: -4 }, flag: 'cult_contact', intel: 1 }) },
|
||||
{ t: 'Learn a forbidden art from her', req: st => W.sectHas(st, 'chamber') || st.background === 'exiled' || st.background === 'assassin',
|
||||
fx: () => ({ txt: 'In a single night she opens your meridians along paths no orthodox master would dare. Power arrives like floodwater. So do the whispers.', art: 'poison_qi', faction: { demon: 10, orthodox: -10 }, fear: 8, honor: -6, flag: 'walked_dark' }) },
|
||||
{ t: 'Refuse and warn her off', fx: () => ({ txt: '"The valley is patient," she repeats, fading into the mist, "but so are we."', faction: { demon: -6, orthodox: 3 }, honor: 3 }) },
|
||||
{ t: 'Attack the envoy', fx: () => ({ txt: 'Her smile is almost approving as the mist fills with cultists.', combat: { enemies: ['cultist', 'cultist', 'cult_adept'], context: 'ambush' }, winReward: { fame: 6, faction: { demon: -12, orthodox: 8 } } }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'blood_moon', n: 'Under the Blood Moon', cn: '血月之夜', cat: 'world', weight: 7, once: true,
|
||||
when: st => st.day >= 35,
|
||||
scene: 'blood_moon', speaker: null,
|
||||
text: st => ['The moon rises the color of an opened wound, and the night birds do not sing.', 'Old texts say a blood moon thins the wall between discipline and hunger. Cultivation attempted tonight burns brighter — and hotter.'],
|
||||
choices: [
|
||||
{ t: 'Meditate beneath it (dangerous)', fx: st => { const p = W.sim.player(st); const dev = W.chance(0.25); if (dev) return { txt: 'Qi runs wild through the meridians like fire through dry grass. You wake at dawn with frost on your lips and a week of recovery ahead.', innerGain: Math.round(W.REALMS[p.realm].need * 0.15), injurePlayer: 5, fear: 3, flag: 'blood_moon_practiced' }; return { txt: 'For six hours the moon and your heartbeat agree. Inner force surges.', innerGain: Math.round(W.REALMS[p.realm].need * 0.25), morale: 3, flag: 'blood_moon_practiced' }; } },
|
||||
{ t: 'Practice a forbidden art openly', req: st => W.sim.knownForbidden(st).length > 0, fx: () => ({ txt: 'You let the art off its leash under the red light. Something ancient approves. Your reflection lags half a breath tonight.', fear: 10, fame: 4, honor: -8, innerGain: 40, flag: 'embraced_dark' }) },
|
||||
{ t: 'Bar the doors and wait for dawn', fx: () => ({ txt: 'Superstition, perhaps. But the dogs howl until midnight, and you sleep with a sword in reach.', nothing: true }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'beggar_deal', n: 'Nine Flags\' Price', cn: '九袋长老', cat: 'world', loc: ['camp', 'town'], weight: 9, once: true,
|
||||
scene: 'camp_fire', speaker: () => ({ name: 'Elder Nine Flags', cn: '九袋长老', expr: 'neutral' }),
|
||||
text: () => ['The elder of the Beggar Sect eats like a man who has never once been in a hurry.', '"Information, young sect leader. Fresh as morning fish. Someone is buying maps of your mountain — and asking specifically about your food stores." He licks grease from a thumb. "Names cost. Friendship costs less."'],
|
||||
choices: [
|
||||
{ t: 'Pay for the name', cost: { gold: 50 }, fx: () => ({ txt: '"Blackwind Ridge," he says at once. "Hu Laoba likes his raids fat and easy." Now you know where to look, or where to hit.', gold: -50, intel: 1, rumor: 'Blackwind plans a raid on your sect.', flag: 'warned_blackwind' }) },
|
||||
{ t: 'Offer friendship — and future favors', fx: () => ({ txt: '"Friendship!" He beams like the sun. "Then the name is free, and someday I will knock, and you will answer." The name is Blackwind. The knock will come.', faction: { beggar: 12 }, flag: 'beggar_friend', rumor: 'Blackwind plans a raid on your sect.', flag2: 'warned_blackwind' }) },
|
||||
{ t: 'Decline politely', fx: () => ({ txt: '"Prudence," he shrugs. "Also expensive. Different wallet." He goes back to his rice.', nothing: true }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'street_kid', n: 'The Thief in the Market', cn: '市集小贼', cat: 'world', loc: ['town', 'city'], weight: 10,
|
||||
scene: 'market_day', speaker: () => ({ name: 'Snatched Voice', cn: '小贼', expr: 'shocked' }),
|
||||
text: () => ['A hand darts from the crowd — your coin purse! You catch a skinny wrist before its owner clears three steps.', 'Up close: a child, twelve at most, ribs like a washboard, eyes calculating escape routes.', '"I ate yesterday," the kid announces, as if this proves remarkable discipline.'],
|
||||
choices: [
|
||||
{ t: 'Feed them and let them go', cost: { food: 3 }, fx: () => ({ txt: 'The kid eats like a wolf, cries exactly two tears, denies both, and vanishes. Three days later a stolen melon appears outside your inn door. With a bow tied to it.', food: -3, mercy: 6, karma: 1, flag: 'kind_to_thief' }) },
|
||||
{ t: 'Take them into the sect', fx: () => ({ txt: 'A sect is built from such timber — hungry, quick, loyal to whoever feeds them first.', recruit: { age: 13, trait: 'streetwise', power: 0.7 }, fame: 2, mercy: 5 }) },
|
||||
{ t: 'Hand them to the market guard', fx: () => ({ txt: 'The guard\'s cane does the talking. The kid\'s eyes over the guard\'s shoulder promise you a long, patient acquaintance with regret.', fear: 2, mercy: -8, karma: -1, flag: 'cruel_to_thief' }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'imperial_edict', n: 'Edict at the Gate', cn: '朝廷告示', cat: 'world', loc: ['city', 'town'], weight: 9,
|
||||
when: st => st.day >= 25,
|
||||
scene: 'city_gate', speaker: null,
|
||||
text: st => ['A crowd gathers under the drum tower. An official reads from a scroll with imperial satisfaction:', '"...whereas crooked arts spread disorder, all unregistered sects shall present their lineage scrolls for inspection. Unregistered practitioners practice at their peril."', 'Someone spits. Someone else memorizes every word.'],
|
||||
choices: [
|
||||
{ t: 'Register your sect with the court', fx: st => ({ txt: 'Paperwork, fees, and a censor\'s supercilious stamp. Your sect now exists officially — which means it can also be taxed, conscripted, and audited.', gold: -60, fame: 4, faction: { imperial: 10, orthodox: -2 }, flag: 'registered_imperial' }) },
|
||||
{ t: 'Ignore the edict', fx: () => ({ txt: 'Mountains are high, the court is far. For now, that arithmetic holds.', faction: { imperial: -4 }, deception: 2 }) },
|
||||
{ t: 'Publicly tear down the notice', fx: () => ({ txt: 'The crowd inhales as one. By nightfall, half the jianghu\'s teahouses have heard of the sect leader who defied the dragon throne.', fame: 6, fear: 6, faction: { imperial: -15, orthodox: 4 }, flag: 'defied_court' }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'arena_invite', n: 'The Ring Calls', cn: '擂台之邀', cat: 'world', loc: ['city'], weight: 10,
|
||||
scene: 'arena', speaker: null,
|
||||
text: () => ['Leiting Arena City smells of sweat, sesame oil and easy money. A promoter with a silk fan materializes at your elbow.', '"New face! The crowd loves new faces. Three bouts, winner takes the purse. Losers get carried out with dignity — mostly."'],
|
||||
choices: [
|
||||
{ t: 'Fight in the ring', fx: () => ({ txt: 'The gong sounds. The crowd roars like weather.', combat: { enemies: ['bandit_vet', 'rival_disciple'], context: 'arena', powerMul: 1 }, winReward: { gold: 150, fame: 7, morale: 4 }, losePenalty: { gold: -20, fame: -2 } }) },
|
||||
{ t: 'Bet on yourself — heavily', cost: { gold: 60 }, fx: () => ({ txt: 'Sixty gold on an unknown name. The bookmaker laughs all the way to the ring.', combat: { enemies: ['rival_disciple', 'bandit_chief'], context: 'arena', powerMul: 1.05 }, winReward: { gold: 260, fame: 9, morale: 6 }, losePenalty: { fame: -2 } }) },
|
||||
{ t: 'Watch, learn, and move on', fx: () => ({ txt: 'You spend an afternoon studying ring fighters\' habits — the tells, the showboating, the exhaustion patterns.', intel: 1 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'bamboo_hermit', n: 'Music in the Bamboo', cn: '竹海琴音', cat: 'world', loc: ['wild'], weight: 9, once: true,
|
||||
scene: 'bamboo_light', speaker: () => ({ name: 'The Hermit', cn: '隐士', expr: 'neutral' }),
|
||||
text: () => ['Deep in the bamboo sea, someone plays the qin. The melody stops the moment your foot snaps a twig.', 'An old man sits among fallen leaves, a qin across his knees, tea steaming for two. There were not two cups a moment ago. Or were there?', '"Sit," he says. "You walk loudly for someone carrying a famous sword."'],
|
||||
choices: [
|
||||
{ t: 'Listen to him play', fx: () => ({ txt: 'The melody is simple and endless, like water deciding to be a river. When it ends, something in your breathing has changed permanently.', innerGain: 45, spirit: 1, morale: 6, flag: 'heard_hermit' }) },
|
||||
{ t: 'Ask him to teach you', req: st => st.rep.fame >= 20, fx: st => W.chance(0.5) + (st.rep.honor > 20 ? 0.2 : 0) > 0.5 ? { txt: '"Fame," he sniffs, "arrives before its owner." Still, he corrects your footwork twice and shows you one step. One is enough for a lifetime.', art: 'cloud_step', innerGain: 30, flag: 'hermit_student' } : { txt: 'He studies you the way a carpenter studies warped wood. "Not yet. Perhaps not ever. Drink your tea."', morale: 2 } },
|
||||
{ t: 'Ask about the jianghu\'s secrets', fx: () => ({ txt: '"Secrets?" He laughs until the bamboo trembles. "Child, the secret is that everyone is making it up as badly as you are." Somehow this helps.', intel: 1, morale: 4 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'tomb_ghost', n: 'What Guards the Tomb', cn: '古墓异响', cat: 'world', loc: ['tomb'], weight: 12,
|
||||
scene: 'tomb_dark', speaker: null,
|
||||
text: () => ['Past the second corridor, the torches gutter blue. Stone grinds somewhere ahead — rhythmic, patient, wrong.', 'The air tastes of rust and old incense. Whatever walks this tomb has walked it for centuries.'],
|
||||
choices: [
|
||||
{ t: 'Advance and face it', fx: () => ({ txt: 'From the sarcophagus chamber, clay and bronze assemble themselves with terrible courtesy.', combat: { enemies: ['tomb_guard'], context: 'explore' }, winReward: { fame: 8, fear: 4, gold: 80 } }) },
|
||||
{ t: 'Search the outer chambers carefully', fx: st => W.chance(0.6) ? { txt: 'You avoid whatever sings in the deep dark. In a side niche: grave goods left by respectful thieves, and one item the thieves somehow missed.', gold: 70, item: 'ink_tome' } : { txt: 'Dust, bones, and the certain feeling of being counted by something patient.', nothing: true } },
|
||||
{ t: 'Leave the dead their peace', fx: () => ({ txt: 'Some doors are closed for good reasons. You add a stick of incense to the offering bowl on the way out. It feels appropriate.', mercy: 4, honor: 2 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'coffin_choice', n: 'The Lacquer Case', cn: '漆匣抉择', cat: 'world', loc: ['tomb'], weight: 8, once: true,
|
||||
when: st => st.flags.found_nine_yin !== 'taken' && st.day >= 20,
|
||||
scene: 'tomb_inner', speaker: null,
|
||||
text: () => ['At the coffin\'s head rests a lacquer case, sealed with wax stamped by a seal no living house uses.', 'Inside, visible through cracked lacquer: pages dense with diagrams of meridian pathways — the Nine Yin Scripture, or a masterpiece forgery designed to kill the greedy.', 'The air in the chamber waits.'],
|
||||
choices: [
|
||||
{ t: 'Take the scripture', fx: () => ({ txt: 'The pages are genuine. Cold radiates from the diagrams like a well in winter. Your hands remember the shapes before your eyes finish reading them.', art: 'nine_yin', fear: 4, flag: 'found_nine_yin', post: st => { st.flags.found_nine_yin = 'taken'; } }) },
|
||||
{
|
||||
t: 'Study it here, take nothing', req: st => true,
|
||||
fx: st => { const ok = W.sim.statCheck(st, 'intellect', 60); return ok ? { txt: 'Hours dissolve. You memorize the first circulation diagram and leave the case intact. Knowledge weighs nothing; curses weigh everything.', innerGain: 60, intel: 1, mercy: 2, flag: 'read_nine_yin' } : { txt: 'The diagrams swim. Twice you nearly trace a pathway backward — the kind of error that bursts hearts. You retreat with a headache and humility.', injurePlayer: 1, intel: 0 }; },
|
||||
},
|
||||
{ t: 'Seal it and report to the orthodox alliance', fx: () => ({ txt: 'The Alliance sends a delegation, seals the tomb properly, and mentions your name in their records with something adjacent to respect.', fame: 6, faction: { orthodox: 10 }, honor: 6, mercy: 4 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'hidden_valley', n: 'The Valley That Was Not There', cn: '误入隐谷', cat: 'world', weight: 7, once: true,
|
||||
scene: 'valley_hidden', speaker: null,
|
||||
text: () => ['Fog parts like a curtain drawn by a thoughtful servant — and there is a valley that no map admits to: terraced gardens, a stream with odd silver glints, peach trees fruiting out of season.', 'An old woman weeds a herb bed. She does not look up. "Mind the rows," she says, "or mind your own business. Either suits me."'],
|
||||
choices: [
|
||||
{ t: 'Help her weed, silently', fx: () => ({ txt: 'An hour of honest work. She inspects your rows, snorts — approval, apparently — and presses a packet of seeds into your hand.', medicine: 6, item: 'herb_spirit', mercy: 3, flag: 'met_valley_keeper', revealLocation: 'herb_valley' }) },
|
||||
{ t: 'Ask to learn from the valley\'s master', fx: () => ({ txt: '"Master?" She finally looks up, amused. "There is a gardener beyond the peach trees who talks to roots. Roots repeat what they hear. Make of that fortune whatever fits."', rumor: 'A gardener in the hidden valley speaks with mountains.', revealLocation: 'herb_valley', intel: 1 }) },
|
||||
{ t: 'Memorize the path for later', fx: () => ({ txt: 'You note landmarks with a spy\'s care. Behind you, the fog begins — gently, deliberately — to erase them.', revealLocation: 'hidden_valley_marked', intel: 1, deception: 2 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'grateful_farmer', n: 'A Basket of Eggs', cn: '一篮鸡蛋', cat: 'world', loc: ['village'], weight: 10,
|
||||
scene: 'village_day', speaker: () => ({ name: 'Farmer Old Zhou', cn: '周老汉', expr: 'happy' }),
|
||||
text: () => ['"Honored one! Honored one!" Old Zhou trots after you waving a basket. "After your people cleared the wolves, the hens started laying like it was a competition. Wife insists. Take them. Take them."', 'The basket contains eighteen eggs and one slightly concussed frog, which he removes with dignity.'],
|
||||
choices: [
|
||||
{ t: 'Accept warmly', cost: {}, fx: () => ({ txt: 'Fresh eggs, better than anything sold in towns. Word travels: the Azure Cloud Sect protects those who feed it.', food: 8, fame: 2, morale: 3, flag: 'protected_village' }) },
|
||||
{ t: 'Pay for them anyway', cost: { gold: 10 }, fx: () => ({ txt: 'He refuses thrice, accepts on the fourth as tradition demands, and tells his neighbors you are "proper folk". Villages remember "proper folk" for generations.', gold: -10, food: 8, fame: 4, mercy: 3 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'bandit_offer', n: 'Hu Laoba\'s Invitation', cn: '胡老八之邀', cat: 'world', weight: 8, once: true,
|
||||
when: st => st.day >= 18,
|
||||
scene: 'camp_throne', speaker: () => ({ name: 'Hu Laoba, Bandit King', cn: '胡老八', expr: 'proud' }),
|
||||
text: () => ['The Bandit King receives you on a chair made of cart axles, a roasted chicken in one hand.', '"Word is your sect fights well. Good! Fighting well is wasted on farmers." He tosses you the chicken. "Join me. Take a third of everything on the north road. Refuse —" he grins with terrible friendliness, "— and we\'ll visit your mountain sometime, just to talk."'],
|
||||
choices: [
|
||||
{ t: 'Pretend to accept', fx: () => ({ txt: 'You drink his wine, laugh at his jokes, and leave with his trust and his road-schedule. Treachery, your old master noted, is just strategy with worse company.', gold: 100, faction: { bandit: 5, orthodox: -6 }, deception: 8, flag: 'fake_joined_bandits', intel: 1 }) },
|
||||
{ t: 'Refuse to his face', fx: () => ({ txt: '"Shame," Hu sighs, genuinely sad for a moment. "I hate wasting good chicken."', faction: { bandit: -15 }, flag: 'refused_bandits', fear: 3 }) },
|
||||
{ t: 'Challenge him for leadership of the ridge', req: st => st.rep.fear >= 20 || st.rep.fame >= 40, fx: () => ({ txt: 'The camp goes silent. Hu Laoba sets down the chicken almost reverently. "Finally," he says, unbuckling his saber, "someone interesting."', combat: { enemies: ['bandit_chief', 'bandit_vet'], context: 'duel', powerMul: 0.95 }, winReward: { gold: 300, fear: 20, fame: 10, flag: 'won_blackwind', faction: { bandit: -30, orthodox: 5 } }, losePenalty: { injure: 6, gold: -50 } }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'captured_scout', n: 'The Bound Scout', cn: '被俘斥候', cat: 'world', weight: 8,
|
||||
scene: 'camp_palisade', speaker: () => ({ name: 'Bound Scout', cn: '斥候', expr: 'injured' }),
|
||||
text: () => ['Your patrol drags in a ragged scout caught mapping your mountain trails. Blackwind colors on his sash.', 'He is maybe seventeen. He is trying very hard to look older. "I\'ll say nothing," he announces, voice cracking on nothing.'],
|
||||
choices: [
|
||||
{ t: 'Interrogate him', fx: st => { const ok = W.sim.statCheck(st, 'intellect', 50); return ok ? { txt: 'It takes less time than breakfast. Blackwind moves on your sect within the fortnight — or meant to, before you knew.', intel: 1, rumor: 'Blackwind\'s raid is imminent.', flag: 'warned_blackwind' } : { txt: 'He sticks to his story with teenage stubbornness and a split lip. You learn only that his boots are too big for him.', nothing: true }; } },
|
||||
{ t: 'Release him with a warning', fx: () => ({ txt: '"Tell Hu Laoba the mountain grows thorns." He runs. Whether mercy or message, time will price it.', mercy: 6, faction: { bandit: 3 }, flag: 'released_scout' }) },
|
||||
{ t: 'Offer him a place in the sect', fx: () => ({ txt: 'He stares. Then, very slowly, the arrogance drains out of a seventeen-year-old spine. He kneels on the wet grass and does not pretend not to cry.', recruit: { power: 0.9, trait: 'loyal', martialBonus: -2 }, mercy: 6, faction: { bandit: -8 }, flag: 'recruited_scout' }) },
|
||||
{ t: 'Make an example', fx: () => ({ txt: 'The patrol handles it efficiently. Afterwards nobody meets your eye, but nobody doubts the mountain has teeth.', fear: 8, mercy: -12, honor: -6, morale: -3 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'sword_mound', n: 'The Mound of Swords', cn: '剑冢', cat: 'world', loc: ['wild', 'mountain', 'tomb'], weight: 8, once: true,
|
||||
scene: 'sword_mound', speaker: null,
|
||||
text: () => ['A hillside bristling with rusted swords planted hilt-up, hundreds of them, like a field of iron reeds.', 'Local legend claims a sword-mad old monster buried every blade he had broken — and one blade he never managed to break.'],
|
||||
choices: [
|
||||
{ t: 'Search for the unbroken sword', fx: st => { if (W.chance(0.45)) return { txt: 'Beneath the largest mound, wrapped in oiled silk that crumbles at a touch: a sword still keen after a century. The old monster\'s one failure.', item: 'green_jade_sword', fame: 3 }; return { txt: 'A day of digging yields rust, tetanus risk, and profound respect for whomever hid that sword better.', nothing: true }; } },
|
||||
{ t: 'Train among the graves of blades', fx: () => ({ txt: 'You drill forms until dusk among the iron reeds. Something about the place sharpens intent.', innerGain: 25, weaponSkillXp: 20 }) },
|
||||
{ t: 'Pay respects and leave', fx: () => ({ txt: 'You bow to the hillside. Somewhere, perhaps, a mad old ghost appreciates the manners.', honor: 2 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'merchant_trouble', n: 'Trouble with the Caravan', cn: '商队麻烦', cat: 'world', loc: ['town', 'city'], weight: 10,
|
||||
scene: 'caravan_dusk', speaker: () => ({ name: 'Guild Factor Qiu', cn: '邱掌柜', expr: 'worried' }),
|
||||
text: () => ['The Guild factor counts coins with one hand and wrings them with the other.', '"Bandits took my silk caravan on the north loop. Insurance," he says bitterly, "does not cover acts of Hu Laoba. Recover even half the goods and the Guild remembers its friends."'],
|
||||
choices: [
|
||||
{ t: 'Take the contract', fx: () => ({ txt: 'The trail leads into Blackwind\'s foothills. Smoke ahead; silk somewhere behind it.', combat: { enemies: ['bandit', 'bandit', 'bandit_vet', 'bandit_chief'], context: 'quest', powerMul: 0.95 }, winReward: { gold: 200, faction: { guild: 12 }, fame: 6 }, losePenalty: { faction: { guild: -3 } } }) },
|
||||
{ t: 'Negotiate escort terms instead', req: st => st.rep.fame >= 15, fx: () => ({ txt: 'Why rescue caravans when you can be paid to prevent rescues being needed? The Guild respects a person who understands what safety costs.', gold: 90, faction: { guild: 6 }, income: true, flag: 'guild_retainer' }) },
|
||||
{ t: 'Decline', fx: () => ({ txt: '"Friends are cheaper than mercenaries," Qiu notes to no one, "until they are not."', nothing: true }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'assassin_shadow', n: 'A Blade in the Dark', cn: '夜刃', cat: 'world', weight: 9,
|
||||
when: st => st.day >= 22 && (st.rep.fear >= 15 || st.rep.fame >= 30),
|
||||
scene: 'inn_night', speaker: null,
|
||||
text: () => ['You wake at the exact moment the floorboard two steps from your bed decides not to creak.', 'A shadow detaches from the wall. Moonlight finds the edge of a short, professional blade. On its hilt: the mark of Shadow Tower.'],
|
||||
choices: [
|
||||
{ t: 'Fight!', fx: () => ({ txt: 'You roll from the bed as the blade takes your pillow\'s life instead.', combat: { enemies: ['assassin'], context: 'ambush', powerMul: 0.9 }, winReward: { fear: 8, faction: { shadow: -8 }, gold: 60 }, losePenalty: { injure: 4 } }) },
|
||||
{ t: '"Who bought the contract?"', fx: st => { const ok = W.sim.statCheck(st, 'charm', 50) || W.chance(0.4); return ok ? { txt: 'The assassin considers — then answers, because professionals respect clients\' privacy only until the client is dead. "A rival sect\'s elder. Name in your archives, if you survive the week." The window stands open; the room is empty.', intel: 1, flag: 'know_contractor', rumor: 'A rival elder paid Shadow Tower for your death.' } : { txt: '"Professionals," the shadow chides, dying would-be employers\' secrets with them. The fight comes anyway.', combat: { enemies: ['assassin'], context: 'ambush', powerMul: 0.9 }, losePenalty: { injure: 4 } }; } },
|
||||
{ t: 'Counter-offer through the Tower', cost: { gold: 120 }, fx: () => ({ txt: 'Shadow Tower has no loyalty, but impeccable accounting. By dawn your contract is bought, voided, and filed. The assassin leaves a receipt. Consideration, even here.', gold: -120, faction: { shadow: 6 }, flag: 'tower_client' }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'court_intrigue', n: 'The Censor\'s Dinner', cn: '御史宴', cat: 'world', loc: ['city'], weight: 8, once: true,
|
||||
when: st => st.day >= 40,
|
||||
scene: 'mansion_hall', speaker: () => ({ name: 'Censor Pei', cn: '裴御史', expr: 'neutral' }),
|
||||
text: () => ['An invitation on excellent paper: Censor Pei requests the pleasure of a martial demonstration after dinner.', 'The dinner is lavish. The other guests are rich. Pei\'s questions are soft and precise as awls: troop numbers, sect finances, opinions on certain unregistered practices.', 'Somewhere in this house is a report half-written about you.'],
|
||||
choices: [
|
||||
{ t: 'Charm the censor', req: st => W.sim.player(st).stats.charm + st.rep.fame / 4 >= 45, fx: () => ({ txt: 'You leave the report glowing rather than damning. Pei writes beautifully and accepts beautiful corrections.', faction: { imperial: 8 }, fame: 3, deception: 3, flag: 'censor_friend' }) },
|
||||
{ t: 'Demonstrate restraint — disarm, never wound', fx: () => ({ txt: 'Your demonstration ends with every guard\'s weapon neatly stacked and nobody bruised. Pei writes: "controlled, disciplined, possibly loyal." Words worth armies.', faction: { imperial: 5 }, honor: 4, fame: 3 }) },
|
||||
{ t: 'Refuse the performance', fx: () => ({ txt: 'Martial artists are entertainers for no one\'s dinner table. The report, you later hear, grew teeth.', faction: { imperial: -8, orthodox: 2 }, pride: 1 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'smuggler_offer', n: 'Night Cargo', cn: '夜航船', cat: 'world', loc: ['town'], weight: 8,
|
||||
scene: 'river_night', speaker: () => ({ name: 'Ferrywoman Guan', cn: '关船娘', expr: 'neutral' }),
|
||||
text: () => ['The ferrywoman poles out of the reeds without being called. Her boat rides low with unlabeled crates.', '"Cargo needs moving upstream. Official eyes on the roads these days. Pays triple rates to sects that don\'t ask what\'s in the boxes." She spits, expertly. "Or double, if you ask nicely and don\'t open them."'],
|
||||
choices: [
|
||||
{ t: 'Carry the cargo unasked', fx: () => ({ txt: 'Crates travel; coins accumulate; curiosity starves. One box hums faintly, which you elect not to have heard.', gold: 130, deception: 5, faction: { guild: 4, imperial: -4 }, flag: 'ran_smuggled_goods' }) },
|
||||
{ t: 'Inspect the cargo first', fx: st => { const r = W.ri(1, 3); if (r === 1) return { txt: 'Salt. Mountains of smuggled salt. The empire taxes salt; therefore salt is worth smuggling. You take the job at double rates.', gold: 110, deception: 2, intel: 1 }; if (r === 2) return { txt: 'Under tarpaulin: military crossbows, packed in grease, bound for — you note the seal — a certain orthodox armory. Interesting. Very interesting.', gold: 140, intel: 2, flag: 'know_arms_smuggling', deception: 3 }; return { txt: 'The third box holds a person: a girl, bound and gagged, eyes furious above the cloth. The ferrywoman\'s hand drifts to her pole. "Special order," she says.', combat: { enemies: ['assassin'], context: 'rescue' }, winReward: { fame: 6, mercy: 10, recruit: { gender: 'f', age: 19, trait: 'brave', power: 1.4 }, flag: 'rescued_cargo_girl' } }; } },
|
||||
{ t: 'Refuse politely', fx: () => ({ txt: '"Suit yourself." The boat fades into the reeds like it rehearsed.', nothing: true }) },
|
||||
],
|
||||
});
|
||||
|
||||
/* ===================== SECT EVENTS ===================== */
|
||||
ev({
|
||||
id: 'sect_quarrel', n: 'Steel and Pride', cn: '同门争执', cat: 'sect', weight: 12,
|
||||
scene: 'sect_yard', dynamic: true,
|
||||
text: st => { const q = W.sim.findQuarrel(st); return q ? [`${q.a.name} and ${q.b.name} came to blows in the yard over — depending on the telling — training space, an insult, or the philosophical meaning of a stolen meat bun.`, 'Both stand bleeding and rigid with righteousness, waiting for your judgment.'] : null; },
|
||||
choices: [
|
||||
{ t: 'Judge fairly — both labor to repair the yard', fx: st => { const q = W.sim.lastQuarrel; return { txt: 'Shared shovels make poor weapons. By dusk the fence is mended and, quietly, so is most of the grudge.', relPair: q ? { a: q.a.id, b: q.b.id, d: 10 } : null, morale: 2, honor: 3 }; } },
|
||||
{ t: 'Side with the senior disciple', fx: st => { const q = W.sim.lastQuarrel; return { txt: 'Hierarchy exists for reasons. One disciple bows, relieved. The other bows, and files it away where grudges winter.', relPair: q ? { a: q.a.id, b: q.b.id, d: -8 } : null, loyaltyBias: q ? q.a.id : null }; } },
|
||||
{ t: 'Make them duel formally, first blood', fx: st => { const q = W.sim.lastQuarrel; return { txt: 'A formal duel burns pride clean. The sect gathers to watch — spectacle settles what lectures cannot.', fear: 2, morale: 3, relPair: q ? { a: q.a.id, b: q.b.id, d: 4 } : null, tradition: 1 }; } },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'sect_food_shortage', n: 'The Rice Jar Echoes', cn: '粮尽', cat: 'sect', weight: 14,
|
||||
when: st => st.res.food <= 8,
|
||||
scene: 'sect_kitchen', speaker: null,
|
||||
text: () => ['The cook shakes the rice jar; it answers with echoes. Supper tonight is thin soup and optimistic cabbage.', 'Disciples train a little slower. Eyes follow you a little longer. Hunger is a tax collected from morale first, then from everything else.'],
|
||||
choices: [
|
||||
{ t: 'Buy grain from merchants at usurious prices', cost: { gold: 80 }, fx: () => ({ txt: 'The merchant\'s smile could grease axles. But the jar is full, and full jars make loyal noises.', gold: -80, food: 25 }) },
|
||||
{ t: 'Hunt the mountain forests', fx: () => ({ txt: 'Two days of snares and tracking put venison in the pot. Lean times call for lean skills.', food: 14, apCost: 1 }) },
|
||||
{ t: 'Tighten belts together — eat last yourself', fx: () => ({ txt: 'You eat last and least. The sect notices everything, especially this.', morale: 6, honor: 4, relSect: 4, food: 2 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'sect_betrayal_tempt', n: 'Whispers in the Dormitory', cn: '弟子密语', cat: 'sect', weight: 9,
|
||||
when: st => W.sim.disciples(st).some(c => c.loyalty < 35) && st.day >= 25,
|
||||
scene: 'sect_night', dynamic: true,
|
||||
text: st => { const c = W.sim.disciples(st).filter(x => x.loyalty < 35).sort((a, b) => a.loyalty - b.loyalty)[0]; W.sim._betrayCand = c; return c ? [`A night watchman reports voices behind ${c.name}'s window after midnight.`, '"...doesn\'t appreciate us... Blackwind pays in silver, not promises..." The words drift out like smoke from a banked fire.'] : null; },
|
||||
choices: [
|
||||
{ t: 'Confront them privately', fx: st => { const c = W.sim._betrayCand; if (!c) return { txt: '' }; const ok = c.relPlayer >= 40 || st.rep.mercy >= 15; if (ok) { c.loyalty = W.U.clamp(c.loyalty + 25, 0, 100); return { txt: `You sit with ${c.name} until dawn, listening. Grievances aired shrink in daylight. Loyalty does not mend like cloth — but it mends.`, relPlayerDelta: { id: c.id, d: 8 } }; } return { txt: `${c.name} denies everything smoothly, too smoothly. The next morning their cot is empty, and so is the medicine cabinet.`, leave: c.id, morale: -4 }; } },
|
||||
{ t: 'Publicly forgive them before the sect', fx: st => { const c = W.sim._betrayCand; if (!c) return { txt: '' }; c.loyalty = W.U.clamp(c.loyalty + 15, 0, 100); return { txt: '"We were all hungry once," you announce, and embrace the traitor in front of everyone. Mercy, performed publicly, buys strange and durable currency.', mercy: 8, fame: 2, morale: 4, relPlayerDelta: { id: c.id, d: 12 } }; } },
|
||||
{ t: 'Expel them at once', fx: st => { const c = W.sim._betrayCand; return { txt: `${c.name} walks down the mountain alone. Discipline is preserved; something quieter is lost.`, leave: c ? c.id : null, fear: 4, morale: -2, honor: -2 }; } },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'sect_training_accident', n: 'Blood on the Training Ground', cn: '演武受伤', cat: 'sect', weight: 10,
|
||||
scene: 'sect_yard', dynamic: true,
|
||||
text: st => { const c = W.sim.randomDisciple(st); W.sim._accidentCand = c; return c ? [`Sparring goes wrong: a staff catches ${c.name} across the ribs. The crack is heard across the yard.`, `${c.name} waves off help with white-lipped stubbornness — pride is also an injury.`] : null; },
|
||||
choices: [
|
||||
{ t: 'Tend the injury personally', req: st => W.sim.playerMedicine(st) >= 20, fx: st => { const c = W.sim._accidentCand; return { txt: `Your hands know the rib pattern. ${c.name} will spar again in days, not weeks — and will remember whose hands`, healDisciple: c ? c.id : null, relPlayerDelta: c ? { id: c.id, d: 10 } : null, mercy: 3 }; } },
|
||||
{ t: 'Send them to rest with medicine', cost: { medicine: 2 }, fx: st => { const c = W.sim._accidentCand; return { txt: 'Proper rest, proper salves. Boring, effective, correct.', medicine: -2, healDisciple: c ? c.id : null }; } },
|
||||
{ t: 'Order them back to training', fx: st => { const c = W.sim._accidentCand; if (!c) return { txt: '' }; c.hp = Math.max(1, c.hp - 10); return { txt: `"Pain is information," you say. ${c.name} trains on, ribs taped, resentment taped tighter underneath.`, relPlayerDelta: { id: c.id, d: -8 }, fear: 3 }; } },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'sect_anniversary', n: 'Founding Day', cn: '立派纪念日', cat: 'sect', weight: 8,
|
||||
when: st => st.day % 20 === 0 && st.day > 5,
|
||||
scene: 'sect_feast', speaker: null,
|
||||
text: () => ['A month since the gates were re-hung. Someone — the cook, probably — has produced actual wine and actual meat.', 'Disciples look at you over their bowls, waiting to see what kind of tradition gets invented tonight.'],
|
||||
choices: [
|
||||
{ t: 'Feast and tell the sect\'s story so far', fx: st => ({ txt: 'You recount the days — the wolves, the plagues, the fools, the dead, the lucky. Stories eaten with meat become sect history by morning.', morale: 10, food: -5, relSect: 5, chronicle: 'The sect held its first founding feast.' }) },
|
||||
{ t: 'Double training in celebration', fx: () => ({ txt: '"Celebration," you declare, "is a form of sparring with joy." Joy, it turns out, improves everyone\'s footwork.', xpAll: 15, morale: 4, food: -3 }) },
|
||||
{ t: 'Quiet remembrance for the fallen', fx: st => ({ txt: 'You pour wine on the ground for those the mountain has taken. Even the youngest disciples understand this grammar.', honor: 4, morale: 5, mercy: 3 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'romance_moment', n: 'Moonrise on the Wall', cn: '墙头月色', cat: 'sect', weight: 10,
|
||||
when: st => !!W.sim.romanceCandidate(st),
|
||||
scene: 'sect_wall_night', dynamic: true,
|
||||
text: st => { const c = W.sim.romanceCandidate(st); W.sim._romanceCand = c; return c ? [`${c.name} is on the wall walk, ostensibly watching the road, actually watching the moon.`, 'You end up beside them. The conversation is nothing — patrol schedules, bad wine, the shape of clouds. It takes two hours. Neither of you notices.'] : null; },
|
||||
choices: [
|
||||
{ t: 'Let the silence say it', fx: st => { const c = W.sim._romanceCand; return { txt: `Nothing is said. Everything is understood. On the morrow, the sect's gossip mill grinds sweetly.`, romanceAdvance: c ? c.id : null, morale: 5 }; } },
|
||||
{ t: 'Speak plainly of your feelings', req: st => { const c = W.sim._romanceCand; return c && c.relPlayer >= 55; }, fx: st => { const c = W.sim._romanceCand; return { txt: `You say it aloud. ${c.name} looks at you for a long moment — then laughs, and punches your arm hard enough to mean yes.`, romanceAdvance: c ? c.id : null, morale: 6, relPlayerDelta: { id: c ? c.id : '', d: 10 } }; } },
|
||||
{ t: 'Keep the relationship proper', fx: st => { const c = W.sim._romanceCand; return { txt: 'A sect leader owes the sect clarity. You wish them goodnight, formal as a ceremony. The moon keeps its own counsel.', honor: 2 }; } },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'rival_challenge', n: 'Challenge at the Gate', cn: '门前挑战', cat: 'sect', weight: 11,
|
||||
when: st => W.sim.rivals(st).length > 0 && st.day >= 15,
|
||||
scene: 'sect_gate', dynamic: true,
|
||||
text: st => { const list = W.sim.rivals(st); if (!list.length) return null; const r = W.pick(list); W.sim._rival = r; return [`${r.name} of the ${r.n} marches ten disciples up to your gate and plants a challenge-staff in the ground with theatrical force.`, `"Our elders say your sect teaches crooked forms on stolen land. Prove otherwise — or yield your courtyard and your name."`] },
|
||||
choices: [
|
||||
{ t: 'Accept the formal duel', fx: () => ({ txt: 'You walk out alone. The challenge-staff trembles in the wind between you.', combat: { enemies: ['rival_disciple', 'rival_disciple', 'rival_elder'], context: 'defense', powerMul: 0.9 }, winReward: { fame: 10, fear: 6, rivalStrike: 2 }, losePenalty: { fame: -8, morale: -6, tribute: true } }) },
|
||||
{ t: 'Answer with your strongest disciple', fx: st => { const c = W.sim.bestDisciple(st); W.sim._champion = c; return { txt: `${c ? c.name : 'Your champion'} steps through the gate rolling both shoulders. The visiting formation hesitates — champions were not in the script.`, combat: { enemies: ['rival_disciple', 'rival_elder'], context: 'defense', powerMul: 0.85, champion: c ? c.id : null }, winReward: { fame: 8, relDisciple: c ? { id: c.id, d: 12 } : null }, losePenalty: { morale: -4, tribute: true } }; } },
|
||||
{ t: 'Buy them off', cost: { gold: 100 }, fx: () => ({ txt: 'Gold buys peace the way ice buys summer — temporarily, and at melting prices. But the week is quiet.', gold: -100, morale: -3, deception: 2 }) },
|
||||
{ t: 'Set the mountain traps and wait', fx: () => ({ txt: 'You decline the theater. That night, the "ten disciples" trip every snare line your people own and limp home humiliated by geography.', fear: 5, rivalStrike: 1, trap: true, fame: 2 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'elder_han_plot', n: 'The Elder\'s Long Game', cn: '长者之心', cat: 'sect', weight: 8, once: true,
|
||||
when: st => st.day >= 35 && W.sim.disciples(st).some(c => c.loyalty < 45 && c.age > 35),
|
||||
scene: 'sect_hall_night', dynamic: true,
|
||||
text: st => { const c = W.sim.disciples(st).filter(x => x.loyalty < 45 && x.age > 35)[0]; W.sim._plotter = c; return c ? [`You find ${c.name}'s brush marks on documents you never signed: requisitions rerouted, patrol logs rewritten, a letter of complaint drafted to the Orthodox Alliance — in your name, with your seal forged beneath it.`, `${c.name} believed the sect deserved "steadier hands." The evidence is thorough. So was the loyalty, once.`] : null; },
|
||||
choices: [
|
||||
{ t: 'Confront with evidence before the whole sect', fx: st => { const c = W.sim._plotter; return { txt: `${c.name} doesn't deny it. "Someone had to steer while you chased legends." Expulsion follows law, not anger — but the leaving is ugly all the same.`, leave: c ? c.id : null, honor: 4, fear: 3, morale: -3, flag: 'survived_betrayal' }; } },
|
||||
{ t: 'Forgive — publicly assign them greater duty', fx: st => { const c = W.sim._plotter; const ok = c && (c.relPlayer > 25 || W.chance(0.5)); if (ok) { c.loyalty = W.U.clamp(c.loyalty + 30, 0, 100); return { txt: 'You hand them the ledgers they tried to steal and a heavier title. "Steer, then. Openly." Redemption, it turns out, can be administered like a bitter medicine.', mercy: 10, fame: 4, morale: 5, relPlayerDelta: { id: c.id, d: 20 }, flag: 'redeemed_traitor' }; } return { txt: `${c.name} takes the offered post — and the keys, and three months later the strongbox. Forgiveness has a failure rate. This was it.`, theftFlag: true, leave: c ? c.id : null, gold: -120, flag: 'forgave_wrong_one' }; } },
|
||||
{ t: 'Say nothing. Watch.', fx: st => { const c = W.sim._plotter; return { txt: 'You re-seal the documents with invisible care and begin, quietly, to route everything important around them. Patience is a blade with no shine.', intel: 1, deception: 5, flag: 'watching_' + (c ? c.id : '') }; } },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'orphan_arrival', n: 'The Children at the Gate', cn: '门前孤儿', cat: 'sect', weight: 9,
|
||||
when: st => st.rep.fame >= 12,
|
||||
scene: 'sect_gate_rain', speaker: null,
|
||||
text: () => ['Fame is a lamp; it draws moths. Tonight it drew three orphans — the eldest maybe ten, holding the youngest\'s hand, all of them soaked and insolent with terror.', '"We heard your sect takes people," the eldest recites, clearly rehearsed. "We don\'t eat much."'],
|
||||
choices: [
|
||||
{ t: 'Take them in', fx: () => ({ txt: 'The cook grumbles; the dormitory gains three small storms. Within a week the youngest has labeled every herb jar in the medhall with pictures.', recruit: { age: 10, trait: 'kind', power: 0.4 }, mercy: 8, fame: 2, food: -3, morale: 4 }) },
|
||||
{ t: 'Feed them, point them to Qinghe Town', cost: { food: 4 }, fx: () => ({ txt: 'Full bellies and honest directions. The eldest bows with adult precision. Some debts you pay forward without expecting receipts.', food: -4, mercy: 4 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'manual_thief', n: 'Missing Pages', cn: '失窃残页', cat: 'sect', weight: 8, once: true,
|
||||
when: st => W.sim.knownArtCount(st) >= 4 && W.sectHas(st, 'library'),
|
||||
scene: 'library_night', dynamic: true,
|
||||
text: st => { const c = W.sim.randomDisciple(st); W.sim._thief = c; return c ? [`The library lock is scratched — from the inside. Three pages are missing from the movement manual: precisely the pages on the reverse-circulation trick.`, `${c.name} has been practicing alone at odd hours lately, and getting noticeably better at exactly that technique.`] : null; },
|
||||
choices: [
|
||||
{ t: 'Praise the initiative, formalize the study', fx: st => { const c = W.sim._thief; return { txt: 'Hungry students steal knowledge; fed ones ask. You grant access, add supervision, and gain a disciple who now learns faster honestly.', xpDisciple: c ? c.id : null, relPlayerDelta: c ? { id: c.id, d: 8 } : null, morale: 3 }; } },
|
||||
{ t: 'Punish the theft — three days scrubbing', fx: st => { const c = W.sim._thief; return { txt: `Rules are rules. ${c ? c.name : 'The culprit'} scrubs, sulks, and obeys. The library lock gets replaced with a better one.`, relPlayerDelta: c ? { id: c.id, d: -6 } : null, order: 1 }; } },
|
||||
{ t: 'Ignore it — talent finds its way', fx: () => ({ txt: 'You leave the lock broken. Sometimes the best master is an unlocked door.', nothing: true }) },
|
||||
],
|
||||
});
|
||||
|
||||
/* ===================== TRAVEL AMBIENT ===================== */
|
||||
ev({
|
||||
id: 'rain_shelter', n: 'Sharing a Ruined Shrine', cn: '破庙避雨', cat: 'travel', weight: 10,
|
||||
scene: 'shrine_rain', speaker: () => ({ name: 'Traveling Merchant', cn: '行商', expr: 'neutral' }),
|
||||
text: () => ['The storm forces you into a ruined shrine already occupied by a merchant, his mule, and a small fire of wet bills and dry gossip.', '"Roads are bad, seas are worse," he offers companionably. "But a person met on the road is a person met. Wine?"'],
|
||||
choices: [
|
||||
{ t: 'Share wine and trade rumors', cost: { gold: 10 }, fx: () => ({ txt: 'By the fire\'s end you have traded rumors fair and square, and gained a contact in Qinghe with fair prices and loose discretion.', gold: -10, rumorPool: 1, morale: 3, flag: 'merchant_contact' }) },
|
||||
{ t: 'Trade news for supplies', fx: () => ({ txt: 'Your road-news buys dried meat and a coil of good rope. Commerce, the oldest martial art.', food: 6, wood: 4 }) },
|
||||
{ t: 'Sleep apart and wary', fx: () => ({ txt: 'You sleep against the opposite wall, one eye cracked. Nothing happens. Usually that means the other party was equally tired.', nothing: true }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'procession', n: 'A Funeral Procession', cn: '送葬队伍', cat: 'travel', weight: 8,
|
||||
scene: 'funeral_rain', speaker: null,
|
||||
text: () => ['White funeral banners fill the road ahead. A village buries its elder, and tradition demands travelers wait or help carry.', 'The dead elder, you gather, once taught half the village to read. They bury him with his books.'],
|
||||
choices: [
|
||||
{ t: 'Help carry the coffin', fx: () => ({ txt: 'Mud to the ankles, weight on the shoulder, forty strangers singing. You arrive at the grave oddly lighter than you left.', honor: 4, fame: 2, morale: 2 }) },
|
||||
{ t: 'Wait respectfully at the roadside', fx: () => ({ txt: 'You stand with head bowed until the last banner passes. Enough. Manners cost minutes and buy years.', honor: 2 }) },
|
||||
{ t: 'Take the side path', fx: () => ({ txt: 'The dead do not need you. The mud disagrees, and steals a boot-print\'s worth of your dignity.', travelDelay: 1 }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'night_attack_beasts', n: 'Eyes Beyond the Fire', cn: '兽袭', cat: 'travel', weight: 10,
|
||||
scene: 'camp_night', speaker: null,
|
||||
text: () => ['The horses scream first. Then the firelight shows eyes — many eyes, low to the ground, circling with professional patience.', 'Not wolves that hunt men usually. Something has emptied the hills of easier prey.'],
|
||||
choices: [
|
||||
{ t: 'Stand and fight at the fire', fx: () => ({ txt: 'Steel and flame hold the circle. The pack breaks at dawn, taking one mule as consolation.', combat: { enemies: ['wolf', 'wolf', 'wolfpack', 'wolfpack'], context: 'beast' }, winReward: { food: 8, fame: 2 } }) },
|
||||
{ t: 'Fire brands and loud singing', fx: st => W.chance(0.6) ? { txt: 'Beasts fear fire and, apparently, your singing most of all. The pack melts into the dark offended.', nothing: true, morale: 2 } : { txt: 'The beasts are unimpressed by your repertoire. Claws find flesh before brands find fur.', combat: { enemies: ['wolfpack', 'wolfpack'], context: 'beast' } } },
|
||||
{ t: 'Climb — trees, wagon, anything', fx: () => ({ txt: 'An undignified, successful night in a tree. The mule is less successful. The mule is also gone.', food: -2, nothing: true }) },
|
||||
],
|
||||
});
|
||||
|
||||
ev({
|
||||
id: 'ferry_night', n: 'The Last Ferry', cn: '末班渡船', cat: 'travel', weight: 7,
|
||||
scene: 'river_fog', speaker: () => ({ name: 'Old Ferryman', cn: '老艄公', expr: 'neutral' }),
|
||||
text: () => ['Fog on the river like spilled milk. The old ferryman poles out of it, unsurprised by anything, possibly including ghosts.', '"Last crossing tonight," he says. "Fog crossing costs extra. Fog has teeth, see."'],
|
||||
choices: [
|
||||
{ t: 'Pay for the fog crossing', cost: { gold: 15 }, fx: () => ({ txt: 'Halfway across, shapes circle the boat — long, slow, curious. The ferryman taps the hull twice, conversationally. They leave. You do not ask.', gold: -15, intel: 1 }) },
|
||||
{ t: 'Camp and cross at dawn', fx: () => ({ txt: 'Dry firewood beats fog-teeth. Dawn crossing is cheap and bright.', travelDelay: 1 }) },
|
||||
],
|
||||
});
|
||||
|
||||
W.EVENTS = E;
|
||||
W.EVENT_BY_ID = {}; E.forEach(e => W.EVENT_BY_ID[e.id] = e);
|
||||
})();
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/* =========================================================================
|
||||
GameState: creation, world generation, character generation, save/load
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const U = W.U;
|
||||
let _uid = Math.floor(W.rng() * 1e6);
|
||||
|
||||
function nid(p) { return (p || 'c') + (_uid++).toString(36); }
|
||||
|
||||
/* ---------------- character factory ---------------- */
|
||||
function baseStats() { return { martial: W.ri(28, 40), intellect: W.ri(28, 42), medicine: W.ri(2, 12), leadership: W.ri(8, 22), charm: W.ri(20, 40), spirit: W.ri(15, 30) }; }
|
||||
|
||||
function makeCharacter(opt) {
|
||||
opt = opt || {};
|
||||
const gender = opt.gender || (W.chance(0.5) ? 'm' : 'f');
|
||||
const name = opt.name || W.genName(gender);
|
||||
const c = {
|
||||
id: nid('c'), name, cn: opt.cn || null, gender, age: opt.age || W.ri(16, 34),
|
||||
epithet: W.chance(0.35) ? W.pick(W.EPITHETS) : null,
|
||||
isPlayer: !!opt.isPlayer,
|
||||
stats: Object.assign(baseStats(), opt.stats || {}),
|
||||
skills: Object.assign({ unarmed: 10 + W.ri(0, 14), sword: 6, saber: 4, spear: 4, staff: 4, claw: 4, hidden: 4, internal: 8, lightness: 8 }, opt.skills || {}),
|
||||
hp: 100, maxHp: 100, qi: 50, maxQi: 50,
|
||||
inner: 0, realm: 0,
|
||||
arts: [], equipped: [],
|
||||
weapon: opt.weapon || null, armor: null,
|
||||
loyalty: opt.isPlayer ? 100 : W.ri(45, 65),
|
||||
relPlayer: opt.isPlayer ? 100 : W.ri(25, 45),
|
||||
rels: {}, // charId -> value
|
||||
traits: opt.traits || [W.pick(Object.keys(W.TRAITS)), W.pick(Object.keys(W.TRAITS))].filter((v, i, a) => a.indexOf(v) === i),
|
||||
hidden: null, hiddenRevealed: false,
|
||||
memories: [],
|
||||
romance: null, // {partnerId, stage}
|
||||
injuryDays: 0, poisonDays: 0,
|
||||
alive: true, atSect: !opt.isPlayer ? true : undefined,
|
||||
xp: { martial: 0, sword: 0, saber: 0, spear: 0, staff: 0, claw: 0, hidden: 0, internal: 0, lightness: 0 },
|
||||
background: opt.background || null,
|
||||
joinedDay: opt.joinedDay != null ? opt.joinedDay : 1,
|
||||
};
|
||||
for (const a of (opt.arts || [])) if (!c.arts.includes(a)) c.arts.push(a);
|
||||
if (opt.isPlayer) { c.hp = 110; c.maxHp = 110; }
|
||||
return c;
|
||||
}
|
||||
|
||||
function applyBackground(c, bg) {
|
||||
for (const k in bg.mods) c.stats[k] = U.clamp((c.stats[k] || 20) + bg.mods[k], 3, 99);
|
||||
if (bg.trait && !c.traits.includes(bg.trait)) c.traits.unshift(bg.trait);
|
||||
}
|
||||
|
||||
/* ---------------- derived combat numbers ---------------- */
|
||||
function realmMul(realm) { return 1 + realm * 0.28; }
|
||||
|
||||
function equipBonuses(c) {
|
||||
const b = { atk: 0, def: 0, spd: 0, hp: 0, qi: 0, qreg: 0, crit: 0, dodge: 0, lifesteal: 0, regen: 0 };
|
||||
if (c.weapon && W.itemById(c.weapon)) b.atk += W.itemById(c.weapon).atk || 0;
|
||||
if (c.armor && W.itemById(c.armor)) b.def += W.itemById(c.armor).def || 0;
|
||||
for (const id of c.equipped) {
|
||||
const a = W.artById(id); if (!a) continue;
|
||||
const p = a.pas || {};
|
||||
for (const k of ['atk', 'def', 'spd', 'hp', 'qi', 'qreg', 'regen']) if (p[k]) b[k] += p[k];
|
||||
if (p.crit) b.crit += p.crit;
|
||||
if (p.dodge) b.dodge += p.dodge;
|
||||
if (p.lifesteal) b.lifesteal += p.lifesteal;
|
||||
}
|
||||
for (const cb of W.combosFor(c.equipped)) {
|
||||
const p = cb.bonus || {};
|
||||
for (const k of ['atk', 'def', 'spd', 'hp', 'qi', 'qreg', 'regen']) if (p[k]) b[k] += p[k];
|
||||
if (p.crit) b.crit += p.crit;
|
||||
if (p.dodge) b.dodge += p.dodge;
|
||||
if (p.lifesteal) b.lifesteal += p.lifesteal;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function refreshDerived(st, c) {
|
||||
const eq = equipBonuses(c);
|
||||
const r = realmMul(c.realm);
|
||||
const newMax = Math.round((80 + c.stats.martial * 1.1 + eq.hp) * r);
|
||||
if (newMax > c.maxHp) c.hp += newMax - c.maxHp;
|
||||
c.maxHp = newMax;
|
||||
c.hp = U.clamp(c.hp, 0, c.maxHp);
|
||||
const nq = Math.round((40 + c.stats.spirit * 1.4 + eq.qi + W.REALMS[c.realm].need * 0.08));
|
||||
c.maxQi = nq; c.qi = U.clamp(c.qi, 0, nq);
|
||||
}
|
||||
|
||||
/* ---------------- world ---------------- */
|
||||
function genWorld(st) {
|
||||
st.world = { locs: {} };
|
||||
for (const def of W.LOCATIONS) {
|
||||
st.world.locs[def.id] = {
|
||||
id: def.id, discovered: !def.hidden, visited: false,
|
||||
secretsDone: {}, controlFaction: def.faction === 'yours' ? null : def.faction,
|
||||
prosperity: W.ri(45, 70), state: 'normal', lastEventDay: 0,
|
||||
};
|
||||
}
|
||||
// rival minor sects
|
||||
st.world.rivals = [
|
||||
{ id: 'riv1', n: 'Black Sword Sect', cn: '黑剑门', power: W.ri(30, 45), rel: -20, alive: true },
|
||||
{ id: 'riv2', n: 'Stone Gate Sect', cn: '石门派', power: W.ri(25, 40), rel: -10, alive: true },
|
||||
];
|
||||
st.world.rumors = [];
|
||||
}
|
||||
|
||||
/* ---------------- new game ---------------- */
|
||||
function newState(opt) {
|
||||
opt = opt || {};
|
||||
W.seedRng(opt.seed || ((Date.now() ^ (Math.random() * 0xffffffff)) >>> 0));
|
||||
const bgDef = W.bgById(opt.background) || W.BACKGROUNDS[0];
|
||||
const diff = W.C.DIFF[opt.difficulty] ? opt.difficulty : 'jianghu';
|
||||
|
||||
const st = {
|
||||
version: W.VERSION, seed: W._rngSeed, day: 1, phase: 0,
|
||||
ap: 3, apMax: 3, difficulty: diff,
|
||||
res: { gold: 60 + (bgDef.gold || 0), food: 18, medicine: 4 + (bgDef.res.medicine || 0), wood: 8 + (bgDef.res.wood || 0), iron: 4 + (bgDef.res.iron || 0) },
|
||||
sect: {
|
||||
name: opt.sectName || 'Azure Cloud Sect', cn: opt.sectCn || '青云门',
|
||||
color: '#39627d', morale: 50, buildings: {}, queue: [],
|
||||
discCapBase: 4, foundedDay: 1,
|
||||
},
|
||||
rep: { fame: bgDef.fame || 0, honor: 0, fear: 0, mercy: 0, deception: 0, ambition: 0 },
|
||||
chars: {}, nextId: 0,
|
||||
player: null, party: [],
|
||||
locId: 'home', travel: null,
|
||||
flags: Object.assign({}, bgDef.rels ? {} : {}),
|
||||
factionRel: Object.assign({ orthodox: 0, demon: -10, beggar: 0, tianlong: 0, taiyi: 0, shadow: 0, imperial: 0, bandit: -10, guild: 0 }, bgDef.rels || {}),
|
||||
chronicle: [], quests: [], rumors: [],
|
||||
war: { tension: 0, declared: false, side: null, finalDone: false },
|
||||
stats: { kills: 0, recruits: 1, duels: 0, arts: 0, treasures: 0, sectsDestroyed: 0, betrayals: 0, romances: 0, deaths: 0, breakthroughs: 0, daysExplored: 0 },
|
||||
settings: { music: 0.55, sfx: 0.75, fx: 2, cnText: true, speed: 1 },
|
||||
ended: false, endingId: null,
|
||||
};
|
||||
for (let i = 0; i < 200; i++) W.rng(); // decorrelate
|
||||
|
||||
// player
|
||||
const pc = makeCharacter({ isPlayer: true, name: opt.playerName || 'Nameless', gender: opt.gender || 'm', age: W.ri(19, 26), arts: bgDef.arts.slice(), background: bgDef.id });
|
||||
applyBackground(pc, bgDef);
|
||||
pc.equipped = bgDef.arts.slice(0, W.equipLimit());
|
||||
st.chars[pc.id] = pc; st.player = pc.id;
|
||||
st.party.push(pc.id);
|
||||
|
||||
// founding disciples
|
||||
const founders = [
|
||||
{ gender: 'f', age: 24, trait: 'proud', arts: ['basic_sword'], weapon: 'iron_sword' },
|
||||
{ gender: 'm', age: 21, trait: 'loyal', arts: ['plum_fist'] },
|
||||
{ gender: W.chance(0.5) ? 'f' : 'm', age: 29, trait: 'scholarly', arts: ['qi_circ'], stats: { medicine: 25 } },
|
||||
];
|
||||
for (const f of founders) {
|
||||
const d = makeCharacter({ gender: f.gender, age: f.age, arts: f.arts, weapon: f.weapon || null });
|
||||
if (f.trait && !d.traits.includes(f.trait)) d.traits.push(f.trait);
|
||||
if (f.stats) for (const k in f.stats) d.stats[k] = Math.min(95, d.stats[k] + f.stats[k]);
|
||||
d.relPlayer = W.ri(45, 62);
|
||||
st.chars[d.id] = d;
|
||||
st.party.push(d.id);
|
||||
}
|
||||
st.party = st.party.slice(0, 4);
|
||||
genWorld(st);
|
||||
|
||||
// starting buildings: hall only
|
||||
st.sect.buildings.hall = 1;
|
||||
|
||||
// opening chronicle
|
||||
addChron(st, `You gathered the survivors and re-hung the gate plaque of ${st.sect.name}. One hundred days lie ahead.`, 'major');
|
||||
return st;
|
||||
}
|
||||
|
||||
function addChron(st, text, kind) {
|
||||
st.chronicle.push({ day: st.day, text, kind: kind || 'minor' });
|
||||
if (st.chronicle.length > 400) st.chronicle.shift();
|
||||
}
|
||||
|
||||
/* ---------------- helpers used by events/sim ---------------- */
|
||||
W.simHelpers = {
|
||||
makeCharacter, applyBackground, refreshDerived, equipBonuses, realmMul, addChron, nid,
|
||||
};
|
||||
|
||||
/* ---------------- save / load ---------------- */
|
||||
const KEY = 'wuxia100_saves_v1';
|
||||
function slots() { return W.store.get(KEY, {}); }
|
||||
W.saveGame = function (slot, st) {
|
||||
const s = slots();
|
||||
s[slot] = { t: Date.now(), day: st.day, sect: st.sect.name, fame: st.rep.fame, data: JSON.stringify(serializable(st)) };
|
||||
W.store.set(KEY, s);
|
||||
return true;
|
||||
};
|
||||
W.listSaves = function () {
|
||||
const s = slots(), out = [];
|
||||
for (const k in s) out.push({ slot: k, t: s[k].t, day: s[k].day, sect: s[k].sect, fame: s[k].fame });
|
||||
out.sort((a, b) => b.t - a.t);
|
||||
return out;
|
||||
};
|
||||
W.loadGame = function (slot) {
|
||||
const s = slots()[slot]; if (!s) return null;
|
||||
try { return revive(JSON.parse(s.data)); } catch (e) { console.error(e); return null; }
|
||||
};
|
||||
W.deleteSave = function (slot) { const s = slots(); delete s[slot]; W.store.set(KEY, s); };
|
||||
|
||||
function serializable(st) {
|
||||
return JSON.parse(JSON.stringify(st, (k, v) => {
|
||||
if (k === '_rng') return undefined;
|
||||
return v;
|
||||
}));
|
||||
}
|
||||
function revive(o) {
|
||||
// ensure methods/derived values are fresh
|
||||
for (const id in o.chars) { /* plain objects */ }
|
||||
if (o.combat) o.combat = null;
|
||||
return o;
|
||||
}
|
||||
W.exportSave = function (st) { try { return btoa(unescape(encodeURIComponent(JSON.stringify(serializable(st))))); } catch (e) { return ''; } };
|
||||
W.importSave = function (str) { try { return revive(JSON.parse(decodeURIComponent(escape(atob(str.trim()))))); } catch (e) { return null; } };
|
||||
W.newGameState = newState;
|
||||
})();
|
||||
+927
@@ -0,0 +1,927 @@
|
||||
/* =========================================================================
|
||||
Simulation: day loop, actions, cultivation, relationships, factions,
|
||||
weather, reputation, events engine, endings. Plain-data state + API.
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const U = W.U;
|
||||
const S = W.sim = {};
|
||||
S.lastQuarrel = null;
|
||||
|
||||
/* ================= accessors ================= */
|
||||
S.player = st => st.chars[st.player];
|
||||
S.allChars = st => Object.values(st.chars).filter(c => c.alive);
|
||||
S.disciples = st => S.allChars(st).filter(c => !c.isPlayer);
|
||||
S.roster = st => S.allChars(st);
|
||||
S.party = st => st.party.map(id => st.chars[id]).filter(c => c && c.alive);
|
||||
S.disciplesHome = st => S.disciples(st).filter(c => c.atSect !== false);
|
||||
S.rivals = st => (st.world.rivals || []).filter(r => r.alive);
|
||||
S.knownArts = st => S.player(st).arts;
|
||||
S.knownArtCount = st => S.player(st).arts.length;
|
||||
S.knownForbidden = st => S.player(st).arts.filter(a => { const d = W.artById(a); return d && d.cat === 'forbidden'; });
|
||||
S.locDef = st => W.locById(st.locId);
|
||||
S.locState = st => st.world.locs[st.locId];
|
||||
S.atHome = st => st.locId === 'home' && !st.travel;
|
||||
S.weather = st => W.weatherById(st.weatherId);
|
||||
S.diff = st => W.C.DIFF[st.difficulty];
|
||||
S.fameTitle = st => {
|
||||
const f = st.rep.fame;
|
||||
if (st.rep.fear > f + 25 && st.rep.honor < 0) return { n: W.infamousTitle(f), cn: '恶名昭彰' };
|
||||
for (let i = W.FAME_TITLES.length - 1; i >= 0; i--) if (f >= W.FAME_TITLES[i][0]) return { n: W.FAME_TITLES[i][1], cn: W.FAME_TITLES[i][2] };
|
||||
return { n: 'Unknown', cn: '无名' };
|
||||
};
|
||||
|
||||
/* ================= buildings ================= */
|
||||
S.sectHas = (st, id, lvl) => (st.sect.buildings[id] || 0) >= (lvl || 1);
|
||||
S.sectEffects = function (st) {
|
||||
const e = { trainMul: 0, learnMul: 0, medMul: 0, healMul: 0, foodSave: 0, medicinePerDay: 0, moralePerDay: 0, discCap: 4, apMax: 3, defense: 0, breakAid: 0, ambushGuard: false, forge: false, medCraft: false, forbidden: false };
|
||||
for (const b of W.BUILDINGS) {
|
||||
const lv = st.sect.buildings[b.id] || 0; if (!lv) continue;
|
||||
const f = b.eff;
|
||||
if (f.apMax) e.apMax += f.apMax;
|
||||
if (f.discCap) e.discCap += f.discCap;
|
||||
if (f.trainMul) e.trainMul += f.trainMul;
|
||||
if (f.learnMul) e.learnMul += f.learnMul;
|
||||
if (f.medMul) e.medMul += f.medMul;
|
||||
if (f.healMul) e.healMul += f.healMul;
|
||||
if (f.foodSave) e.foodSave += f.foodSave;
|
||||
if (f.medicinePerDay) e.medicinePerDay += f.medicinePerDay * lv;
|
||||
if (f.moralePerDay) e.moralePerDay += f.moralePerDay * lv;
|
||||
if (f.defense) e.defense += f.defense * lv;
|
||||
if (f.breakAid) e.breakAid += f.breakAid;
|
||||
if (f.ambushGuard) e.ambushGuard = true;
|
||||
if (f.forge) e.forge = true;
|
||||
if (f.medCraft) e.medCraft = true;
|
||||
if (f.forbidden) e.forbidden = true;
|
||||
}
|
||||
return e;
|
||||
};
|
||||
S.discCap = st => Math.min(14, S.sectEffects(st).discCap);
|
||||
|
||||
/* ================= derived combat numbers ================= */
|
||||
S.skillOf = (c, artStat) => artStat ? (c.skills[artStat] || 10) : 10;
|
||||
S.attackOf = function (st, c) {
|
||||
const eq = W.simHelpers.equipBonuses(c);
|
||||
let atk = c.stats.martial * 0.9 + eq.atk + (eq.crit || 0) * 30;
|
||||
// equipped arts contribute skill synergy
|
||||
for (const id of c.equipped) { const a = W.artById(id); if (a) atk += S.skillOf(c, a.stat) * 0.08; }
|
||||
return Math.round(atk * W.simHelpers.realmMul(c.realm));
|
||||
};
|
||||
S.defenseOf = function (st, c) {
|
||||
const eq = W.simHelpers.equipBonuses(c);
|
||||
return Math.round((c.stats.spirit * 0.35 + eq.def + c.stats.martial * 0.18) * W.simHelpers.realmMul(c.realm));
|
||||
};
|
||||
S.speedOf = function (st, c) {
|
||||
const eq = W.simHelpers.equipBonuses(c);
|
||||
return Math.max(2, Math.round((3 + c.stats.spirit * 0.03 + eq.spd) + c.realm * 0.4));
|
||||
};
|
||||
S.combosOf = (st, c) => W.combosFor(c.equipped);
|
||||
S.unstablePenalty = function (st, c) { return W.unstableCombos(S.combosOf(st, c)).length; };
|
||||
|
||||
S.statCheck = function (st, statName, dc) {
|
||||
const p = S.player(st);
|
||||
const v = statName === 'charm' ? p.stats.charm : p.stats[statName] || 20;
|
||||
return (v + W.rng() * 45 + st.rep.fame * 0.2) >= dc;
|
||||
};
|
||||
S.playerMedicine = st => S.player(st).stats.medicine;
|
||||
|
||||
/* ================= reputation / rep axes ================= */
|
||||
S.addRep = function (st, fx) {
|
||||
const r = st.rep;
|
||||
r.fame = U.clamp(r.fame + (fx.fame || 0), -50, 999);
|
||||
r.honor = U.clamp(r.honor + (fx.honor || 0), -100, 100);
|
||||
r.fear = U.clamp(r.fear + (fx.fear || 0), 0, 100);
|
||||
r.mercy = U.clamp(r.mercy + (fx.mercy || 0), -100, 100);
|
||||
r.deception = U.clamp(r.deception + (fx.deception || 0), 0, 100);
|
||||
r.ambition = U.clamp(r.ambition + (fx.ambition || 0), 0, 100);
|
||||
if (fx.morale) S.addMorale(st, fx.morale);
|
||||
};
|
||||
S.addMorale = (st, d) => { st.sect.morale = U.clamp(st.sect.morale + d, 0, 100); };
|
||||
S.addRes = function (st, fx) {
|
||||
for (const k of W.C.RES) if (fx[k]) st.res[k] = Math.max(0, st.res[k] + fx[k]);
|
||||
};
|
||||
S.factionShift = function (st, map) {
|
||||
for (const k in map) st.factionRel[k] = U.clamp((st.factionRel[k] || 0) + map[k], -100, 100);
|
||||
};
|
||||
S.remember = function (c, txt) { c.memories.push({ day: st_day(), txt }); if (c.memories.length > 14) c.memories.shift(); };
|
||||
function st_day() { return W.state ? W.state.day : 0; }
|
||||
S.addChron = (st, txt, kind) => W.simHelpers.addChron(st, txt, kind);
|
||||
|
||||
/* ================= arts ================= */
|
||||
S.learnArt = function (st, c, artId, silent) {
|
||||
if (!c || !W.artById(artId) || c.arts.includes(artId)) return false;
|
||||
c.arts.push(artId);
|
||||
const a = W.artById(artId);
|
||||
if (!c.equipped || c.equipped.length < W.equipLimit()) c.equipped.push(artId);
|
||||
W.simHelpers.refreshDerived(st, c);
|
||||
if (c.isPlayer) {
|
||||
st.stats.arts++;
|
||||
if (!silent) S.addChron(st, `You absorbed the ${a.n} (${a.cn}).`, a.tier >= 4 ? 'major' : 'minor');
|
||||
}
|
||||
if (a.tier >= 4 && !silent) S.remember(c, `Learned the ${a.n}.`);
|
||||
return true;
|
||||
};
|
||||
S.setEquipped = function (st, c, ids) {
|
||||
c.equipped = ids.slice(0, W.equipLimit()).filter(id => c.arts.includes(id));
|
||||
W.simHelpers.refreshDerived(st, c);
|
||||
};
|
||||
|
||||
/* ================= inventory ================= */
|
||||
S.addItem = (st, id, n) => { st.inv = st.inv || {}; st.inv[id] = (st.inv[id] || 0) + (n || 1); if (st.inv[id] <= 0) delete st.inv[id]; };
|
||||
S.hasItem = (st, id, n) => (st.inv || {})[id] >= (n || 1);
|
||||
S.useItemCount = (st, id, n) => { if (S.hasItem(st, id, n)) { st.inv[id] -= n; if (st.inv[id] <= 0) delete st.inv[id]; return true; } return false; };
|
||||
S.itemPrice = function (id) { const it = W.itemById(id); if (!it) return 10; const base = it.value || (it.type === 'use' ? 18 : 12); return Math.round(base * W.rf(0.85, 1.15)); };
|
||||
S.manualItem = artId => ({ id: 'manual_' + artId, n: W.artById(artId).n + ' Manual', cn: W.artById(artId).cn, type: 'manual', art: artId, d: 'Teaches the ' + W.artById(artId).n + '.', value: 60 });
|
||||
|
||||
/* ================= characters ================= */
|
||||
S.genDisciple = function (st, opt) {
|
||||
opt = opt || {};
|
||||
const power = opt.power != null ? opt.power : 1;
|
||||
const c = W.simHelpers.makeCharacter({
|
||||
gender: opt.gender, age: opt.age, trait: undefined,
|
||||
arts: opt.arts || [W.pick(['plum_fist', 'basic_sword', 'qi_circ', 'sleeve_arrow'])],
|
||||
});
|
||||
if (opt.trait) c.traits.unshift(opt.trait);
|
||||
// scale stats with day & requested power
|
||||
const grow = 1 + st.day / 160;
|
||||
for (const k of ['martial', 'spirit']) c.stats[k] = U.clamp(Math.round(c.stats[k] * grow * power), 8, 96);
|
||||
if (opt.martialBonus) c.stats.martial = U.clamp(c.stats.martial + opt.martialBonus, 5, 99);
|
||||
// hidden identity
|
||||
if (opt.hiddenIdentity || (W.chance(0.12))) {
|
||||
c.hidden = W.pick(W.HIDDEN);
|
||||
const he = c.hidden.eff || {};
|
||||
if (he.martial) c.stats.martial = U.clamp(c.stats.martial + he.martial, 5, 99);
|
||||
if (he.spirit) c.stats.spirit = U.clamp(c.stats.spirit + he.spirit, 5, 99);
|
||||
if (he.intellect) c.stats.intellect = U.clamp(c.stats.intellect + he.intellect, 5, 99);
|
||||
if (he.charm) c.stats.charm = U.clamp(c.stats.charm + he.charm, 5, 99);
|
||||
}
|
||||
if (opt.bonusManual) { /* carried manual revealed later */ c._bonusManual = W.pick(['yin_art', 'cloud_step', 'tiger_claw']); }
|
||||
W.simHelpers.refreshDerived(st, c);
|
||||
c.hp = c.maxHp;
|
||||
return c;
|
||||
};
|
||||
S.addDisciple = function (st, c) {
|
||||
if (S.roster(st).length >= S.discCap(st)) return null;
|
||||
st.chars[c.id] = c;
|
||||
st.stats.recruits++;
|
||||
S.addChron(st, `${c.name} joined the ${st.sect.name}.`, 'minor');
|
||||
S.remember(c, `Joined the sect on day ${st.day}.`);
|
||||
W.ach && W.ach.check('first_disciple');
|
||||
return c;
|
||||
};
|
||||
S.randomDisciple = st => { const ds = S.disciples(st); return ds.length ? W.pick(ds) : null; };
|
||||
S.bestDisciple = st => S.disciples(st).sort((a, b) => S.attackOf(st, b) - S.attackOf(st, a))[0] || null;
|
||||
S.pickOtherDisciple = st => S.randomDisciple(st);
|
||||
S.findQuarrel = function (st) {
|
||||
const ds = S.disciples(st); if (ds.length < 2) return null;
|
||||
// pre-existing grudges first
|
||||
for (let i = 0; i < ds.length; i++) for (let j = i + 1; j < ds.length; j++) {
|
||||
const rel = ds[i].rels[ds[j].id] || 0;
|
||||
if (rel <= -18 && W.chance(0.6)) return { a: ds[i], b: ds[j], rel };
|
||||
}
|
||||
// clashing personalities
|
||||
const clash = t => t === 'proud' || t === 'hot_headed' || t === 'ambitious';
|
||||
const pairs = [];
|
||||
for (let i = 0; i < ds.length; i++) for (let j = i + 1; j < ds.length; j++) {
|
||||
if (ds[i].traits.some(clash) && ds[j].traits.some(clash) && (ds[i].rels[ds[j].id] || 0) < 10) pairs.push({ a: ds[i], b: ds[j], rel: ds[i].rels[ds[j].id] || 0 });
|
||||
}
|
||||
if (pairs.length && W.chance(0.5)) return W.pick(pairs);
|
||||
return null;
|
||||
};
|
||||
S.romanceCandidate = function (st) {
|
||||
const p = S.player(st);
|
||||
if (p.romance) return null;
|
||||
return S.disciples(st).find(c => !c.romance && c.age >= 18 && Math.abs(c.age - p.age) <= 14 &&
|
||||
c.relPlayer >= (c.traits.includes('flirtatious') ? 46 : 56) && W.chance(0.75)) || null;
|
||||
};
|
||||
S.adjustRel = function (st, id, d) {
|
||||
const c = st.chars[id]; if (!c || c.isPlayer) return;
|
||||
c.relPlayer = U.clamp(c.relPlayer + d, -100, 100);
|
||||
if (d <= -20) S.remember(c, `You wronged them deeply (day ${st.day}).`);
|
||||
if (d >= 20) S.remember(c, `You earned their lasting gratitude (day ${st.day}).`);
|
||||
};
|
||||
|
||||
/* ================= cultivation ================= */
|
||||
S.innerNeeded = c => W.REALMS[c.realm].need;
|
||||
S.gainInner = function (st, c, amt) {
|
||||
c.inner = Math.min(W.REALMS[W.REALMS.length - 1].need + 200, c.inner + amt);
|
||||
};
|
||||
S.breakthroughChance = function (st, c, aided) {
|
||||
let p = 0.9 - c.realm * 0.09 + S.diff(st).break;
|
||||
p += S.sectEffects(st).breakAid;
|
||||
p += c.stats.spirit / 400;
|
||||
if (aided) p += 0.15;
|
||||
p -= S.knownForbidden(st).length * 0.04 * (c.isPlayer ? 1 : 0);
|
||||
p -= S.unstablePenalty(st, c) * 0.05;
|
||||
return U.clamp(p, 0.15, 0.95);
|
||||
};
|
||||
S.attemptBreakthrough = function (st, c, aided) {
|
||||
const need = S.innerNeeded(c);
|
||||
if (c.inner < need || c.realm >= W.REALMS.length - 1) return { ok: false, txt: 'Inner force is not ready.' };
|
||||
c.inner -= need;
|
||||
if (aided && !S.useItemCount(st, 'breakthrough_pill', 1)) aided = false;
|
||||
const p = S.breakthroughChance(st, c, aided);
|
||||
if (W.chance(p)) {
|
||||
c.realm++;
|
||||
c.stats.martial = U.clamp(c.stats.martial + 3, 0, 99);
|
||||
c.stats.spirit = U.clamp(c.stats.spirit + 3, 0, 99);
|
||||
W.simHelpers.refreshDerived(st, c); c.hp = c.maxHp; c.qi = c.maxQi;
|
||||
st.stats.breakthroughs++;
|
||||
const R = W.REALMS[c.realm];
|
||||
if (c.isPlayer) {
|
||||
S.addChron(st, `You broke through to ${R.n} (${R.cn}). Your qi shakes the courtyard dust.`, c.realm >= 4 ? 'major' : 'minor');
|
||||
W.ach && W.ach.check('grandmaster', c.realm);
|
||||
} else if (c.realm >= 4) S.addChron(st, `${c.name} reached the ${R.n} realm.`, 'major');
|
||||
S.remember(c, `Broke through to ${R.n}.`);
|
||||
return { ok: true, txt: `Lightning fills the meridians — then stillness. You rise as something more: ${R.n} (${R.cn}).`, realm: c.realm };
|
||||
}
|
||||
// failure
|
||||
const r = W.rng();
|
||||
if (r < 0.06 && c.realm >= 3) {
|
||||
if (!c.isPlayer && W.chance(0.5)) {
|
||||
c.alive = false; st.stats.deaths++;
|
||||
S.addChron(st, `${c.name}'s qi deviated during a closed-door attempt. They did not rise again.`, 'dark');
|
||||
S.addMorale(st, -8);
|
||||
return { ok: false, txt: `${c.name} sat down to break through and did not stand up again. The incense burned to nothing.`, death: c.id };
|
||||
}
|
||||
c.inner = Math.round(c.inner * 0.5); c.injuryDays = 6; c.hp = Math.max(1, Math.round(c.hp * 0.3));
|
||||
return { ok: false, txt: 'Qi tears through the meridians like a flooded river through a paper dam. Blood on the lips, weeks of recovery ahead.', injury: true };
|
||||
}
|
||||
if (r < 0.45) {
|
||||
c.inner = Math.round(c.inner * 0.65); c.injuryDays = 3;
|
||||
return { ok: false, txt: 'The breakthrough slips. Qi scatters; cold sweat soaks your robe. Progress remains, barely.', injury: true };
|
||||
}
|
||||
c.injuryDays = 1;
|
||||
return { ok: false, txt: 'The gate refuses you this time. Nothing breaks — except perhaps a little pride.', injury: false };
|
||||
};
|
||||
|
||||
/* ================= weather & time ================= */
|
||||
S.rollWeather = function (st) {
|
||||
const season = W.seasonOf(st.day).id;
|
||||
const pairs = [];
|
||||
for (const k in W.WEATHERS) { const wd = W.WEATHERS[k]; const w = wd.w[season] || 0; if (w > 0) pairs.push([k, w]); }
|
||||
const prev = st.weatherId;
|
||||
let id = W.pickW(pairs);
|
||||
if (id === prev && W.chance(0.5)) id = W.pickW(pairs);
|
||||
st.weatherId = id; st.weatherDays = W.ri(1, 3);
|
||||
};
|
||||
S.travelSpeedMul = st => {
|
||||
let m = S.weather(st).travel || 1;
|
||||
const p = S.player(st);
|
||||
const eq = W.simHelpers.equipBonuses(p);
|
||||
m *= 1 + Math.min(0.3, eq.spd * 0.02);
|
||||
return m;
|
||||
};
|
||||
|
||||
/* ================= travel ================= */
|
||||
S.travelDays = function (st, toId) {
|
||||
const a = W.locById(st.locId), b = W.locById(toId);
|
||||
if (!a || !b) return 1;
|
||||
const d = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
return Math.max(1, Math.round(d / 17 / S.travelSpeedMul(st)));
|
||||
};
|
||||
S.startTravel = function (st, toId) {
|
||||
if (st.travel) return { err: true };
|
||||
const days = S.travelDays(st, toId);
|
||||
st.travel = { to: toId, left: days, total: days, ambushed: false };
|
||||
return { days };
|
||||
};
|
||||
S.tickTravel = function (st) {
|
||||
const t = st.travel; if (!t) return null;
|
||||
t.left--;
|
||||
// travel encounter?
|
||||
const dest = W.locById(t.to);
|
||||
const amb = (dest.danger * 0.05 + (S.weather(st).ambush || 0)) * (S.sectEffects(st).ambushGuard ? 0.5 : 1) * S.diff(st).event;
|
||||
if (!t.ambushed && W.chance(Math.min(0.5, amb))) { t.ambushed = true; return { encounter: true }; }
|
||||
if (t.left <= 0) {
|
||||
st.travel = null; st.locId = t.to;
|
||||
const ls = st.world.locs[t.to]; ls.discovered = true; ls.visited = true;
|
||||
S.addChron(st, `The party arrived at ${dest.n} (${dest.cn}).`, 'minor');
|
||||
return { arrived: dest.id };
|
||||
}
|
||||
return { traveling: true };
|
||||
};
|
||||
|
||||
/* ================= exploration ================= */
|
||||
S.explore = function (st) {
|
||||
const loc = S.locDef(st), ls = S.locState(st);
|
||||
const danger = loc.danger;
|
||||
const secretsLeft = (loc.secrets || []).filter(s => !ls.secretsDone[s.id]);
|
||||
const pool = [
|
||||
['flavor', 16],
|
||||
['gather', 16],
|
||||
['ambush', 10 + danger * 4],
|
||||
['npc', 13],
|
||||
['secret', secretsLeft.length ? 14 : 0],
|
||||
['rumor', 8],
|
||||
['event', 15],
|
||||
];
|
||||
const kind = W.pickW(pool);
|
||||
if (kind === 'event') {
|
||||
const ev = S.rollEvent(st, 'world');
|
||||
if (ev) return { event: ev };
|
||||
}
|
||||
if (kind === 'ambush') {
|
||||
const enemies = S.encounterFor(st, danger);
|
||||
return { combat: { enemies, context: 'explore' }, intro: S.ambushIntro(danger) };
|
||||
}
|
||||
if (kind === 'gather') return S.gatherResult(st);
|
||||
if (kind === 'npc') return S.meetNpc(st, danger);
|
||||
if (kind === 'secret') return S.secretProgress(st, secretsLeft[0]);
|
||||
if (kind === 'rumor') { S.addRumor(st); return { txt: S.lastRumorText }; }
|
||||
// flavor
|
||||
const flavors = {
|
||||
wild: ['Wind combs the bamboo; a woodcutter\'s ax rings somewhere far off.', 'You follow a deer trail to a cliff view worth the walk.'],
|
||||
town: ['You wander the market, pricing weapons you won\'t buy and listening to everything.', 'A tea stall owner saves you the good table without being asked. Fame has privileges.'],
|
||||
village: ['Chickens scatter. Children follow at a safe distance, daring each other closer.', 'An old woman insists you carry home more vegetables than any human needs.'],
|
||||
temple: ['Incense smoke writes slow calligraphy on the air. Somewhere, bells.', 'A young monk sweeps leaves that will return by dusk. He seems at peace with this.'],
|
||||
tomb: ['Cold air breathes from stone throats. Your lantern gutters and steadies.', 'Names carved in old script. You read three before deciding against a fourth.'],
|
||||
camp: ['Watch fires, rough laughter, the smell of wet wool and iron.', 'Someone is always sharpening something in a place like this.'],
|
||||
valley: ['Mist moves with intent here. The paths disagree with the map, politely.', 'Flowers bloom out of season, and the silence has texture.'],
|
||||
mountain: ['The climb steals breath and pays it back with views.', 'Pine shadows stripe the trail like ink strokes.'],
|
||||
city: ['Drum towers, edict boards, a hundred trades shouting their worth.', 'You lose an hour people-watching at the gate. Intelligence, technically.'],
|
||||
sect: ['Your own mountain: familiar stones, faithful echoes.', 'A junior disciple bows mid-drill, nearly falls, recovers with dignity.'],
|
||||
};
|
||||
return { txt: W.pick(flavors[loc.theme] || flavors.wild) };
|
||||
};
|
||||
S.ambushIntro = d => W.pick([
|
||||
'Movement in the treeline — too late to be movement.',
|
||||
'"Wallet or wrists," suggests a voice with a spear behind it.',
|
||||
'They were waiting. The question is for whom.',
|
||||
'Bandages, blades and bad intentions block the path.',
|
||||
]);
|
||||
S.encounterFor = function (st, danger) {
|
||||
const scale = 1 + st.day / 130 * S.diff(st).enemy;
|
||||
const tables = {
|
||||
1: [['wolfpack', 'wolfpack'], ['bandit']],
|
||||
2: [['bandit', 'bandit'], ['cultist', 'cultist'], ['wolf', 'wolf', 'wolfpack']],
|
||||
3: [['bandit_vet', 'bandit', 'bandit'], ['assassin', 'cultist'], ['soldier', 'soldier']],
|
||||
4: [['cult_adept', 'cultist', 'cultist'], ['officer', 'soldier'], ['rival_elder', 'rival_disciple'], ['tomb_guard']],
|
||||
5: [['warlord', 'cultist'], ['assassin', 'assassin', 'cult_adept'], ['master_foe']],
|
||||
};
|
||||
const t = tables[U.clamp(danger, 1, 5)];
|
||||
const grp = W.pick(t);
|
||||
return grp.map(id => ({ id, mul: scale }));
|
||||
};
|
||||
S.gatherResult = function (st) {
|
||||
const theme = S.locDef(st).theme;
|
||||
const r = W.ri(1, 100);
|
||||
if (theme === 'bamboo' || theme === 'village') {
|
||||
if (r < 50) { const n = W.ri(2, 5); st.res.wood += n; return { txt: `You cut and bundle sound bamboo poles (+${n} wood).` }; }
|
||||
if (r < 80) { const n = W.ri(1, 3); st.res.medicine += n; return { txt: `Among the roots: bitter bark and healing leaves (+${n} medicine).` }; }
|
||||
S.addItem(st, 'herb_spirit'); return { txt: 'A spirit herb glows faintly beneath rotting fronds. Kept.' };
|
||||
}
|
||||
if (theme === 'mountain' || theme === 'tomb' || theme === 'valley') {
|
||||
if (r < 45) { const n = W.ri(1, 4); st.res.iron += n; return { txt: `A seam of bog iron, exposed by rain (+${n} iron).` }; }
|
||||
if (r < 75) { const n = W.ri(2, 5); st.res.wood += n; return { txt: `Deadfall pine, dry as old bones (+${n} wood).` }; }
|
||||
S.addItem(st, 'herb_rare'); return { txt: 'A snow lotus, impossibly white in the shadow of the rocks.' };
|
||||
}
|
||||
if (r < 55) { const n = W.ri(2, 6); st.res.wood += n; return { txt: `Gathered firewood and straight shoots (+${n} wood).` }; }
|
||||
const n = W.ri(1, 3); st.res.medicine += n; return { txt: `Field herbs by the wayside (+${n} medicine).` };
|
||||
};
|
||||
S.secretProgress = function (st, sec) {
|
||||
if (!sec) return { txt: 'Nothing new under this sky.' };
|
||||
const ls = S.locState(st);
|
||||
ls.secretsDone[sec.id] = (ls.secretsDone[sec.id] || 0) + 1;
|
||||
if (ls.secretsDone[sec.id] >= sec.steps) {
|
||||
ls.secretsDone[sec.id] = -1;
|
||||
const rw = sec.reward || {};
|
||||
S.addRes(st, rw);
|
||||
let extra = '';
|
||||
if (rw.art) { S.learnArt(st, S.player(st), rw.art); extra = ' A technique now lives in your hands.'; }
|
||||
if (rw.item) { S.addItem(st, rw.item); extra = ' An artifact passes into your keeping.'; st.stats.treasures++; }
|
||||
if (rw.flag) st.flags[rw.flag] = true;
|
||||
if (rw.gold) extra += ` (+${rw.gold} gold)`;
|
||||
S.addChron(st, `At ${S.locDef(st).n}: ${sec.txt}.`, 'major');
|
||||
return { txt: `${sec.txt}.${extra}`, secret: true };
|
||||
}
|
||||
return { txt: `You work at something unfinished here. (${ls.secretsDone[sec.id]}/${sec.steps}) — ${sec.txt}…` };
|
||||
};
|
||||
S.meetNpc = function (st, danger) {
|
||||
const roll = W.rng();
|
||||
if (roll < 0.42) {
|
||||
const cand = S.genDisciple(st, { power: 0.8 + st.day / 150 });
|
||||
st.meeting = { kind: 'recruit', char: cand };
|
||||
return { meeting: true, txt: `Someone has been watching you work: ${cand.name}, ${cand.age}, a drifter with sword-calluses and hungry eyes.` };
|
||||
}
|
||||
// wandering master
|
||||
const masterArts = ['iron_palm', 'flowing_sword', 'bajiquan', 'lightning_step', 'heart_sword', 'turtle_breath', 'cold_qi', 'thunder_qi', 'hidden_knife', 'drunken_fist'];
|
||||
const unknown = masterArts.filter(a => !S.player(st).arts.includes(a));
|
||||
if (unknown.length && W.chance(0.6)) {
|
||||
const artId = W.pick(unknown);
|
||||
st.meeting = { kind: 'master', art: artId, name: 'Master ' + W.pick(W.EPITHETS).replace('the ', ''), power: 1 + st.day / 120 };
|
||||
return { meeting: true, txt: `${st.meeting.name} watches you drill, unimpressed and amused in equal measure. "That form," they say, "has a hole in it big enough to die through."` };
|
||||
}
|
||||
S.addRumor(st);
|
||||
return { txt: S.lastRumorText };
|
||||
};
|
||||
S.addRumor = function (st) {
|
||||
const rumors = [
|
||||
'Blackwind Ridge doubles its watch fires at night.',
|
||||
'The Demon Cult buys red silk and coffin nails in bulk. Nobody asks why.',
|
||||
'A Grandmaster in white was seen asking after the Nine Yin Scripture.',
|
||||
'Grain prices doubled in Qinghe; the Guild blames the war, the war blames everyone.',
|
||||
'Shadow Tower posted three new contracts this month. All sealed names.',
|
||||
'Tianlong Monastery rang its bell forty-nine times. Someone important died.',
|
||||
'Fishermen swear the river ran warm for a whole day below Luoyun Valley.',
|
||||
'The Orthodox Alliance is quietly hiring instructors for "new cadres".',
|
||||
'A ruined shrine outside town grants luck to those who sweep it.',
|
||||
'Imperial censors copied every inn register in the province.',
|
||||
];
|
||||
const r = W.pick(rumors);
|
||||
st.lastRumorDay = st.day; S.lastRumorText = 'Rumor over heard fires: ' + r;
|
||||
if (!st.world.rumors.includes(r)) st.world.rumors.push(r);
|
||||
return r;
|
||||
};
|
||||
|
||||
/* ================= meetings (recruit / master) ================= */
|
||||
S.resolveMeeting = function (st, choice) {
|
||||
const m = st.meeting; if (!m) return { txt: '' };
|
||||
const out = { txt: '' };
|
||||
if (m.kind === 'recruit') {
|
||||
const c = m.char;
|
||||
if (choice === 'gift') {
|
||||
const cost = 40 + Math.round(st.day * 0.8);
|
||||
if (st.res.gold < cost) return { txt: 'Your purse disagrees with your generosity.' };
|
||||
st.res.gold -= cost;
|
||||
if (S.addDisciple(st, c)) { out.txt = `${c.name} weighs the silver, then your face — and stays for the second.`; S.adjustRel(st, c.id, 10); }
|
||||
else out.txt = 'The quarters are full. They understand, mostly.';
|
||||
} else if (choice === 'persuade') {
|
||||
if (W.chance(U.clamp(0.3 + st.rep.fame / 120 + S.player(st).stats.charm / 220, 0.15, 0.8))) {
|
||||
if (S.addDisciple(st, c)) out.txt = `"A sect that feeds its people and buries them with names," ${c.name} says. "Very well. Show me the yard."`;
|
||||
else out.txt = 'No room remains under your roof.';
|
||||
} else out.txt = `${c.name} listens politely, thanks you, and is gone by morning. Some people cannot be kept.`;
|
||||
} else if (choice === 'spar') {
|
||||
st.meeting.spar = true;
|
||||
return { combat: { enemies: [], context: 'spar', sparVs: c.id }, intro: `${c.name} salutes. "Convince me."` };
|
||||
} else out.txt = 'You part as strangers.';
|
||||
} else if (m.kind === 'master') {
|
||||
if (choice === 'duel') {
|
||||
return { combat: { enemies: [], context: 'master_duel', masterArt: m.art, masterPower: m.power }, intro: 'Blades clear sheaths with a sound like torn silk.' };
|
||||
} else if (choice === 'request') {
|
||||
if (st.rep.fame >= 25 && W.chance(0.55)) { S.learnArt(st, S.player(st), m.art); out.txt = `Impressed less by your fame than by your manners, ${m.name} teaches you the ${W.artById(m.art).n}.`; }
|
||||
else out.txt = '"Come back when the jianghu knows your name — or when your fists do."';
|
||||
} else out.txt = 'You bow and move on. Their eyes follow you a while.';
|
||||
}
|
||||
st.meeting = null;
|
||||
return out;
|
||||
};
|
||||
|
||||
/* ================= other actions ================= */
|
||||
S.actionHunt = function (st) {
|
||||
const food = W.ri(3, 7) + Math.round(st.day / 40);
|
||||
st.res.food += food;
|
||||
if (W.chance(0.25)) return { combat: { enemies: S.encounterFor(st, 1).map(e => ({ ...e, id: W.pick(['wolf', 'wolfpack']) })), context: 'hunt' }, txt: `Fresh meat for the pot (+${food} food) — though the woods here have teeth.` };
|
||||
return { txt: `Patient snares and quiet stalking fill the larder (+${food} food).` };
|
||||
};
|
||||
S.actionTrain = function (st, targetId) {
|
||||
const c = targetId ? st.chars[targetId] : S.player(st);
|
||||
if (!c || c.injuryDays > 0) return { txt: c ? `${c.name} must recover before training.` : '' };
|
||||
const eff = S.sectEffects(st);
|
||||
const amt = Math.round((6 + c.stats.intellect * 0.08) * (1 + eff.trainMul));
|
||||
// improve primary equipped art's skill
|
||||
for (const id of c.equipped) { const a = W.artById(id); if (a && a.stat) c.skills[a.stat] = U.clamp((c.skills[a.stat] || 10) + amt * 0.6, 0, 100); }
|
||||
c.xp.martial = (c.xp.martial || 0) + amt;
|
||||
return { txt: `${c.isPlayer ? 'You drill' : c.name + ' drills'} until the sweat maps the floor${eff.trainMul ? ', the yard\'s posts taking their toll of mistakes' : ''}. Skills sharpen.`, xp: amt };
|
||||
};
|
||||
S.actionMeditate = function (st, targetId) {
|
||||
const c = targetId ? st.chars[targetId] : S.player(st);
|
||||
if (!c) return { txt: '' };
|
||||
const eff = S.sectEffects(st);
|
||||
const amt = Math.round((7 + c.stats.spirit * 0.28) * (1 + eff.medMul));
|
||||
S.gainInner(st, c, amt);
|
||||
const near = c.inner >= S.innerNeeded(c);
|
||||
return { txt: `${c.isPlayer ? 'You sit' : c.name + ' sits'} in stillness. Qi gathers like dew. (+${amt} inner force${near ? ' — a breakthrough trembles at the door' : ''})`, inner: amt, near };
|
||||
};
|
||||
S.actionRecruit = function (st) {
|
||||
const loc = S.locDef(st);
|
||||
if (!['town', 'city', 'camp', 'temple'].includes(loc.type)) return { txt: 'Few travelers drift through places like this.' };
|
||||
if (S.roster(st).length >= S.discCap(st)) return { txt: 'The dormitory is full; hearts would spill out the windows.' };
|
||||
const cand = S.genDisciple(st, { power: 0.8 + st.day / 150 });
|
||||
st.meeting = { kind: 'recruit', char: cand };
|
||||
return { meeting: true, txt: `In a tavern's corner: ${cand.name}, watching the door the way people do when the past owes them money. Strong shoulders, guarded eyes.` };
|
||||
};
|
||||
S.actionSpy = function (st) {
|
||||
const loc = S.locDef(st);
|
||||
if (!['camp', 'valley', 'city', 'town', 'temple'].includes(loc.type)) return { txt: 'Nothing worth stealing hides in plain bamboo.' };
|
||||
const p = S.player(st);
|
||||
const sneak = (p.skills.lightness || 10) + p.stats.intellect * 0.4 + (p.arts.includes('shadow_step') ? 20 : 0);
|
||||
if (W.chance(U.clamp(sneak / 140 + 0.25, 0.2, 0.85))) {
|
||||
const pool = ['wind_saber', 'poison_needle', 'iron_body', 'cloud_step', 'yin_art', 'yang_art', 'staff_dog', 'heal_qi', 'shadow_step', 'thunder_qi'];
|
||||
const unknown = pool.filter(a => !p.arts.includes(a));
|
||||
if (unknown.length) {
|
||||
const art = W.pick(unknown); S.learnArt(st, p, art);
|
||||
S.addRep(st, { deception: 4, fear: 2 });
|
||||
return { txt: `Three roofs, one sleeping dog, and a lacquer case lighter by one manual. The ${W.artById(art).n} is yours. Stealing techniques is the oldest tradition in the jianghu.`, stolen: art };
|
||||
}
|
||||
return { txt: 'You memorize guard rotations and slip out unseen. Knowledge weighs nothing.' , intel: 1};
|
||||
}
|
||||
if (W.chance(0.4)) return { combat: { enemies: S.encounterFor(st, Math.max(2, S.locDef(st).danger)), context: 'caught' }, txt: 'A floorboard, a shout, lanterns blooming in every doorway—' };
|
||||
return { txt: 'Dogs. It is always dogs. You escape minus some dignity and one sleeve.', fear: 1 };
|
||||
};
|
||||
S.actionChallengeMaster = function (st) {
|
||||
const p = S.player(st);
|
||||
const power = 0.9 + st.day / 110 + st.rep.fame / 150;
|
||||
return { combat: { enemies: [{ id: 'master_foe', mul: power }], context: 'duel' }, txt: 'You send a formal challenge by arrow-and-letter. By dusk, a master stands at the far end of the field, cracking their knuckles like distant thunder.' };
|
||||
};
|
||||
S.actionAttackRival = function (st, rivalId) {
|
||||
const r = S.rivals(st).find(x => x.id === rivalId);
|
||||
if (!r) return { txt: '' };
|
||||
return { combat: { enemies: [], context: 'raid', rivalId }, txt: `You march on the ${r.n} under a grey dawn.` };
|
||||
};
|
||||
S.actionTrade = function (st) { return { trade: true }; };
|
||||
S.actionRest = function (st) {
|
||||
const eff = S.sectEffects(st);
|
||||
for (const c of S.party(st)) {
|
||||
const heal = Math.round((12 + c.stats.spirit * 0.2) * (1 + (st.atHome ? eff.healMul : 0)));
|
||||
c.hp = U.clamp(c.hp + heal, 0, c.maxHp); c.qi = U.clamp(c.qi + 15, 0, c.maxQi);
|
||||
}
|
||||
return { txt: 'Fire, food, and the deep quiet of people who trust their watch.' };
|
||||
};
|
||||
S.actionProtectVillage = function (st) {
|
||||
if (!['village', 'town'].includes(S.locDef(st).type)) return { txt: 'No villages nearby need shadows on their walls.' };
|
||||
S.addRep(st, { fame: 3, mercy: 2 }); S.factionShift(st, { orthodox: 1 });
|
||||
st.flags.patrolled = st.day;
|
||||
return { txt: 'You walk the perimeter through the small hours. Bandits read patrols the way wolves read fires.', combat: W.chance(0.3) ? { enemies: S.encounterFor(st, 2), context: 'protect' } : null };
|
||||
};
|
||||
|
||||
/* ================= combat bridging ================= */
|
||||
S.buildCombatEnemies = function (st, spec) {
|
||||
if (spec.enemies && spec.enemies.length) {
|
||||
const pm = spec.powerMul || 1;
|
||||
return W.combat.makeUnits(st, spec.enemies.map(e => {
|
||||
const o = typeof e === 'string' ? { id: e, mul: 1 + st.day / 130 * S.diff(st).enemy } : Object.assign({ mul: pm }, e);
|
||||
if (typeof e === 'string' && pm !== 1) o.mul *= pm;
|
||||
return o;
|
||||
}));
|
||||
}
|
||||
if (spec.sparVs) return W.combat.makeUnits(st, [], { sparChar: st.chars[spec.sparVs] });
|
||||
if (spec.masterArt) return W.combat.makeUnits(st, [{ id: 'master_foe', mul: spec.masterPower || 1, art: spec.masterArt }]);
|
||||
if (spec.rivalId) {
|
||||
const r = S.rivals(st).find(x => x.id === spec.rivalId);
|
||||
const n = U.clamp(Math.ceil(r.power / 14), 2, 5);
|
||||
const list = []; for (let i = 0; i < n - 1; i++) list.push({ id: 'rival_disciple', mul: 1 + st.day / 140 }); list.push({ id: 'rival_elder', mul: 0.9 + r.power / 70 });
|
||||
return W.combat.makeUnits(st, list);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
S.startCombat = function (st, spec) {
|
||||
st.pendingCombat = spec;
|
||||
return spec;
|
||||
};
|
||||
S.applyCombatResult = function (st, res) {
|
||||
// res: {win, fled, context, spec, loot:{gold,items,xp}, deaths:[ids]}
|
||||
const ctx = res.context;
|
||||
if (ctx === 'spar') {
|
||||
const c = res.spec && res.spec._sparChar;
|
||||
if (res.win && c) { if (S.addDisciple(st, c)) { S.adjustRel(st, c.id, 12); return { txt: `${c.name} concedes with a real smile. "A sect worth losing to," they say, and picks up their bedroll.`, joined: true }; } return { txt: 'They yield — but your halls are full. They bow and go.' }; }
|
||||
return { txt: `${c ? c.name : 'They'} stands you up twice and leaves respectfully unconvinced. Spar again another day.` };
|
||||
}
|
||||
if (ctx === 'master_duel') {
|
||||
const art = res.spec && res.spec.masterArt;
|
||||
if (res.win) {
|
||||
st.stats.duels++;
|
||||
if (art) S.learnArt(st, S.player(st), art);
|
||||
S.addRep(st, { fame: 8, fear: 3 });
|
||||
return { txt: `The master concedes with the happiest expression you have ever seen on a defeated person. "The style needed a new heir anyway." ${(art ? 'They teach you the ' + W.artById(art).n + '.' : '')}` };
|
||||
}
|
||||
return { txt: 'You wake at dusk with cracked ribs and a bucket of advice delivered at high speed. The master\'s point stands.' };
|
||||
}
|
||||
if (ctx === 'raid') {
|
||||
const r = S.rivals(st).find(x => x.id === (res.spec && res.spec.rivalId));
|
||||
if (res.win && r) {
|
||||
r.power -= 25; S.addRep(st, { fame: 8, fear: 8 }); st.stats.kills += 2;
|
||||
S.factionShift(st, { orthodox: 2 });
|
||||
if (r.power <= 0) {
|
||||
r.alive = false; st.stats.sectsDestroyed++;
|
||||
S.addChron(st, `The ${r.n} banners came down. Their gate plaque lies broken in the weeds.`, 'dark');
|
||||
return { txt: `The ${r.n} is finished. You take their manuals and their banner, and leave the mountain to the crows.` };
|
||||
}
|
||||
return { txt: `The ${r.n} reels from the blow — stores burned, elders humbled. They will remember this.` };
|
||||
}
|
||||
if (!res.win) { S.addRep(st, { fame: -4, morale: -5 }); return { txt: 'Bloodied, you withdraw down the mountain. Raids fail; legends note it and wait.' }; }
|
||||
}
|
||||
// generic contexts
|
||||
if (res.win) {
|
||||
st.stats.kills += (res.enemyCount || 2);
|
||||
const fx = {};
|
||||
if (ctx === 'arena') { }
|
||||
if (ctx === 'defense') { S.addRep(st, { fame: 4, morale: 4 }); }
|
||||
if (ctx === 'hunt' || ctx === 'beast') st.res.food += 4;
|
||||
return fx;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
/* ================= events engine ================= */
|
||||
S.eventCooldown = 0;
|
||||
S.rollEvent = function (st, cat, forcedId) {
|
||||
if (forcedId) { const d = W.EVENT_BY_ID[forcedId]; return d && S.prepareEvent(st, d) ? d : null; }
|
||||
const locType = S.locDef(st).type;
|
||||
const cands = [];
|
||||
for (const ev of W.EVENTS) {
|
||||
if (ev.cat !== cat) continue;
|
||||
if (ev.once && st.flags['ev_' + ev.id]) continue;
|
||||
if (cat === 'world' && ev.loc && !ev.loc.includes(locType)) continue;
|
||||
if (ev.when && !ev.when(st)) continue;
|
||||
if (st.day < (ev.minDay || 0) || st.day > (ev.maxDay || 999)) continue;
|
||||
cands.push(ev);
|
||||
}
|
||||
if (!cands.length) return null;
|
||||
const def = W.pickW(cands.map(e => [e, e.weight || 10]));
|
||||
return S.prepareEvent(st, def) ? def : null;
|
||||
};
|
||||
S.prepareEvent = function (st, def) {
|
||||
let lines = typeof def.text === 'function' ? def.text(st) : def.text;
|
||||
if (!lines) return false;
|
||||
if (typeof lines === 'string') lines = [lines];
|
||||
const choices = def.choices.map(ch => {
|
||||
let ok = true, why = '';
|
||||
if (ch.cost) for (const k in ch.cost) if ((st.res[k] || 0) < ch.cost[k] && !(k === 'gold' && false)) { ok = false; why = `needs ${ch.cost[k]} ${W.C.RES_N[k]}`; }
|
||||
if (ok && ch.req) { try { ok = !!ch.req(st); } catch (e) { ok = false; } if (!ok && !why) why = 'requirements unmet'; }
|
||||
return { t: ch.t, ok, why };
|
||||
});
|
||||
let speaker = null;
|
||||
if (def.speaker !== undefined) { try { speaker = typeof def.speaker === 'function' ? def.speaker(st) : def.speaker; } catch (e) { speaker = null; } }
|
||||
st.pendingEvent = { id: def.id, title: def.n, cn: def.cn, lines, choices, speaker, scene: def.scene || S.locDef(st).theme };
|
||||
st._pendingDef = def;
|
||||
return true;
|
||||
};
|
||||
S.chooseEvent = function (st, idx) {
|
||||
const def = st._pendingDef, pe = st.pendingEvent;
|
||||
if (!def || !pe) return null;
|
||||
const ch = def.choices[idx];
|
||||
st.flags['ev_' + def.id] = true;
|
||||
st.pendingEvent = null; st._pendingDef = null;
|
||||
if (!ch) return null;
|
||||
if (ch.cost) for (const k in ch.cost) st.res[k] = Math.max(0, (st.res[k] || 0) - ch.cost[k]);
|
||||
let fx = {};
|
||||
try { fx = ch.fx ? (ch.fx(st) || {}) : {}; } catch (e) { console.error('event fx error', def.id, e); fx = { txt: 'The moment passes strangely.' }; }
|
||||
if (fx && fx.txt !== undefined) S.applyFx(st, fx, def);
|
||||
return fx || {};
|
||||
};
|
||||
|
||||
S.applyFx = function (st, fx, def) {
|
||||
if (!fx) return;
|
||||
S.addRes(st, fx);
|
||||
S.addRep(st, fx);
|
||||
if (fx.faction) S.factionShift(st, fx.faction);
|
||||
if (fx.flag) st.flags[Array.isArray(fx.flag) ? fx.flag[0] : fx.flag] = true;
|
||||
if (fx.flag2) st.flags[fx.flag2] = true;
|
||||
if (fx.art) S.learnArt(st, S.player(st), fx.art);
|
||||
if (fx.item) { S.addItem(st, fx.item); st.stats.treasures++; }
|
||||
if (fx.items) for (const i of fx.items) S.addItem(st, i);
|
||||
if (fx.healParty) for (const c of S.party(st)) c.hp = c.maxHp;
|
||||
if (fx.injurePlayer) { const p = S.player(st); p.injuryDays = Math.max(p.injuryDays, fx.injurePlayer); p.hp = Math.max(1, p.hp * 0.6 | 0); }
|
||||
if (fx.innerGain) S.gainInner(st, S.player(st), fx.innerGain);
|
||||
if (fx.xpAll) for (const c of S.roster(st)) c.xp.martial += fx.xpAll;
|
||||
if (fx.weaponSkillXp) { const p = S.player(st); p.skills.sword = U.clamp((p.skills.sword || 10) + fx.weaponSkillXp * 0.5, 0, 100); }
|
||||
if (fx.leave) { const c = st.chars[fx.leave]; if (c) { c.alive = false; c.left = true; st.flags.someone_left = true; S.addMorale(st, -3); } }
|
||||
if (fx.relPlayerDelta && fx.relPlayerDelta.id) S.adjustRel(st, fx.relPlayerDelta.id, fx.relPlayerDelta.d);
|
||||
if (fx.relDisciple && fx.relDisciple.id) S.adjustRel(st, fx.relDisciple.id, fx.relDisciple.d);
|
||||
if (fx.relPair) { const a = st.chars[fx.relPair.a], b = st.chars[fx.relPair.b]; if (a && b) { a.rels[b.id] = U.clamp((a.rels[b.id] || 0) + fx.relPair.d, -100, 100); b.rels[a.id] = a.rels[b.id]; } }
|
||||
if (fx.loyaltyBias) { /* flavor */ }
|
||||
if (fx.romanceAdvance) {
|
||||
const c = st.chars[fx.romanceAdvance], p = S.player(st);
|
||||
if (c && !c.romance && !p.romance) { c.romance = { partnerId: p.id, stage: 1 }; p.romance = { partnerId: c.id, stage: 1 }; st.stats.romances++; S.remember(c, 'Moonrise on the wall. Everything changed.'); W.ach && W.ach.check('romance'); }
|
||||
}
|
||||
if (fx.healDisciple) { const c = st.chars[fx.healDisciple]; if (c) c.hp = c.maxHp; }
|
||||
if (fx.xpDisciple) { const c = st.chars[fx.xpDisciple]; if (c) for (const id of c.equipped) { const a = W.artById(id); if (a && a.stat) c.skills[a.stat] = U.clamp((c.skills[a.stat] || 10) + 10, 0, 100); } }
|
||||
if (fx.rumor || fx.rumorPool) { const n = fx.rumorPool || 1; for (let i = 0; i < n; i++) S.addRumor(st); }
|
||||
if (fx.revealLocation) { const lid = String(fx.revealLocation).replace('_marked', ''); const l = st.world.locs[lid]; if (l) l.discovered = true; }
|
||||
if (fx.recruit) {
|
||||
const spec = typeof fx.recruit === 'object' ? fx.recruit : {};
|
||||
const c = S.genDisciple(st, spec);
|
||||
if (!S.addDisciple(st, c)) fx.txt = (fx.txt || '') + ' (But the quarters are full — they drift on.)';
|
||||
}
|
||||
if (fx.travelDelay && st.travel) st.travel.left += 1;
|
||||
if (fx.chronicle) S.addChron(st, fx.chronicle, 'minor');
|
||||
if (fx.tribute) { const g = Math.min(st.res.gold, 80); st.res.gold -= g; }
|
||||
if (fx.post) try { fx.post(st); } catch (e) { }
|
||||
if (fx.combat) {
|
||||
const spec = fx.combat;
|
||||
spec.rewardFx = fx.winReward || null; spec.loseFx = fx.losePenalty || null;
|
||||
spec.rewardFame = fx.rewardFame || 0; spec.winFame = fx.winFame || 0; winFearStore(spec, fx);
|
||||
spec.intro = fx.introExtra || spec.intro;
|
||||
st.pendingCombat = spec;
|
||||
}
|
||||
};
|
||||
function winFearStore(spec, fx) { if (fx.winFear) spec.winFear = fx.winFear; }
|
||||
|
||||
/* ================= END OF DAY ================= */
|
||||
S.endDay = function (st) {
|
||||
const log = [];
|
||||
const season = W.seasonOf(st.day);
|
||||
const eff = S.sectEffects(st);
|
||||
const dif = S.diff(st);
|
||||
|
||||
// 1) travel
|
||||
if (st.travel) {
|
||||
const tr = S.tickTravel(st);
|
||||
if (tr && tr.encounter) { log.push({ kind: 'encounter' }); }
|
||||
else if (tr && tr.arrived) log.push({ kind: 'arrived', loc: tr.arrived });
|
||||
if (st.travel) log.push({ kind: 'msg', txt: `On the road to ${W.locById(st.travel.to).n}… (${st.travel.total - st.travel.left}/${st.travel.total} days)` });
|
||||
}
|
||||
|
||||
// 2) food
|
||||
const heads = S.roster(st).length;
|
||||
let eat = heads * 0.55 * (season.id === 'winter' ? 1.5 : 1) * dif.food;
|
||||
eat *= (1 - Math.min(0.4, eff.foodSave));
|
||||
eat = Math.max(1, Math.round(eat));
|
||||
if (st.res.food >= eat) { st.res.food -= eat; }
|
||||
else {
|
||||
st.res.food = 0; S.addMorale(st, -6);
|
||||
log.push({ kind: 'msg', txt: 'The rice jar is empty. Hunger walks the halls like a debt collector.', warn: true });
|
||||
for (const c of S.roster(st)) { c.hp = Math.max(1, c.hp - 6); c.loyalty = U.clamp(c.loyalty - 2, 0, 100); }
|
||||
st.flags.starving = true;
|
||||
}
|
||||
|
||||
// 3) production & construction
|
||||
for (const id in st.assign || {}) {
|
||||
const c = st.chars[id]; if (!c || !c.alive || c.atSect === false) continue;
|
||||
const a = st.assign[id];
|
||||
if (a === 'work_wood') st.res.wood += W.ri(2, 4);
|
||||
else if (a === 'work_iron') st.res.iron += W.ri(1, 3);
|
||||
else if (a === 'work_herb') st.res.medicine += W.ri(1, 2);
|
||||
else if (a === 'work_food') st.res.food += W.ri(2, 4);
|
||||
else if (a === 'train') S.actionTrain(st, id);
|
||||
else if (a === 'meditate') S.actionMeditate(st, id);
|
||||
}
|
||||
if (eff.medicinePerDay) st.res.medicine += W.chance(eff.medicinePerDay % 1) ? Math.ceil(eff.medicinePerDay) : Math.floor(eff.medicinePerDay);
|
||||
// construction queue
|
||||
if (st.sect.queue.length) {
|
||||
const job = st.sect.queue[0];
|
||||
job.left--;
|
||||
if (job.left <= 0) {
|
||||
st.sect.queue.shift();
|
||||
st.sect.buildings[job.id] = (st.sect.buildings[job.id] || 0) + 1;
|
||||
const bd = W.buildingById(job.id);
|
||||
S.addChron(st, `Construction complete: ${bd.n} (${bd.cn}).`, 'bright');
|
||||
log.push({ kind: 'build', id: job.id });
|
||||
W.ach && W.ach.check('builder', st);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) recovery
|
||||
for (const c of S.roster(st)) {
|
||||
if (c.injuryDays > 0) {
|
||||
c.injuryDays--;
|
||||
const heal = Math.round(c.maxHp * (0.08 + eff.healMul * 0.15));
|
||||
c.hp = U.clamp(c.hp + heal, 1, c.maxHp);
|
||||
if (S.hasItem(st, 'medicine') && c.hp < c.maxHp * 0.5 && W.chance(0.5)) { S.useItemCount(st, 'medicine', 1); c.hp = U.clamp(c.hp + 25, 0, c.maxHp); }
|
||||
} else {
|
||||
c.hp = U.clamp(c.hp + Math.round(c.maxHp * 0.05), 0, c.maxHp);
|
||||
}
|
||||
c.qi = U.clamp(c.qi + 8, 0, c.maxQi);
|
||||
}
|
||||
|
||||
// 5) morale & loyalty
|
||||
let morDrift = (st.sect.morale < 50 ? 1 : -0.5) + eff.moralePerDay;
|
||||
if (st.flags.starving) morDrift -= 2;
|
||||
S.addMorale(st, morDrift);
|
||||
st.flags.starving = false;
|
||||
for (const c of S.disciples(st)) {
|
||||
let ld = 0;
|
||||
ld += st.sect.morale > 65 ? 0.4 : (st.sect.morale < 35 ? -0.8 : 0);
|
||||
ld += c.relPlayer > 55 ? 0.3 : (c.relPlayer < 25 ? -0.5 : 0);
|
||||
if (c.hidden && c.hidden.id === 'spy_demon' && !c.hiddenRevealed) ld -= 0.6;
|
||||
if (c.traits.includes('loyal')) ld += 0.2;
|
||||
if (c.traits.includes('ambitious') && st.rep.fame < 30) ld -= 0.15;
|
||||
c.loyalty = U.clamp(c.loyalty + ld, 0, 100);
|
||||
if (c.romance && c.romance.partnerId === st.player) c.loyalty = U.clamp(c.loyalty + 0.2, 0, 100);
|
||||
}
|
||||
// relationship drift among disciples
|
||||
const ds = S.disciples(st);
|
||||
for (let i = 0; i < ds.length; i++) for (let j = i + 1; j < ds.length; j++) {
|
||||
const a = ds[i], b = ds[j];
|
||||
const bothOut = !a.atSect && !b.atSect;
|
||||
const cur = a.rels[b.id] || 0;
|
||||
let d = bothOut ? 0.8 : 0.15;
|
||||
if ((a.traits.includes('proud') && b.traits.includes('hot_headed')) || (b.traits.includes('proud') && a.traits.includes('hot_headed'))) d -= 0.5;
|
||||
if (cur > 70) d += 0.2;
|
||||
a.rels[b.id] = U.clamp(cur + d, -100, 100); b.rels[a.id] = a.rels[b.id];
|
||||
}
|
||||
|
||||
// 6) factions drift & war clock
|
||||
for (const k in st.factionRel) {
|
||||
const base = k === 'demon' || k === 'bandit' ? -10 : 0;
|
||||
const v = st.factionRel[k];
|
||||
st.factionRel[k] = U.clamp(v + (base - v) * 0.01 + W.rf(-0.8, 0.8), -100, 100);
|
||||
}
|
||||
if (st.day >= 30 && !st.war.declared) {
|
||||
st.war.tension += W.rf(0.6, 1.4) * (1 + S.knownForbidden(st).length * 0.3);
|
||||
if (st.war.tension >= 45 && st.day >= 45) {
|
||||
st.war.declared = true;
|
||||
log.push({ kind: 'event', cat: 'war' });
|
||||
}
|
||||
}
|
||||
|
||||
// 7) sect event chance at home
|
||||
if (S.atHome(st) && W.chance(0.38 * dif.event)) {
|
||||
const ev = S.rollEvent(st, 'sect');
|
||||
if (ev) log.push({ kind: 'event', id: ev.id });
|
||||
}
|
||||
|
||||
// 8) rival sect activity
|
||||
for (const r of S.rivals(st)) {
|
||||
if (W.chance(0.06)) {
|
||||
r.power += W.ri(1, 4);
|
||||
if (r.rel < -25 && W.chance(0.3) && st.day > 20) {
|
||||
log.push({ kind: 'raid_incoming', rival: r.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9) late-game escalation
|
||||
if (st.day === 88 && !st.war.finalDone) log.push({ kind: 'final_warning' });
|
||||
|
||||
// 10) advance
|
||||
st.day++;
|
||||
st.phase = 0;
|
||||
st.ap = S.apMax(st);
|
||||
S.rollWeather(st);
|
||||
if (st.day > W.C.DAYS && !st.ended) {
|
||||
st.ended = true; st.endingId = S.computeEnding(st);
|
||||
log.push({ kind: 'end' });
|
||||
}
|
||||
// autosave hook
|
||||
return log;
|
||||
};
|
||||
S.apMax = st => S.sectEffects(st).apMax;
|
||||
|
||||
S.doAction = function (st, actionId, arg) {
|
||||
if (st.ap <= 0) return { err: 'No strength left today. Rest, or end the day.' };
|
||||
if (st.phase < 3) st.phase++;
|
||||
let r = {};
|
||||
switch (actionId) {
|
||||
case 'explore': r = S.explore(st); break;
|
||||
case 'hunt': r = S.actionHunt(st); break;
|
||||
case 'train': r = S.actionTrain(st, arg); break;
|
||||
case 'meditate': r = S.actionMeditate(st, arg); break;
|
||||
case 'recruit': r = S.actionRecruit(st); break;
|
||||
case 'spy': r = S.actionSpy(st); break;
|
||||
case 'challenge': r = S.actionChallengeMaster(st); break;
|
||||
case 'trade': r = S.actionTrade(st); break;
|
||||
case 'rest': r = S.actionRest(st); break;
|
||||
case 'protect': r = S.actionProtectVillage(st); break;
|
||||
case 'gather': r = S.gatherResult(st); break;
|
||||
default: r = { txt: '' };
|
||||
}
|
||||
if (!r.err) st.ap--;
|
||||
return r;
|
||||
};
|
||||
|
||||
/* ================= war & final battle ================= */
|
||||
S.finalInvasion = function (st) {
|
||||
st.war.finalDone = true;
|
||||
const enemySide = st.rep.fear > 55 || st.factionRel.imperial < -40 ? 'imperial' : 'demon';
|
||||
st.war.enemySide = enemySide;
|
||||
const enemies = enemySide === 'demon'
|
||||
? [{ id: 'cult_adept', mul: 1.1 + st.day / 120 }, { id: 'cultist', mul: 1.2 }, { id: 'cultist', mul: 1.1 }, { id: 'warlord', mul: 0.95 }]
|
||||
: [{ id: 'soldier', mul: 1.2 }, { id: 'soldier', mul: 1.1 }, { id: 'officer', mul: 1 }, { id: 'officer', mul: 0.95 }];
|
||||
return { enemySide, spec: { enemies, context: 'final', intro: 'Horns from the valley. Banners without number. The last battle comes up the mountain road.' } };
|
||||
};
|
||||
S.defenseBattle = function (st, rivalId) {
|
||||
const r = S.rivals(st).find(x => x.id === rivalId);
|
||||
const n = U.clamp(Math.ceil((r ? r.power : 30) / 12), 2, 5);
|
||||
const list = []; for (let i = 0; i < n; i++) list.push({ id: 'rival_disciple', mul: 0.9 + st.day / 150 });
|
||||
list.push({ id: 'rival_elder', mul: 0.85 + (r ? r.power : 30) / 80 });
|
||||
return { enemies: list, context: 'defense', rivalId, intro: `Torches snake up the mountain path. The ${r ? r.n : 'enemy'} has come to collect.` };
|
||||
};
|
||||
|
||||
/* ================= endings ================= */
|
||||
S.computeEnding = function (st) {
|
||||
const p = S.player(st);
|
||||
if (!p.alive) return 'martyr';
|
||||
if (st.flags.sect_destroyed) return 'destroyed';
|
||||
const forb = S.knownForbidden(st).length;
|
||||
const darkPath = forb >= 2 && (st.rep.honor < -5 || st.rep.fear > 45);
|
||||
if (st.flags.abandoned_jianghu) return 'hermit';
|
||||
if (darkPath) return 'demonic';
|
||||
const inheritance = (st.flags.found_nine_yin || st.playerArtsNineYang || p.arts.includes('nine_yang'));
|
||||
if (inheritance && st.rep.fame >= 55 && p.realm >= 4) return 'newera';
|
||||
if (st.war.side === 'orthodox' && st.flags.won_final) return 'alliance';
|
||||
if (st.stats.sectsDestroyed >= 2 && st.rep.fear >= 45) return 'conqueror';
|
||||
if (st.flags.betrayed_alliance) return 'betrayer';
|
||||
if (st.rep.fame >= 85) return 'legend';
|
||||
if (st.rep.fame >= 40 && S.disciples(st).length >= 5) return 'prosperous';
|
||||
return 'wanderer';
|
||||
};
|
||||
|
||||
W.ENDINGS = {
|
||||
legend: { n: 'THE SWORD THAT CHANGED THE JIANGHU', cn: '改写江湖之剑', d: 'Songs are sung of your sect in teahouses from here to the capital. Children practice your forms with sticks. The jianghu bends around the shape of your name.' },
|
||||
newera: { n: 'DAWN OF A NEW MARTIAL ERA', cn: '武林新纪元', d: 'Ancient scriptures mastered, a new generation raised on your mountain: historians will mark this century by your sect. A new era of the jianghu begins — yours.' },
|
||||
alliance: { n: 'PILLAR OF THE ORTHODOX ALLIANCE', cn: '正道柱石', d: 'When the smoke cleared, it was your banners the alliance rallied to. You saved the orthodox world, and the orthodox world knows it.' },
|
||||
conqueror: { n: 'THE IRON LOTUS THRONE', cn: '铁莲之座', d: 'Rival sects kneel or scatter. The jianghu speaks your name quietly, the way sailors speak of storms. You did not seek love. You built order.' },
|
||||
demonic: { n: 'THE DEMON OF THE MOUNTAIN', cn: '魔道之主', d: 'The forbidden arts gave everything and took payment in kind. Powerful, feared, and alone at the top of a very quiet mountain.' },
|
||||
hermit: { n: 'THE ONE WHO LAID DOWN THE SWORD', cn: '归隐山林', d: 'You walked away at the height of the storm. Somewhere a valley keeps bees, and a famous sword rusts decoratively by the door.' },
|
||||
betrayer: { n: 'THE KNIFE IN THE ALLIANCE', cn: '背盟之刃', d: 'You won. That is what you tell yourself, some nights, when the wind sounds like old friends closing a gate.' },
|
||||
prosperous: { n: 'A RESPECTED NAME UNDER HEAVEN', cn: '名扬四海', d: 'Not legend — yet. A strong sect, loyal students, full granaries, and roads that are safer because you walk them.' },
|
||||
wanderer: { n: 'THE WANDERING CLAN', cn: '流浪门派', d: 'The sect survives the way rivers survive mountains: by moving. Your people carry the school with them, and home is wherever the fire is.' },
|
||||
martyr: { n: 'THE MASTER WHO DIED STANDING', cn: '宁死不退', d: 'You fell so the gates could hold. Disciples buried you facing the mountain road, where enemies come from. Pilgrims still bow there.' },
|
||||
destroyed: { n: 'ASHES ON THE WIND', cn: '灰飞烟灭', d: 'The mountain is silent. The plaque lies in weeds. What the jianghu gives, the jianghu sometimes takes back.' },
|
||||
};
|
||||
|
||||
/* expose */
|
||||
W.st_day = st_day;
|
||||
})();
|
||||
W.sectHas = W.sim.sectHas; W.sim.playerArtsNineYang=0;
|
||||
@@ -0,0 +1,478 @@
|
||||
/* =========================================================================
|
||||
Tactical combat: isometric grid, techniques, statuses, AI, loot.
|
||||
Engine is synchronous; UI drives pacing. Emits BUS events for FX.
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const U = W.U;
|
||||
const CB = W.combat = {};
|
||||
|
||||
const GRID_W = 13, GRID_H = 9;
|
||||
|
||||
/* ---------------- grid generation ---------------- */
|
||||
function genGrid(theme) {
|
||||
const t = [];
|
||||
for (let y = 0; y < GRID_H; y++) { t.push([]); for (let x = 0; x < GRID_W; x++) t[y].push(0); }
|
||||
const seedKey = theme + Math.floor(W.rng() * 1e6);
|
||||
// obstacles clusters
|
||||
const nClusters = W.ri(4, 7);
|
||||
for (let i = 0; i < nClusters; i++) {
|
||||
const cx = W.ri(2, GRID_W - 3), cy = W.ri(0, GRID_H - 1);
|
||||
const n = W.ri(1, 3);
|
||||
for (let j = 0; j < n; j++) {
|
||||
const x = U.clamp(cx + W.ri(-1, 1), 0, GRID_W - 1), y = U.clamp(cy + W.ri(-1, 1), 0, GRID_H - 1);
|
||||
t[y][x] = W.chance(0.8) ? 1 : 2;
|
||||
}
|
||||
}
|
||||
// keep spawn columns clear
|
||||
for (let y = 0; y < GRID_H; y++) { for (const x of [0, 1, 2, GRID_W - 3, GRID_W - 2, GRID_W - 1]) if (t[y][x] === 1 && W.chance(0.7)) t[y][x] = 0; }
|
||||
return { w: GRID_W, h: GRID_H, tiles: t };
|
||||
}
|
||||
|
||||
/* ---------------- units ---------------- */
|
||||
CB.makeUnits = function (st, specs, opt) {
|
||||
opt = opt || {};
|
||||
const out = [];
|
||||
for (const sp of specs) {
|
||||
const e = W.enemyById(sp.id); if (!e) continue;
|
||||
const mul = sp.mul || 1;
|
||||
out.push({
|
||||
side: 'enemy', templateId: sp.id, name: e.n, cn: e.cn,
|
||||
hp: Math.round(e.hp * mul), maxHp: Math.round(e.hp * mul),
|
||||
atk: Math.round(e.atk * mul * 0.9), def: Math.round(e.def * mul * 0.9), spd: e.spd,
|
||||
qi: 60, maxQi: 60,
|
||||
arts: sp.art ? [sp.art] : (e.arts || []).slice(),
|
||||
boss: !!e.boss, beast: !!e.beast, undead: !!e.undead,
|
||||
crit: e.tier >= 4 ? 0.08 : 0.04, dodge: 0.02, lifesteal: 0,
|
||||
statuses: [], cds: {}, dead: false, sprite: e.boss ? 'boss' : (e.beast ? 'beast' : 'foe'),
|
||||
lootGold: e.loot ? e.loot.gold : null, lootItems: e.loot ? e.loot.items : null, tier: e.tier,
|
||||
});
|
||||
if (sp.art === undefined && out[out.length - 1].arts.includes('blood_demon')) out[out.length - 1].lifesteal = 0.2;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function allyFromChar(st, c) {
|
||||
const eq = W.simHelpers.equipBonuses(c);
|
||||
return {
|
||||
side: 'ally', charId: c.id, ref: c, name: c.isPlayer ? (c.name || 'You') : c.name, cn: c.cn,
|
||||
hp: Math.max(1, Math.round(c.hp)), maxHp: c.maxHp,
|
||||
qi: Math.round(c.qi), maxQi: c.maxQi,
|
||||
atk: W.sim.attackOf(st, c), def: W.sim.defenseOf(st, c), spd: W.sim.speedOf(st, c),
|
||||
arts: (c.equipped || []).slice(),
|
||||
crit: 0.06 + eq.crit, dodge: eq.dodge || 0, lifesteal: eq.lifesteal || 0, regen: eq.regen || 0,
|
||||
statuses: [], cds: {}, dead: false, sprite: c.isPlayer ? 'player' : 'ally',
|
||||
isPlayer: !!c.isPlayer,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- create ---------------- */
|
||||
CB.create = function (st, spec) {
|
||||
const theme = W.sim.locDef(st).theme || 'wild';
|
||||
const grid = genGrid(theme);
|
||||
const units = [];
|
||||
// allies: player first, then up to 3 party members
|
||||
const p = W.sim.player(st);
|
||||
units.push(allyFromChar(st, p));
|
||||
let nAllies = 3;
|
||||
if (spec.champion) { units.push(allyFromChar(st, st.chars[spec.champion])); nAllies--; }
|
||||
for (const id of st.party) {
|
||||
if (nAllies <= 0) break;
|
||||
const c = st.chars[id];
|
||||
if (!c || !c.alive || c.isPlayer || id === spec.champion) continue;
|
||||
units.push(allyFromChar(st, c)); nAllies--;
|
||||
}
|
||||
let foes = (spec._foes && spec._foes.length) ? spec._foes : CB.makeUnits(st, (spec.enemies || []).map(e => (spec.powerMul && typeof e === 'object') ? Object.assign({ mul: spec.powerMul }, e) : e));
|
||||
if (spec.sparChar) {
|
||||
const c = spec.sparChar;
|
||||
const u = allyFromChar(st, c);
|
||||
u.side = 'enemy'; u.sprite = 'rival'; u.spar = true;
|
||||
u.name = c.name;
|
||||
foes = [u];
|
||||
spec._sparChar = c;
|
||||
}
|
||||
// placement
|
||||
placeUnits(grid, units.filter(u => u.side === 'ally'), 0);
|
||||
placeUnits(grid, foes, 1);
|
||||
units.push(...foes);
|
||||
|
||||
// unstable combo backlash at start
|
||||
for (const u of units) if (u.side === 'ally') {
|
||||
const unst = W.unstableCombos(W.combosFor(u.arts));
|
||||
for (const uc of unst) if (W.chance(uc.unstable.ch)) {
|
||||
u.hp = Math.max(1, u.hp - uc.unstable.hp); u.qi = Math.max(0, u.qi - (uc.unstable.qi || 0));
|
||||
CB.log(st, `${u.name}: ${uc.unstable.txt}. (-${uc.unstable.hp} hp)`);
|
||||
}
|
||||
}
|
||||
|
||||
const cbt = {
|
||||
spec, grid, units, round: 1, order: [], oi: 0, over: false, result: null,
|
||||
context: spec.context, theme,
|
||||
logLines: [], lethal: !['spar', 'master_duel', 'arena'].includes(spec.context),
|
||||
};
|
||||
CB.newRound(st, cbt);
|
||||
st.combat = cbt;
|
||||
W.BUS.emit('combat_start', cbt);
|
||||
return cbt;
|
||||
};
|
||||
|
||||
function placeUnits(grid, units, sideFlag) {
|
||||
const cols = sideFlag === 0 ? [0, 1, 2] : [grid.w - 1, grid.w - 2, grid.w - 3];
|
||||
let i = 0;
|
||||
for (const u of units) {
|
||||
let placed = false;
|
||||
for (let tries = 0; tries < 40 && !placed; tries++) {
|
||||
const x = cols[i % 3], y = (i * 2 + Math.floor(i / 3)) % grid.h;
|
||||
if (!occupied(grid, units, x, y) && grid.tiles[y][x] !== 1) { u.x = x; u.y = y; placed = true; }
|
||||
else if (tries > 20) { u.x = x; u.y = (y + tries) % grid.h; placed = true; }
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
function occupied(grid, units, x, y) { return units.some(u => !u.dead && u.x === x && u.y === y); }
|
||||
function tileAt(grid, x, y) { return (x < 0 || y < 0 || x >= grid.w || y >= grid.h) ? 1 : grid.tiles[y][x]; }
|
||||
CB.unitAt = (cbt, x, y) => cbt.units.find(u => !u.dead && u.x === x && u.y === y) || null;
|
||||
|
||||
CB.log = function (st, txt) {
|
||||
if (st.combat) { st.combat.logLines.push(txt); if (st.combat.logLines.length > 40) st.combat.logLines.shift(); }
|
||||
W.BUS.emit('combat_log', txt);
|
||||
};
|
||||
|
||||
/* ---------------- rounds & order ---------------- */
|
||||
CB.newRound = function (st, cbt) {
|
||||
cbt.order = cbt.units.filter(u => !u.dead).sort((a, b) => (b.spd + W.rf(0, 1.5)) - (a.spd + W.rf(0, 1.5)));
|
||||
cbt.oi = 0;
|
||||
for (const u of cbt.units) {
|
||||
if (u.dead) continue;
|
||||
for (const k in u.cds) if (u.cds[k] > 0) u.cds[k]--;
|
||||
tickStatuses(st, cbt, u);
|
||||
if (u.regen) u.hp = Math.min(u.maxHp, u.hp + u.regen);
|
||||
// forbidden curse upkeep
|
||||
if (u.side === 'ally' && u.ref) {
|
||||
for (const id of u.arts) { const a = W.artById(id); if (a && a.curse && a.curse.hpPerTurn) { u.hp = Math.max(1, u.hp - a.curse.hpPerTurn); } }
|
||||
const unst = W.unstableCombos(W.combosFor(u.arts));
|
||||
for (const uc of unst) if (W.chance(uc.unstable.ch)) { u.hp = Math.max(1, u.hp - uc.unstable.hp); CB.log(st, `${u.name}: ${uc.unstable.txt}. (-${uc.unstable.hp})`); }
|
||||
}
|
||||
if (u.qi !== undefined) u.qi = Math.min(u.maxQi, u.qi + 6);
|
||||
}
|
||||
CB.checkEnd(st, cbt);
|
||||
};
|
||||
|
||||
function tickStatuses(st, cbt, u) {
|
||||
for (const s of u.statuses.slice()) {
|
||||
if (s.k === 'poison' || s.k === 'bleed' || s.k === 'burn') {
|
||||
u.hp -= s.val || 3;
|
||||
W.BUS.emit('float', { unit: u, txt: '-' + (s.val || 3), kind: s.k });
|
||||
s.dur--;
|
||||
} else if (s.k !== 'stun' && s.k !== 'root') s.dur--;
|
||||
if (s.dur <= 0) u.statuses.splice(u.statuses.indexOf(s), 1);
|
||||
}
|
||||
if (u.hp <= 0) killUnit(st, cbt, u);
|
||||
}
|
||||
|
||||
function hasStatus(u, k) { return u.statuses.some(s => s.k === k); }
|
||||
function addStatus(u, s) {
|
||||
if (s.k === 'poison' && u.side === 'enemy' && (u.undead)) { }
|
||||
const ex = u.statuses.find(x => x.k === s.k);
|
||||
if (ex) { ex.dur = Math.max(ex.dur, s.dur); ex.val = Math.max(ex.val || 0, s.val || 0); }
|
||||
else u.statuses.push(Object.assign({}, s));
|
||||
}
|
||||
|
||||
function killUnit(st, cbt, u) {
|
||||
if (u.dead) return;
|
||||
if (!cbt.lethal && u.side === 'ally') { u.hp = 1; return; }
|
||||
if (u.spar) { u.hp = 1; return; } // sparring partners yield below threshold instead
|
||||
u.dead = true; u.hp = 0;
|
||||
CB.log(st, `${u.name} falls.`);
|
||||
W.BUS.emit('death', u);
|
||||
W.audio && W.audio.sfx('death');
|
||||
if (u.side === 'ally' && u.ref) {
|
||||
if (!u.isPlayer && cbt.lethal) { u.ref.alive = false; W.sim.addChron(st, `${u.ref.name} fell in battle${contextLabel(cbt.context)}.`, 'dark'); W.sim.addMorale(st, -8); st.stats.deaths++; }
|
||||
else if (u.ref.isPlayer && cbt.lethal) { u.ref.alive = false; }
|
||||
}
|
||||
}
|
||||
function contextLabel(c) {
|
||||
return { explore: ' in the wilds', road: ' on the road', raid: ' raiding the rival sect', defense: ' defending the gates', final: ' in the last battle', ambush: ' in an ambush', protect: ' protecting villagers', hunt: ' on the hunt', caught: '', arena: '', duel: '' }[c] || '';
|
||||
}
|
||||
|
||||
CB.current = cbt => cbt.order[cbt.oi] || null;
|
||||
|
||||
CB.advance = function (st, cbt) {
|
||||
if (cbt.over) return null;
|
||||
let guard = 0;
|
||||
do {
|
||||
cbt.oi++;
|
||||
if (cbt.oi >= cbt.order.length && !cbt.over) { cbt.round++; CB.newRound(st, cbt); }
|
||||
guard++;
|
||||
} while (!cbt.over && guard <= cbt.order.length + 4 && cbt.order[cbt.oi] && cbt.order[cbt.oi].dead);
|
||||
CB.checkEnd(st, cbt);
|
||||
return CB.current(cbt);
|
||||
};
|
||||
|
||||
CB.checkEnd = function (st, cbt) {
|
||||
if (cbt.over) return;
|
||||
const foesAlive = cbt.units.some(u => u.side === 'enemy' && !u.dead);
|
||||
const alliesAlive = cbt.units.some(u => u.side === 'ally' && !u.dead);
|
||||
const sparFoe = cbt.units.find(u => u.spar);
|
||||
if (alliesAlive && !foesAlive) { endBattle(st, cbt, 'win'); return; }
|
||||
if (!alliesAlive) { endBattle(st, cbt, 'lose'); return; }
|
||||
if (sparFoe && sparFoe.hp <= sparFoe.maxHp * 0.25) { endBattle(st, cbt, 'win'); return; }
|
||||
if ((cbt.context === 'master_duel')) {
|
||||
const m = cbt.units.find(u => u.side === 'enemy');
|
||||
if (m && m.hp <= m.maxHp * 0.22) { endBattle(st, cbt, 'win'); return; }
|
||||
}
|
||||
};
|
||||
|
||||
function endBattle(st, cbt, outcome) {
|
||||
if (cbt.over) return;
|
||||
cbt.over = true;
|
||||
// sync back ally data
|
||||
for (const u of cbt.units) if (u.side === 'ally' && u.ref) { u.ref.hp = Math.max(1, Math.round(u.hp)); u.ref.qi = Math.round(u.qi); }
|
||||
const res = computeRewards(st, cbt, outcome);
|
||||
cbt.result = res;
|
||||
W.BUS.emit('combat_end', res);
|
||||
}
|
||||
|
||||
function computeRewards(st, cbt, outcome) {
|
||||
const spec = cbt.spec;
|
||||
const foes = cbt.units.filter(u => u.side === 'enemy');
|
||||
let gold = 0, items = [], xp = 0;
|
||||
if (outcome === 'win') {
|
||||
for (const f of foes) {
|
||||
xp += 6 + (f.tier || 1) * 6;
|
||||
if (f.lootGold && !spec.noLoot) gold += Math.round(W.rf(f.lootGold[0], f.lootGold[1]) * (1 + st.day / 200));
|
||||
if (f.lootItems && W.chance(0.35) && !spec.noLoot) items.push(W.pick(f.lootItems));
|
||||
}
|
||||
if (spec.rivalId) { const r = W.sim.rivals(st).find(r => r.id === spec.rivalId); if (r) gold += Math.round(r.power * 2); }
|
||||
if (spec.winReward) { const rw = spec.winReward; if (rw.gold) gold += rw.gold; if (rw.item) items.push(rw.item); }
|
||||
}
|
||||
// sync xp to living allies
|
||||
for (const u of cbt.units) if (u.side === 'ally' && u.ref && u.ref.alive) {
|
||||
u.ref.xp.martial = (u.ref.xp.martial || 0) + Math.round(xp * (outcome === 'win' ? 1 : 0.3));
|
||||
for (const id of u.arts) { const a = W.artById(id); if (a && a.stat) u.ref.skills[a.stat] = U.clamp((u.ref.skills[a.stat] || 10) + (outcome === 'win' ? 3 : 1), 0, 100); }
|
||||
}
|
||||
return {
|
||||
outcome, gold, items, xp, context: cbt.context, spec,
|
||||
enemyCount: foes.length,
|
||||
fled: false,
|
||||
};
|
||||
}
|
||||
|
||||
CB.finish = function (st, cbt) {
|
||||
const res = cbt.result; if (!res) return null;
|
||||
st.combat = null;
|
||||
const spec = res.spec || {};
|
||||
if (res.outcome === 'win') {
|
||||
if (res.gold) st.res.gold += res.gold;
|
||||
for (const it of res.items) W.sim.addItem(st, it);
|
||||
if (spec.winReward) {
|
||||
W.sim.applyFx(st, Object.assign({ txt: '' }, spec.winReward));
|
||||
}
|
||||
if (spec.winFame || spec.rewardFame) W.sim.addRep(st, { fame: (spec.winFame || 0) + (spec.rewardFame || 0) });
|
||||
if (spec.winFear) W.sim.addRep(st, { fear: spec.winFear });
|
||||
W.sim.applyCombatResult(st, { win: true, context: res.context, spec, enemyCount: res.enemyCount });
|
||||
if (['explore', 'road', 'ambush', 'hunt', 'beast', 'caught', 'arena'].includes(res.context)) W.sim.addRep(st, { fame: 1, fear: res.context === 'arena' ? 0 : 1 });
|
||||
W.ach && W.ach.check('first_blood');
|
||||
W.ach && W.ach.check('duels', st);
|
||||
} else {
|
||||
if (spec.losePenalty) W.sim.applyFx(st, Object.assign({ txt: '' }, spec.losePenalty));
|
||||
W.sim.applyCombatResult(st, { win: false, context: res.context, spec });
|
||||
if (res.context === 'final') { st.flags.sect_destroyed = true; }
|
||||
if (res.context === 'defense' && !W.sim.party(st).length) { }
|
||||
if (res.context === 'final') st.flags.won_final = false;
|
||||
if (res.context === 'raid' && res.outcome !== 'win') { }
|
||||
}
|
||||
if (!W.sim.player(st).alive) { st.ended = true; st.endingId = W.sim.computeEnding(st); }
|
||||
return res;
|
||||
};
|
||||
|
||||
/* ---------------- movement ---------------- */
|
||||
CB.reachable = function (cbt, u) {
|
||||
const range = Math.max(1, Math.round(u.spd * (hasStatus(u, 'slow') ? 0.5 : 1)));
|
||||
const dist = {}; const q = [[u.x, u.y]]; dist[u.x + ',' + u.y] = 0;
|
||||
while (q.length) {
|
||||
const [x, y] = q.shift(); const d = dist[x + ',' + y];
|
||||
if (d >= range) continue;
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (tileAt(cbt.grid, nx, ny) === 1) continue;
|
||||
if (CB.unitAt(cbt, nx, ny)) continue;
|
||||
const k = nx + ',' + ny;
|
||||
if (dist[k] === undefined) { dist[k] = d + 1; q.push([nx, ny]); }
|
||||
}
|
||||
}
|
||||
delete dist[u.x + ',' + u.y];
|
||||
const cells = [];
|
||||
for (const k in dist) { const [x, y] = k.split(',').map(Number); cells.push({ x, y, d: dist[k] }); }
|
||||
return cells;
|
||||
};
|
||||
CB.moveUnit = function (st, cbt, u, x, y) {
|
||||
if (hasStatus(u, 'root')) return false;
|
||||
const ok = CB.reachable(cbt, u).some(c => c.x === x && c.y === y);
|
||||
if (!ok || CB.unitAt(cbt, x, y)) return false;
|
||||
u.x = x; u.y = y;
|
||||
W.BUS.emit('moved', { unit: u });
|
||||
return true;
|
||||
};
|
||||
CB.pathHint = function (cbt, u, tx, ty) {
|
||||
// simple greedy path for animation
|
||||
const cells = CB.reachable(cbt, u);
|
||||
const cell = cells.find(c => c.x === tx && c.y === ty);
|
||||
return cell ? cell.d : 0;
|
||||
};
|
||||
|
||||
/* ---------------- attacks & techniques ---------------- */
|
||||
function calcDamage(a, d, pow, kind, opts) {
|
||||
opts = opts || {};
|
||||
let base = a.atk * pow + 8;
|
||||
if (kind === 'qi') base *= 1.08;
|
||||
let dmg = base * W.rf(0.88, 1.14);
|
||||
const defMul = kind === 'qi' ? 0.32 : 0.52;
|
||||
dmg -= d.def * defMul * (opts.pierce ? 0.2 : 1);
|
||||
if (hasStatus(a, 'fear')) dmg *= 0.78;
|
||||
if (hasStatus(d, 'defUp')) dmg *= 0.72;
|
||||
if (hasStatus(a, 'atkUp')) dmg *= 1.3;
|
||||
const critCh = (a.crit || 0) + (opts.crit || 0);
|
||||
const crit = W.chance(critCh);
|
||||
if (crit) dmg *= 1.75;
|
||||
return { dmg: Math.max(1, Math.round(dmg)), crit };
|
||||
}
|
||||
|
||||
function dealDamage(st, cbt, a, d, dmg, crit, kind, art) {
|
||||
d.hp -= dmg;
|
||||
W.BUS.emit('hit', { unit: d, dmg, crit, kind, from: a });
|
||||
W.BUS.emit('shake', { mag: crit ? 7 : 3 });
|
||||
W.audio && W.audio.sfx(crit ? 'crit' : kind === 'qi' ? 'thunder' : 'hit');
|
||||
if (a.lifesteal) { const h = Math.round(dmg * a.lifesteal); a.hp = Math.min(a.maxHp, a.hp + h); W.BUS.emit('float', { unit: a, txt: '+' + h, kind: 'heal' }); }
|
||||
if (art && art.eff) for (const ef of art.eff) {
|
||||
if (!W.chance(ef.ch)) continue;
|
||||
if (ef.k === 'drain') { const h = Math.round(dmg * ef.val); a.hp = Math.min(a.maxHp, a.hp + h); W.BUS.emit('float', { unit: a, txt: '+' + h, kind: 'heal' }); continue; }
|
||||
addStatus(d, { k: ef.k, dur: ef.dur || 2, val: ef.val || 0 });
|
||||
W.audio && W.audio.sfx(ef.k === 'poison' ? 'poison' : ef.k === 'stun' ? 'block' : 'whoosh');
|
||||
}
|
||||
if (art && art.lifesteal) { const h = Math.round(dmg * art.lifesteal); a.hp = Math.min(a.maxHp, a.hp + h); }
|
||||
if (d.hp <= 0) killUnit(st, cbt, d);
|
||||
}
|
||||
|
||||
CB.attack = function (st, cbt, a, d) {
|
||||
if (!d || d.dead) return;
|
||||
const plagueBonus = a.side === 'ally' && W.combosFor(a.arts).some(c => c.bonus && c.bonus.poisonAll);
|
||||
const r = calcDamage(a, d, 1.0, 'phys', {});
|
||||
CB.log(st, `${a.name} strikes ${d.name} — ${r.dmg} damage${r.crit ? ', CRITICAL!' : ''}`);
|
||||
dealDamage(st, cbt, a, d, r.dmg, r.crit, 'phys', plagueBonus ? { eff: [{ k: 'poison', ch: 0.35, dur: 3, val: 3 }] } : null);
|
||||
};
|
||||
|
||||
CB.targetsFor = function (cbt, u, art) {
|
||||
const cmb = art.cmb; if (!cmb) return [];
|
||||
const rng = cmb.rng || 1;
|
||||
const friendly = cmb.kind === 'heal' || cmb.kind === 'buff';
|
||||
const out = [];
|
||||
for (const o of cbt.units) {
|
||||
if (o.dead) continue;
|
||||
if (friendly) { if (o.side !== u.side && o !== u) continue; }
|
||||
else { if (o.side === u.side) continue; }
|
||||
const dd = U.dist(u.x, u.y, o.x, o.y);
|
||||
if (dd <= Math.max(rng, 1)) out.push(o);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
CB.useTechnique = function (st, cbt, u, artId, target) {
|
||||
const art = W.artById(artId); if (!art || !art.cmb) return false;
|
||||
const cmb = art.cmb;
|
||||
if ((u.cds[artId] || 0) > 0) { CB.log(st, `${art.n} is not ready.`); return false; }
|
||||
if (u.qi < cmb.qi) { CB.log(st, `Not enough qi for ${art.n}.`); return false; }
|
||||
const targets = CB.targetsFor(cbt, u, art);
|
||||
if (!targets.length) { CB.log(st, `No valid target for ${art.n}.`); return false; }
|
||||
let tgt = target && targets.includes(target) ? target : targets[0];
|
||||
// prefer current hp lowest for heals
|
||||
if (cmb.kind === 'heal') tgt = targets.sort((a, b) => (a.hp / a.maxHp) - (b.hp / b.maxHp))[0];
|
||||
u.qi -= cmb.qi;
|
||||
u.cds[artId] = (cmb.cd || 1) + 1;
|
||||
W.BUS.emit('cine', { fx: cmb.fx, fxT: cmb.fxT || 1, name: art.n, cn: art.cn, unit: u, target: tgt });
|
||||
W.audio && W.audio.sfx(cmb.kind === 'heal' ? 'heal' : cmb.fx === 'thunder' ? 'thunder' : 'sword');
|
||||
|
||||
const aoeCells = [];
|
||||
if (cmb.aoe) for (const o of cbt.units) if (!o.dead && U.dist(tgt.x, tgt.y, o.x, o.y) <= cmb.aoe) aoeCells.push(o);
|
||||
const victims = aoeCells.length ? aoeCells.filter(o => cmb.kind === 'heal' || cmb.kind === 'buff' ? o.side === u.side : o.side !== u.side) : [tgt];
|
||||
|
||||
if (cmb.kind === 'heal') {
|
||||
for (const v of victims) {
|
||||
const amt = Math.round((u.atk * 0.5 + 10) * cmb.pow);
|
||||
v.hp = Math.min(v.maxHp, v.hp + amt);
|
||||
W.BUS.emit('float', { unit: v, txt: '+' + amt, kind: 'heal' });
|
||||
if (cmb.cure) v.statuses = v.statuses.filter(s => !['poison', 'bleed'].includes(s.k));
|
||||
}
|
||||
CB.log(st, `${u.name} channels ${art.n} — wounds close like water settling.`);
|
||||
} else if (cmb.kind === 'buff') {
|
||||
for (const v of victims.concat([u]).filter((x, i, arr) => arr.indexOf(x) === i)) {
|
||||
for (const ef of (cmb.eff || [])) addStatus(v, { k: ef.k, dur: ef.dur || 2, val: ef.val || 0 });
|
||||
W.BUS.emit('float', { unit: v, txt: '▲', kind: 'buff' });
|
||||
}
|
||||
CB.log(st, `${u.name} assumes the ${art.n} stance.`);
|
||||
} else {
|
||||
for (const v of victims) {
|
||||
const r = calcDamage(u, v, cmb.pow, cmb.kind, { pierce: cmb.pierce, crit: cmb.crit || 0 });
|
||||
dealDamage(st, cbt, u, v, r.dmg, r.crit, cmb.kind, art);
|
||||
CB.log(st, `${u.name} unleashes ${art.n} (${art.cn}) on ${v.name} — ${r.dmg}${r.crit ? ' CRITICAL' : ''}`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
CB.guard = function (st, cbt, u) {
|
||||
addStatus(u, { k: 'defUp', dur: 2, val: 6 });
|
||||
u.qi = Math.min(u.maxQi, u.qi + 8);
|
||||
W.BUS.emit('float', { unit: u, txt: '◆', kind: 'guard' });
|
||||
CB.log(st, `${u.name} guards.`);
|
||||
W.audio && W.audio.sfx('block');
|
||||
return true;
|
||||
};
|
||||
|
||||
CB.flee = function (st, cbt, u) {
|
||||
const foes = cbt.units.filter(x => x.side === 'enemy' && !x.dead);
|
||||
const ch = U.clamp(0.45 + (u.spd - U.avg(foes.map(f => f.spd))) * 0.08, 0.15, 0.9);
|
||||
if (W.chance(ch)) { CB.log(st, 'You melt into the terrain. Discretion, valor, et cetera.'); endBattle(st, cbt, 'fled'); return true; }
|
||||
CB.log(st, 'The escape fails! Enemies cut off the retreat.');
|
||||
return false;
|
||||
};
|
||||
|
||||
/* ---------------- AI ---------------- */
|
||||
CB.aiAct = function (st, cbt, u) {
|
||||
if (!u || u.dead || cbt.over) return;
|
||||
const foes = cbt.units.filter(x => x.side !== u.side && !x.dead);
|
||||
if (!foes.length) return;
|
||||
// choose target: prefer low hp ratio & proximity
|
||||
foes.sort((a, b) => (a.hp / a.maxHp + U.dist(u.x, u.y, a.x, a.y) * 0.08) - (b.hp / b.maxHp + U.dist(u.x, u.y, b.x, b.y) * 0.08));
|
||||
let tgt = foes[0];
|
||||
|
||||
if (hasStatus(u, 'stun')) { CB.log(st, `${u.name} reels, stunned.`); return; }
|
||||
|
||||
// healer logic
|
||||
const hurtFriend = cbt.units.filter(x => x.side === u.side && !x.dead && x.hp < x.maxHp * 0.55)
|
||||
.sort((a, b) => (a.hp / a.maxHp) - (b.hp / b.maxHp))[0];
|
||||
for (const artId of u.arts) {
|
||||
const a = W.artById(artId); if (!a || !a.cmb) continue;
|
||||
if ((u.cds[artId] || 0) > 0 || u.qi < a.cmb.qi) continue;
|
||||
if ((a.cmb.kind === 'heal') && hurtFriend && U.dist(u.x, u.y, hurtFriend.x, hurtFriend.y) <= (a.cmb.rng || 1)) {
|
||||
CB.useTechnique(st, cbt, u, artId, hurtFriend); return;
|
||||
}
|
||||
if (a.cmb.kind !== 'heal' && a.cmb.kind !== 'buff') {
|
||||
const inRng = cbt.units.filter(x => x.side !== u.side && !x.dead && U.dist(u.x, u.y, x.x, x.y) <= (a.cmb.rng || 1));
|
||||
if (inRng.length && W.chance(u.boss ? 0.85 : 0.55)) { CB.useTechnique(st, cbt, u, artId, inRng[0]); return; }
|
||||
}
|
||||
if (a.cmb.kind === 'buff' && W.chance(0.3)) { CB.useTechnique(st, cbt, u, artId, null); return; }
|
||||
}
|
||||
// move toward target then attack
|
||||
const reach = CB.reachable(cbt, u);
|
||||
let bestCell = null, bestScore = 1e9;
|
||||
for (const c of reach) {
|
||||
const dd = U.dist(c.x, c.y, tgt.x, tgt.y);
|
||||
const score = dd + (tileAt(cbt.grid, c.x, c.y) === 2 ? 0.5 : 0);
|
||||
if (score < bestScore) { bestScore = score; bestCell = c; }
|
||||
}
|
||||
if (bestCell && bestScore > 1) CB.moveUnit(st, cbt, u, bestCell.x, bestCell.y);
|
||||
if (U.dist(u.x, u.y, tgt.x, tgt.y) <= 1) CB.attack(st, cbt, u, tgt);
|
||||
else if (W.chance(0.3)) { /* approach again next turn */ CB.log(st, `${u.name} circles warily.`); }
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,867 @@
|
||||
/* =========================================================================
|
||||
Renderer part 1 — isometric ink-painting world engine
|
||||
Layers: paper -> parallax mountains -> iso map (cached) -> units ->
|
||||
particles -> lighting -> fog fade -> FX overlays
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const U = W.U;
|
||||
const R = W.rend = {
|
||||
cv: null, ctx: null, W: 0, H: 0, dpr: 1,
|
||||
cam: { x: 0, y: 0, z: 1 },
|
||||
TW: 64, TH: 32,
|
||||
mode: 'explore', // explore | sect | combat
|
||||
mapCache: null, mapKey: '',
|
||||
shakeT: 0, shakeMag: 0,
|
||||
time: 0,
|
||||
hover: null, // hovered tile {x,y}
|
||||
highlights: null, // {move:[{x,y}], targets:[units], danger:[..]}
|
||||
floats: [],
|
||||
npcs: [], // ambient wanderers
|
||||
lastSt: null,
|
||||
};
|
||||
|
||||
R.TILE = (x, y, cam) => ({ x: ((x - y) * R.TW / 2 - cam.x) * cam.z + R.W / 2, y: ((x + y) * R.TH / 2 - cam.y) * cam.z + R.H / 2 });
|
||||
R.UNTILE = (sx, sy, cam) => {
|
||||
const px = (sx - R.W / 2) / cam.z + cam.x, py = (sy - R.H / 2) / cam.z + cam.y;
|
||||
return { x: Math.floor((px / (R.TW / 2) + py / (R.TH / 2)) / 2), y: Math.floor((py / (R.TH / 2) - px / (R.TW / 2)) / 2) };
|
||||
};
|
||||
|
||||
R.init = function (cv) {
|
||||
R.cv = cv; R.ctx = cv.getContext('2d');
|
||||
R.resize();
|
||||
window.addEventListener('resize', R.resize);
|
||||
};
|
||||
R.resize = function () {
|
||||
if (!R.cv) return;
|
||||
R.dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
R.W = window.innerWidth; R.H = window.innerHeight;
|
||||
R.cv.width = R.W * R.dpr; R.cv.height = R.H * R.dpr;
|
||||
R.cv.style.width = R.W + 'px'; R.cv.style.height = R.H + 'px';
|
||||
R.paper = null; R.mtnCache = {};
|
||||
};
|
||||
|
||||
/* ================= paper & helpers ================= */
|
||||
R.makePaper = function () {
|
||||
const c = document.createElement('canvas'); c.width = 256; c.height = 256;
|
||||
const g = c.getContext('2d');
|
||||
g.fillStyle = '#ece5d4'; g.fillRect(0, 0, 256, 256);
|
||||
// fiber noise
|
||||
for (let i = 0; i < 1400; i++) {
|
||||
g.fillStyle = `rgba(${180 + W.ri(0, 40)},${172 + W.ri(0, 40)},${150 + W.ri(0, 40)},${0.05 + Math.random() * 0.06})`;
|
||||
g.fillRect(Math.random() * 256, Math.random() * 256, 1 + Math.random() * 2, 1);
|
||||
}
|
||||
for (let i = 0; i < 60; i++) {
|
||||
g.strokeStyle = `rgba(160,152,130,${0.04 + Math.random() * 0.05})`;
|
||||
g.lineWidth = 0.6;
|
||||
g.beginPath();
|
||||
const x = Math.random() * 256, y = Math.random() * 256;
|
||||
g.moveTo(x, y); g.lineTo(x + Math.random() * 30 - 15, y + Math.random() * 8 - 4); g.stroke();
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
function brushLine(g, x1, y1, x2, y2, wobble, width, color, alpha) {
|
||||
const mx = (x1 + x2) / 2 + (Math.random() - 0.5) * wobble;
|
||||
const my = (y1 + y2) / 2 + (Math.random() - 0.5) * wobble;
|
||||
g.strokeStyle = color; g.globalAlpha = alpha == null ? 1 : alpha;
|
||||
g.lineWidth = width; g.lineCap = 'round';
|
||||
g.beginPath(); g.moveTo(x1, y1); g.quadraticCurveTo(mx, my, x2, y2); g.stroke();
|
||||
g.globalAlpha = 1;
|
||||
}
|
||||
|
||||
R.addShake = mag => { R.shakeT = 0.35; R.shakeMag = Math.max(R.shakeMag, mag); };
|
||||
W.BUS.on('shake', d => R.addShake(d.mag));
|
||||
|
||||
/* ================= parallax mountains ================= */
|
||||
R.mountains = function (theme, w, h) {
|
||||
const key = theme + '_' + w + 'x' + h;
|
||||
if (R.mtnCache[key]) return R.mtnCache[key];
|
||||
const th = W.THEMES[theme] || W.THEMES.wild || W.THEMES.sect;
|
||||
const c = document.createElement('canvas'); c.width = Math.max(600, w); c.height = h;
|
||||
const g = c.getContext('2d');
|
||||
// sky wash
|
||||
const grd = g.createLinearGradient(0, 0, 0, h);
|
||||
grd.addColorStop(0, th.sky[0]); grd.addColorStop(1, th.sky[1]);
|
||||
g.fillStyle = grd; g.fillRect(0, 0, c.width, h);
|
||||
// sun/moon disc
|
||||
g.save();
|
||||
g.globalAlpha = 0.5;
|
||||
const sx = c.width * 0.72, sy = h * 0.22;
|
||||
const sg = g.createRadialGradient(sx, sy, 4, sx, sy, 60);
|
||||
sg.addColorStop(0, 'rgba(255,250,235,0.9)'); sg.addColorStop(1, 'rgba(255,250,235,0)');
|
||||
g.fillStyle = sg; g.beginPath(); g.arc(sx, sy, 60, 0, 7); g.fill();
|
||||
g.restore();
|
||||
// 3 mountain layers
|
||||
const cols = [th.mtn, shade(th.mtn, -14), shade(th.mtn, -28)];
|
||||
for (let L = 0; L < 3; L++) {
|
||||
g.save();
|
||||
g.globalAlpha = 0.32 + L * 0.18;
|
||||
g.fillStyle = cols[L];
|
||||
g.beginPath();
|
||||
let x = -40; const baseY = h * (0.52 + L * 0.14);
|
||||
g.moveTo(-40, h);
|
||||
while (x < c.width + 40) {
|
||||
const peak = baseY - (30 + Math.random() * 90) * (1 - L * 0.15);
|
||||
const wid = 90 + Math.random() * 160;
|
||||
g.lineTo(x + wid * 0.5, peak);
|
||||
g.lineTo(x + wid, baseY + Math.random() * 12);
|
||||
x += wid;
|
||||
}
|
||||
g.lineTo(c.width + 40, h); g.closePath(); g.fill();
|
||||
// mist band
|
||||
g.globalAlpha = 0.35;
|
||||
g.fillStyle = th.fog;
|
||||
g.fillRect(0, baseY - 8, c.width, 26 + L * 10);
|
||||
g.restore();
|
||||
}
|
||||
R.mtnCache[key] = c;
|
||||
return c;
|
||||
};
|
||||
function shade(hex, amt) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
let r = (n >> 16) + amt, gg = ((n >> 8) & 255) + amt, b = (n & 255) + amt;
|
||||
r = U.clamp(r, 0, 255); gg = U.clamp(gg, 0, 255); b = U.clamp(b, 0, 255);
|
||||
return '#' + ((r << 16) | (gg << 8) | b).toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||
/* ================= world map generation ================= */
|
||||
R.MAP_W = 22; R.MAP_H = 22;
|
||||
R.mapFor = function (st) {
|
||||
const mode = st.combat ? 'combat' : (st.locId === 'home' ? 'sect' : 'explore');
|
||||
const key = mode + ':' + st.locId + ':' + (st.combat ? st.combat.round : '') + ':' + Object.keys(st.sect.buildings).join(',') + (st.sect.queue[0] ? '+q' : '');
|
||||
if (R.mode === mode && R.mapKey === key && R.mapData) return R.mapData;
|
||||
R.mode = mode; R.mapKey = key;
|
||||
if (mode === 'combat' && st.combat) { R.mapData = null; return null; } // combat grid rendered live
|
||||
R.mapData = mode === 'sect' ? genSectMap(st) : genExploreMap(st);
|
||||
R.npcs = [];
|
||||
const md = R.mapData;
|
||||
// ambient npcs
|
||||
if (mode === 'explore') {
|
||||
const n = ['town', 'city', 'temple'].includes(W.locById(st.locId).type) ? 3 : 1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const spot = freeTile(md);
|
||||
if (spot) R.npcs.push({ x: spot.x, y: spot.y, tx: spot.x, ty: spot.y, kind: W.pick(['villager', 'monk', 'peddler', 'beggar']), t: Math.random() * 10 });
|
||||
}
|
||||
}
|
||||
return R.mapData;
|
||||
};
|
||||
function freeTile(md) {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const x = W.ri(2, md.w - 3), y = W.ri(2, md.h - 3);
|
||||
if (md.tiles[y][x] === 0 && !md.blocked[y][x]) return { x, y };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function blankMap(w, h, fill) {
|
||||
const tiles = [], blocked = [];
|
||||
for (let y = 0; y < h; y++) { tiles.push(new Array(w).fill(fill)); blocked.push(new Array(w).fill(false)); }
|
||||
return { w, h, tiles, blocked, props: [] };
|
||||
}
|
||||
|
||||
function genExploreMap(st) {
|
||||
const def = W.locById(st.locId);
|
||||
const th = W.THEMES[def.theme] || W.THEMES.sect;
|
||||
const md = blankMap(R.MAP_W, R.MAP_H, 'ground');
|
||||
const rngSeed = U.hash(def.id) ; const saveState = null;
|
||||
// deterministic per-location rng
|
||||
let s = rngSeed >>> 0;
|
||||
const drand = () => { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
|
||||
const dri = (a, b) => a + Math.floor(drand() * (b - a + 1));
|
||||
// water pond
|
||||
if (['river', 'town', 'city'].includes(def.theme)) {
|
||||
const px = dri(4, 8), py = dri(4, 8);
|
||||
for (let y = py; y < py + 4; y++) for (let x = px; x < px + 6; x++) if (md.tiles[y] && md.tiles[y][x]) md.tiles[y][x] = 'water';
|
||||
}
|
||||
// path from bottom edge to center
|
||||
let cx = dri(9, 12), cy = dri(8, 11);
|
||||
let py2 = R.MAP_H - 1, px2 = cx;
|
||||
while (py2 >= cy) { if (md.tiles[py2] && md.tiles[py2][px2] === 'ground') md.tiles[py2][px2] = 'path'; if (drand() < 0.3) px2 = U.clamp(px2 + dri(-1, 1), 3, R.MAP_W - 4); py2--; }
|
||||
while (px2 !== cx) { if (md.tiles[cy] && md.tiles[cy][px2] === 'ground') md.tiles[cy][px2] = 'path'; px2 += px2 < cx ? 1 : -1; }
|
||||
md.poi = { x: cx, y: cy };
|
||||
// scatter features
|
||||
const propCount = 46;
|
||||
for (let i = 0; i < propCount; i++) {
|
||||
const x = dri(1, R.MAP_W - 2), y = dri(1, R.MAP_H - 2);
|
||||
if (md.tiles[y][x] !== 'ground') continue;
|
||||
const r = drand();
|
||||
let kind = null;
|
||||
switch (def.theme) {
|
||||
case 'bamboo': kind = r < 0.75 ? 'bamboo' : (r < 0.85 ? 'rock' : 'grass'); break;
|
||||
case 'temple': kind = r < 0.3 ? 'pine' : (r < 0.45 ? 'lantern' : (r < 0.55 ? 'rock' : 'grass')); break;
|
||||
case 'tomb': kind = r < 0.35 ? 'grave' : (r < 0.5 ? 'rock' : (r < 0.65 ? 'deadtree' : 'grass')); break;
|
||||
case 'valley': kind = r < 0.4 ? 'deadtree' : (r < 0.55 ? 'rock' : 'grass'); break;
|
||||
case 'mountain': kind = r < 0.5 ? 'pine' : (r < 0.7 ? 'rock' : 'grass'); break;
|
||||
case 'camp': kind = r < 0.3 ? 'tent' : (r < 0.45 ? 'pine' : (r < 0.6 ? 'campfire' : 'grass')); break;
|
||||
case 'sect': kind = r < 0.5 ? 'pine' : (r < 0.65 ? 'flag' : 'grass'); break;
|
||||
default: kind = r < 0.4 ? 'tree' : (r < 0.55 ? 'pine' : (r < 0.68 ? 'grass' : (r < 0.78 ? 'house_small' : 'rock')));
|
||||
}
|
||||
if (kind === 'tent' || kind === 'campfire') { if (!nearPath(md, x, y)) continue; }
|
||||
md.props.push({ x, y, kind, v: drand() });
|
||||
}
|
||||
// landmark structure at poi
|
||||
const lm = { town: 'house_big', city: 'gate', village: 'house_big', temple: 'temple_hall', tomb: 'tomb_entry', valley: 'ruin_arch', mountain: 'shrine', wild: 'pavilion', camp: 'tent_big', sect: 'hall', river: 'dock' };
|
||||
md.props.push({ x: cx, y: cy, kind: lm[def.theme] || 'pavilion', v: 0.5, landmark: true });
|
||||
md.blocked[cy] && (md.blocked[cy][cx] = true);
|
||||
md.theme = def.theme;
|
||||
return md;
|
||||
}
|
||||
|
||||
function nearPath(md, x, y) { return md.tiles[y][x] === 'path'; }
|
||||
|
||||
function genSectMap(st) {
|
||||
const md = blankMap(18, 16, 'ground');
|
||||
const th = W.THEMES.sect;
|
||||
// central plaza path cross
|
||||
const cx = 9, cy = 9;
|
||||
for (let x = 2; x < 16; x++) md.tiles[cy][x] = 'path';
|
||||
for (let y = 3; y < 13; y++) md.tiles[y][cx] = 'path';
|
||||
// main hall always
|
||||
md.props.push({ x: cx - 1, y: 4, kind: 'sect_hall', v: 0.5, landmark: true });
|
||||
md.props.push({ x: cx + 1, y: 4, kind: 'flag', v: 0.3 });
|
||||
md.props.push({ x: cx - 3, y: 4, kind: 'flag', v: 0.7 });
|
||||
// built buildings placed on slots
|
||||
const slots = [
|
||||
{ id: 'yard', x: cx - 4, y: 7 }, { id: 'library', x: cx + 3, y: 6 }, { id: 'medhall', x: cx + 4, y: 9 },
|
||||
{ id: 'forge', x: cx - 4, y: 10 }, { id: 'meditation', x: cx + 2, y: 11 }, { id: 'kitchen', x: cx - 2, y: 12 },
|
||||
{ id: 'dormitory', x: cx + 4, y: 12 }, { id: 'garden', x: cx - 5, y: 12 }, { id: 'walls', x: cx - 6, y: 8 },
|
||||
{ id: 'watchtower', x: cx + 6, y: 7 }, { id: 'chamber', x: cx + 1, y: 3 },
|
||||
];
|
||||
for (const sl of slots) {
|
||||
const lv = st.sect.buildings[sl.id] || 0;
|
||||
const q = st.sect.queue.find(j => j.id === sl.id);
|
||||
if (q) md.props.push({ x: sl.x, y: sl.y, kind: 'construction', v: 0.5, bid: sl.id });
|
||||
else if (lv > 0) md.props.push({ x: sl.x, y: sl.y, kind: 'b_' + sl.id, v: 0.5, bid: sl.id, lv });
|
||||
}
|
||||
// scenery
|
||||
for (let i = 0; i < 26; i++) {
|
||||
const x = W.ri(1, md.w - 2), y = W.ri(1, md.h - 2);
|
||||
if (md.tiles[y][x] !== 'ground') continue;
|
||||
md.props.push({ x, y, kind: W.pick(['pine', 'grass', 'rock', 'bamboo']), v: Math.random() });
|
||||
}
|
||||
md.poi = { x: cx, y: cy + 2 };
|
||||
md.theme = 'sect';
|
||||
return md;
|
||||
}
|
||||
|
||||
/* ================= map cache painting ================= */
|
||||
R.paintMap = function (md) {
|
||||
const tw = R.TW, thh = R.TH;
|
||||
const cw = (md.w + md.h) * tw / 2 + 80, ch = (md.w + md.h) * thh / 2 + 220;
|
||||
const c = document.createElement('canvas'); c.width = cw; c.height = ch;
|
||||
const g = c.getContext('2d');
|
||||
const th = W.THEMES[md.theme] || W.THEMES.sect;
|
||||
const ox = cw / 2, oy = 90;
|
||||
const P = (x, y) => ({ x: ox + (x - y) * tw / 2, y: oy + (x + y) * thh / 2 });
|
||||
// ground base wash
|
||||
g.fillStyle = shade(th.ground, 8); g.fillRect(0, 0, cw, ch);
|
||||
// tiles
|
||||
for (let y = 0; y < md.h; y++) for (let x = 0; x < md.w; x++) {
|
||||
const p = P(x, y);
|
||||
drawTile(g, p.x, p.y, md.tiles[y][x], th, x, y);
|
||||
}
|
||||
// props sorted by depth
|
||||
const props = md.props.slice().sort((a, b) => (a.x + a.y) - (b.x + b.y));
|
||||
for (const pr of props) {
|
||||
const p = P(pr.x, pr.y);
|
||||
drawProp(g, p.x, p.y, pr.kind, pr.v, pr.lv || 1, th);
|
||||
}
|
||||
R.mapCanvas = c; R.mapOrigin = { ox, oy };
|
||||
return c;
|
||||
};
|
||||
|
||||
function drawTile(g, x, y, kind, th, tx, ty) {
|
||||
const tw = R.TW, thh = R.TH;
|
||||
let col = th.ground;
|
||||
if (kind === 'path') col = th.path;
|
||||
else if (kind === 'water') col = th.water;
|
||||
else col = ((tx + ty) % 2 === 0) ? th.ground : th.groundAlt;
|
||||
g.beginPath();
|
||||
g.moveTo(x, y - thh / 2); g.lineTo(x + tw / 2, y); g.lineTo(x, y + thh / 2); g.lineTo(x - tw / 2, y); g.closePath();
|
||||
g.fillStyle = col; g.fill();
|
||||
// texture flecks
|
||||
const seed = (tx * 31 + ty * 17);
|
||||
g.strokeStyle = shade(col, -16); g.lineWidth = 1;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const fx = x + (((seed * (i + 3)) % 20) - 10), fy = y + ((((seed >> i) * 7) % 10) - 5);
|
||||
g.beginPath(); g.moveTo(fx - 2, fy); g.lineTo(fx + 2, fy + 1); g.stroke();
|
||||
}
|
||||
// edges: faint ink
|
||||
g.strokeStyle = 'rgba(70,64,52,0.25)'; g.lineWidth = 0.7; g.stroke();
|
||||
if (kind === 'water') {
|
||||
g.strokeStyle = 'rgba(240,246,244,0.5)'; g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(x - 10, y - 2); g.quadraticCurveTo(x, y - 5, x + 10, y - 2); g.stroke();
|
||||
g.beginPath(); g.moveTo(x - 6, y + 4); g.quadraticCurveTo(x + 2, y + 2, x + 9, y + 5); g.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- props ---- */
|
||||
function drawProp(g, x, y, kind, v, lv, th) {
|
||||
const ink = 'rgba(52,48,42,';
|
||||
switch (kind) {
|
||||
case 'tree':
|
||||
g.strokeStyle = ink + '0.85)'; g.lineWidth = 3; g.lineCap = 'round';
|
||||
g.beginPath(); g.moveTo(x, y); g.lineTo(x + 2, y - 22); g.stroke();
|
||||
for (let i = 0; i < 3; i++) brushLine(g, x - 12 + i * 4, y - 14 - i * 6, x + 12 + i * 2, y - 20 - i * 6, 6, 3 - i * 0.6, ink + '0.5)');
|
||||
blob(g, x + 2, y - 26, 12, 'rgba(96,110,88,0.55)');
|
||||
blob(g, x - 6, y - 20, 8, 'rgba(110,122,96,0.5)');
|
||||
break;
|
||||
case 'pine':
|
||||
g.strokeStyle = ink + '0.9)'; g.lineWidth = 3;
|
||||
g.beginPath(); g.moveTo(x, y); g.lineTo(x + 1, y - 26); g.stroke();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const yy = y - 8 - i * 6, ww = 14 - i * 3;
|
||||
brushLine(g, x - ww, yy, x + ww * 0.6, yy - 4, 3, 3, ink + '0.55)');
|
||||
brushLine(g, x + ww, yy, x - ww * 0.6, yy - 5, 3, 2.4, ink + '0.45)');
|
||||
}
|
||||
break;
|
||||
case 'deadtree':
|
||||
g.strokeStyle = ink + '0.75)'; g.lineWidth = 2.6;
|
||||
g.beginPath(); g.moveTo(x, y); g.quadraticCurveTo(x + 3, y - 14, x + 1, y - 24); g.stroke();
|
||||
brushLine(g, x + 1, y - 16, x + 10, y - 22, 2, 1.8, ink + '0.6)');
|
||||
brushLine(g, x + 1, y - 20, x - 8, y - 27, 2, 1.6, ink + '0.6)');
|
||||
break;
|
||||
case 'bamboo': {
|
||||
const n = 2 + Math.floor(v * 2);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const bx = x - 6 + i * 6 + (v * 4);
|
||||
g.strokeStyle = `rgba(88,116,86,${0.75 - i * 0.12})`; g.lineWidth = 2.4;
|
||||
g.beginPath(); g.moveTo(bx, y); g.quadraticCurveTo(bx + 2, y - 18, bx + (i % 2 ? 4 : -3), y - 34 - v * 8); g.stroke();
|
||||
for (let seg = 1; seg <= 3; seg++) { g.beginPath(); g.moveTo(bx - 1.6, y - seg * 9); g.lineTo(bx + 1.6, y - seg * 9 - 1); g.stroke(); }
|
||||
brushLine(g, bx, y - 28 - v * 6, bx + 8, y - 33 - v * 8, 2, 1.6, 'rgba(88,116,86,0.6)');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rock':
|
||||
blob(g, x, y - 4, 7 + v * 5, 'rgba(118,114,104,0.7)');
|
||||
blob(g, x + 4, y - 2, 5, 'rgba(134,130,120,0.6)');
|
||||
g.strokeStyle = ink + '0.4)'; g.lineWidth = 1;
|
||||
g.beginPath(); g.moveTo(x - 4, y - 8); g.lineTo(x + 1, y - 12); g.stroke();
|
||||
break;
|
||||
case 'grass':
|
||||
for (let i = 0; i < 4; i++) brushLine(g, x - 4 + i * 3, y, x - 5 + i * 3 + v * 2, y - 6 - ((v * 7) % 4), 2, 1.2, 'rgba(104,120,84,0.7)');
|
||||
break;
|
||||
case 'lantern': {
|
||||
g.strokeStyle = ink + '0.8)'; g.lineWidth = 2;
|
||||
g.beginPath(); g.moveTo(x, y); g.lineTo(x, y - 18); g.stroke();
|
||||
g.beginPath(); g.moveTo(x, y - 18); g.lineTo(x + 6, y - 20); g.stroke();
|
||||
blob(g, x + 6, y - 16, 4, 'rgba(190,80,54,0.95)');
|
||||
g.fillStyle = '#e8c66a'; g.beginPath(); g.arc(x + 6, y - 16, 2.2, 0, 7); g.fill();
|
||||
break;
|
||||
}
|
||||
case 'grave':
|
||||
g.fillStyle = 'rgba(126,120,108,0.85)';
|
||||
g.beginPath(); g.moveTo(x - 5, y); g.lineTo(x - 4, y - 10); g.quadraticCurveTo(x, y - 14, x + 4, y - 10); g.lineTo(x + 5, y); g.closePath(); g.fill();
|
||||
g.strokeStyle = ink + '0.5)'; g.stroke();
|
||||
break;
|
||||
case 'tent': case 'tent_big': {
|
||||
const s = kind === 'tent_big' ? 1.5 : 1;
|
||||
g.fillStyle = 'rgba(124,106,84,0.85)';
|
||||
g.beginPath(); g.moveTo(x - 10 * s, y); g.lineTo(x, y - 14 * s); g.lineTo(x + 10 * s, y); g.closePath(); g.fill();
|
||||
g.strokeStyle = ink + '0.6)'; g.stroke();
|
||||
break;
|
||||
}
|
||||
case 'campfire':
|
||||
blob(g, x, y, 5, 'rgba(90,80,66,0.8)');
|
||||
g.strokeStyle = 'rgba(196,110,58,0.9)'; g.lineWidth = 2;
|
||||
g.beginPath(); g.moveTo(x - 3, y - 3); g.quadraticCurveTo(x, y - 10, x + 2, y - 4); g.stroke();
|
||||
break;
|
||||
case 'flag':
|
||||
g.strokeStyle = ink + '0.85)'; g.lineWidth = 2.4;
|
||||
g.beginPath(); g.moveTo(x, y); g.lineTo(x, y - 30); g.stroke();
|
||||
g.fillStyle = 'rgba(148,62,50,0.85)';
|
||||
g.beginPath(); g.moveTo(x, y - 30); g.quadraticCurveTo(x + 10, y - 27, x + 14, y - 23); g.lineTo(x, y - 19); g.closePath(); g.fill();
|
||||
break;
|
||||
case 'house_small': case 'house_big': {
|
||||
const s = kind === 'house_big' ? 1.6 : 1;
|
||||
drawHouseIso(g, x, y, s, th, 'wall');
|
||||
break;
|
||||
}
|
||||
case 'gate': drawGate(g, x, y, th); break;
|
||||
case 'temple_hall': drawTemple(g, x, y, 1.4, th); break;
|
||||
case 'tomb_entry':
|
||||
blob(g, x, y, 14, 'rgba(110,106,96,0.9)');
|
||||
g.fillStyle = 'rgba(52,48,42,0.9)';
|
||||
g.beginPath(); g.arc(x, y - 6, 7, Math.PI, 0); g.lineTo(x + 7, y); g.lineTo(x - 7, y); g.closePath(); g.fill();
|
||||
break;
|
||||
case 'ruin_arch':
|
||||
g.strokeStyle = ink + '0.8)'; g.lineWidth = 5;
|
||||
g.beginPath(); g.moveTo(x - 12, y); g.lineTo(x - 10, y - 22); g.stroke();
|
||||
g.beginPath(); g.moveTo(x + 12, y); g.lineTo(x + 10, y - 20); g.stroke();
|
||||
brushLine(g, x - 14, y - 22, x + 14, y - 19, 3, 5, ink + '0.7)');
|
||||
break;
|
||||
case 'shrine':
|
||||
blob(g, x, y - 2, 10, 'rgba(120,116,106,0.8)');
|
||||
drawRoofOnly(g, x, y - 6, 1, 'rgba(94,88,76,0.9)');
|
||||
break;
|
||||
case 'pavilion': drawPavilion(g, x, y, th); break;
|
||||
case 'dock':
|
||||
g.fillStyle = 'rgba(124,102,74,0.9)';
|
||||
g.fillRect(x - 14, y - 4, 28, 6);
|
||||
g.strokeStyle = ink + '0.6)'; g.strokeRect(x - 14, y - 4, 28, 6);
|
||||
break;
|
||||
case 'sect_hall': drawTemple(g, x, y, 1.9, th, true); break;
|
||||
case 'construction':
|
||||
g.strokeStyle = 'rgba(120,100,70,0.9)'; g.lineWidth = 2;
|
||||
g.strokeRect(x - 14, y - 10, 28, 12);
|
||||
brushLine(g, x - 14, y - 10, x + 14, y + 2, 2, 1.6, 'rgba(120,100,70,0.7)');
|
||||
brushLine(g, x + 14, y - 10, x - 14, y + 2, 2, 1.6, 'rgba(120,100,70,0.7)');
|
||||
break;
|
||||
default:
|
||||
if (kind.startsWith('b_')) drawBuilding(g, x, y, kind.slice(2), lv, th);
|
||||
else blob(g, x, y - 3, 6, 'rgba(120,116,106,0.6)');
|
||||
}
|
||||
}
|
||||
function blob(g, x, y, r, col) {
|
||||
g.fillStyle = col;
|
||||
g.beginPath();
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
const a = i / 8 * Math.PI * 2;
|
||||
const rr = r * (0.85 + ((i * 37) % 10) / 40);
|
||||
const px = x + Math.cos(a) * rr, py = y + Math.sin(a) * rr * 0.8;
|
||||
i ? g.lineTo(px, py) : g.moveTo(px, py);
|
||||
}
|
||||
g.closePath(); g.fill();
|
||||
}
|
||||
function drawHouseIso(g, x, y, s, th, style) {
|
||||
// body
|
||||
g.fillStyle = 'rgba(146,128,100,' + (0.92) + ')';
|
||||
g.beginPath();
|
||||
g.moveTo(x - 14 * s, y - 4 * s); g.lineTo(x, y - 11 * s); g.lineTo(x + 14 * s, y - 4 * s); g.lineTo(x + 14 * s, y - 14 * s);
|
||||
g.lineTo(x, y - 21 * s); g.lineTo(x - 14 * s, y - 14 * s); g.closePath(); g.fill();
|
||||
g.strokeStyle = 'rgba(56,50,42,0.55)'; g.lineWidth = 1; g.stroke();
|
||||
// roof
|
||||
drawRoofOnly(g, x, y - 21 * s, s * 1.25, 'rgba(88,74,60,0.95)');
|
||||
// door hint
|
||||
g.fillStyle = 'rgba(60,52,42,0.8)';
|
||||
g.beginPath(); g.moveTo(x - 4 * s, y - 6 * s); g.lineTo(x - 4 * s, y - 12 * s); g.lineTo(x + 2 * s, y - 15 * s); g.lineTo(x + 2 * s, y - 9 * s); g.closePath(); g.fill();
|
||||
}
|
||||
function drawRoofOnly(g, x, y, s, col) {
|
||||
g.fillStyle = col;
|
||||
g.beginPath();
|
||||
g.moveTo(x - 17 * s, y); g.quadraticCurveTo(x, y - 6 * s, x + 17 * s, y - 1 * s);
|
||||
g.lineTo(x, y + 8 * s); g.closePath(); g.fill();
|
||||
g.strokeStyle = 'rgba(40,36,30,0.6)'; g.lineWidth = 1.2; g.stroke();
|
||||
}
|
||||
function drawTemple(g, x, y, s, th, isSect) {
|
||||
// stepped hall with upturned eaves
|
||||
g.fillStyle = 'rgba(158,138,106,0.95)';
|
||||
g.fillRect(x - 16 * s, y - 18 * s, 32 * s, 18 * s);
|
||||
g.strokeStyle = 'rgba(56,50,42,0.6)'; g.strokeRect(x - 16 * s, y - 18 * s, 32 * s, 18 * s);
|
||||
// columns
|
||||
g.strokeStyle = 'rgba(84,58,44,0.9)'; g.lineWidth = 2;
|
||||
for (const dx of [-12, -4, 4, 12]) { g.beginPath(); g.moveTo(x + dx * s, y); g.lineTo(x + dx * s, y - 14 * s); g.stroke(); }
|
||||
// roof
|
||||
g.fillStyle = isSect ? 'rgba(58,84,96,0.95)' : 'rgba(96,72,56,0.95)';
|
||||
g.beginPath();
|
||||
g.moveTo(x - 24 * s, y - 18 * s);
|
||||
g.quadraticCurveTo(x - 26 * s, y - 24 * s, x - 20 * s, y - 25 * s);
|
||||
g.quadraticCurveTo(x, y - 31 * s, x + 20 * s, y - 25 * s);
|
||||
g.quadraticCurveTo(x + 26 * s, y - 24 * s, x + 24 * s, y - 18 * s);
|
||||
g.quadraticCurveTo(x, y - 23 * s, x - 24 * s, y - 18 * s);
|
||||
g.closePath(); g.fill();
|
||||
g.strokeStyle = 'rgba(40,36,30,0.7)'; g.stroke();
|
||||
// plaque
|
||||
g.fillStyle = 'rgba(150,52,42,0.95)';
|
||||
g.fillRect(x - 6 * s, y - 16 * s, 12 * s, 5 * s);
|
||||
}
|
||||
function drawGate(g, x, y, th) {
|
||||
g.fillStyle = 'rgba(132,112,86,0.95)';
|
||||
g.fillRect(x - 18, y - 24, 8, 24); g.fillRect(x + 10, y - 24, 8, 24);
|
||||
drawRoofOnly(g, x, y - 24, 1.5, 'rgba(88,74,60,0.95)');
|
||||
g.strokeStyle = 'rgba(56,50,42,0.5)'; g.strokeRect(x - 18, y - 24, 8, 24); g.strokeRect(x + 10, y - 24, 8, 24);
|
||||
}
|
||||
function drawPavilion(g, x, y, th) {
|
||||
g.strokeStyle = 'rgba(96,74,58,0.9)'; g.lineWidth = 2;
|
||||
for (const dx of [-8, 8]) { g.beginPath(); g.moveTo(x + dx, y); g.lineTo(x + dx, y - 12); g.stroke(); }
|
||||
g.fillStyle = 'rgba(110,88,66,0.9)';
|
||||
g.beginPath();
|
||||
g.moveTo(x - 16, y - 12); g.quadraticCurveTo(x - 18, y - 17, x - 13, y - 18);
|
||||
g.quadraticCurveTo(x, y - 22, x + 13, y - 18);
|
||||
g.quadraticCurveTo(x + 18, y - 17, x + 16, y - 12);
|
||||
g.quadraticCurveTo(x, y - 15, x - 16, y - 12);
|
||||
g.closePath(); g.fill(); g.strokeStyle = 'rgba(40,36,30,0.6)'; g.stroke();
|
||||
}
|
||||
function drawBuilding(g, x, y, bid, lv, th) {
|
||||
const styles = {
|
||||
yard: () => { drawHouseIso(g, x, y, 1.1, th); g.strokeStyle = 'rgba(96,80,60,0.9)'; g.lineWidth = 2; for (const dx of [-20, 20]) { g.beginPath(); g.moveTo(x + dx, y + 2); g.lineTo(x + dx, y - 12); g.stroke(); } },
|
||||
library: () => { drawTemple(g, x, y, 1.1, th); },
|
||||
medhall: () => { drawHouseIso(g, x, y, 1.15, th); blob(g, x + 14, y - 2, 5, 'rgba(96,128,92,0.8)'); },
|
||||
forge: () => { drawHouseIso(g, x, y, 1, th); blob(g, x - 12, y - 2, 5, 'rgba(200,110,60,0.5)'); },
|
||||
meditation: () => { drawPavilion(g, x, y, th); },
|
||||
kitchen: () => { drawHouseIso(g, x, y, 0.9, th); blob(g, x + 8, y - 16, 4, 'rgba(160,160,160,0.4)'); },
|
||||
dormitory: () => { drawHouseIso(g, x, y, 1.5, th); },
|
||||
garden: () => { for (let i = 0; i < 5; i++) blob(g, x - 10 + i * 5, y - (i % 2) * 3, 4, 'rgba(96,130,88,0.75)'); brushLine(g, x - 12, y, x + 12, y, 3, 1.4, 'rgba(96,80,60,0.7)'); },
|
||||
walls: () => { g.fillStyle = 'rgba(120,112,98,0.95)'; g.fillRect(x - 22, y - 12, 44, 12); g.strokeStyle = 'rgba(56,50,42,0.6)'; g.strokeRect(x - 22, y - 12, 44, 12); for (let i = -18; i <= 18; i += 8) g.strokeRect(x + i, y - 16, 6, 4); },
|
||||
watchtower: () => { g.fillStyle = 'rgba(120,112,98,0.95)'; g.fillRect(x - 6, y - 22, 12, 22); g.strokeStyle = 'rgba(56,50,42,0.6)'; g.strokeRect(x - 6, y - 22, 12, 22); drawRoofOnly(g, x, y - 22, 0.9, 'rgba(88,74,60,0.95)'); },
|
||||
chamber: () => { drawHouseIso(g, x, y, 0.85, th); g.fillStyle = 'rgba(40,36,30,0.8)'; g.fillRect(x - 3, y - 10, 6, 8); },
|
||||
};
|
||||
(styles[bid] || (() => drawHouseIso(g, x, y, 1, th)))();
|
||||
if (lv > 1) { g.fillStyle = 'rgba(150,52,42,0.9)'; g.font = 'bold 9px serif'; g.fillText(lv, x + 16, y - 18); }
|
||||
}
|
||||
|
||||
/* ================= units ================= */
|
||||
R.drawUnitSprite = function (g, u, x, y, t, sel) {
|
||||
const bob = u.moving ? Math.sin(t * 14) * 1.8 : Math.sin(t * 2 + u.x) * 0.8;
|
||||
const flip = u.facing < 0 ? -1 : 1;
|
||||
// shadow
|
||||
g.fillStyle = 'rgba(60,54,44,0.28)';
|
||||
g.beginPath(); g.ellipse(x, y + 1, 9, 4, 0, 0, 7); g.fill();
|
||||
g.save(); g.translate(x, y - 2 - bob); g.scale(flip, 1);
|
||||
const kindCol = {
|
||||
player: ['#f0ead9', '#39627d'], ally: ['#ded5c0', '#4a6a58'],
|
||||
foe: ['#3a352f', '#7d2a2a'], beast: ['#4a443a', '#2f2a24'],
|
||||
boss: ['#2e2a26', '#7d2a4a'], rival: ['#d8cdb4', '#6b3030'],
|
||||
villager: ['#cfc4ac', '#7a6a52'], monk: ['#c9bfa8', '#a3622e'], peddler: ['#cfc4ac', '#2f6b5e'], beggar: ['#b8ad94', '#55503f'],
|
||||
}[u.sprite] || ['#ddd4bf', '#556'];
|
||||
const robe = kindCol[0], accent = kindCol[1];
|
||||
// legs
|
||||
g.strokeStyle = '#33302a'; g.lineWidth = 2.2;
|
||||
const step = u.moving ? Math.sin(t * 14) * 3 : 0;
|
||||
g.beginPath(); g.moveTo(-2, 0); g.lineTo(-3 + step, 6); g.stroke();
|
||||
g.beginPath(); g.moveTo(2, 0); g.lineTo(3 - step, 6); g.stroke();
|
||||
// robe
|
||||
g.fillStyle = robe;
|
||||
g.beginPath();
|
||||
g.moveTo(-6, -2); g.quadraticCurveTo(-8, -12, -4, -18);
|
||||
g.lineTo(4, -18); g.quadraticCurveTo(8, -12, 6, -2); g.closePath();
|
||||
g.fill();
|
||||
g.strokeStyle = 'rgba(40,36,30,0.7)'; g.lineWidth = 1.2; g.stroke();
|
||||
// sash
|
||||
g.strokeStyle = accent; g.lineWidth = 2.4;
|
||||
g.beginPath(); g.moveTo(-5, -10); g.quadraticCurveTo(0, -8, 5, -11); g.stroke();
|
||||
// arms + weapon
|
||||
g.strokeStyle = robe; g.lineWidth = 2.2;
|
||||
g.beginPath(); g.moveTo(4, -16); g.lineTo(8, -12); g.stroke();
|
||||
if (u.sprite === 'foe' || u.sprite === 'boss' || u.sprite === 'rival' || u.isPlayer || u.side === 'ally') {
|
||||
g.strokeStyle = u.side === 'enemy' ? '#7a746a' : '#5c5c66';
|
||||
g.lineWidth = 1.8;
|
||||
if (u.sprite === 'beast') { }
|
||||
else { g.beginPath(); g.moveTo(8, -12); g.lineTo(13, -20); g.stroke(); }
|
||||
}
|
||||
// head
|
||||
g.fillStyle = '#d8c8ae';
|
||||
g.beginPath(); g.arc(0, -22, 4.4, 0, 7); g.fill();
|
||||
g.strokeStyle = 'rgba(40,36,30,0.5)'; g.lineWidth = 0.8; g.stroke();
|
||||
// hair & topknot
|
||||
g.fillStyle = '#26221e';
|
||||
g.beginPath(); g.arc(0, -23.5, 4.4, Math.PI * 0.95, Math.PI * 2.05); g.fill();
|
||||
g.beginPath(); g.arc(1.5, -27, 2.1, 0, 7); g.fill();
|
||||
// eyes
|
||||
g.fillStyle = '#26221e';
|
||||
g.fillRect(1.4, -22.4, 1.4, 1.2);
|
||||
if (u.boss) {
|
||||
g.strokeStyle = 'rgba(125,42,74,0.5)'; g.lineWidth = 1.5;
|
||||
g.beginPath(); g.arc(0, -12, 16 + Math.sin(t * 3) * 2, 0, 7); g.stroke();
|
||||
}
|
||||
g.restore();
|
||||
if (sel) {
|
||||
g.strokeStyle = 'rgba(230,225,210,0.95)'; g.lineWidth = 1.6;
|
||||
g.beginPath(); g.ellipse(x, y, 13, 6.5, 0, 0, 7); g.stroke();
|
||||
g.strokeStyle = 'rgba(163,51,39,0.9)';
|
||||
g.beginPath(); g.ellipse(x, y, 16, 8, 0, 0, 7); g.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
/* ================= particles & weather ================= */
|
||||
R.parts = [];
|
||||
R.ensureParts = function (st) {
|
||||
const wx = st.weatherId || 'clear';
|
||||
const want = { clear: 0, wind: 26, rain: 90, storm: 170, fog: 14, snow: 80 }[wx] || 0;
|
||||
const qualityMul = (st.settings && st.settings.fx || 2) / 2;
|
||||
const target = Math.round(want * qualityMul * (Math.min(R.W, 1400) / 1400));
|
||||
while (R.parts.length > target) R.parts.pop();
|
||||
while (R.parts.length < target) {
|
||||
R.parts.push({
|
||||
x: Math.random() * R.W, y: Math.random() * R.H,
|
||||
vx: 0, vy: 0, ph: Math.random() * 7, kind: wx,
|
||||
});
|
||||
}
|
||||
};
|
||||
R.stepParts = function (dt, st) {
|
||||
const wx = st.weatherId || 'clear';
|
||||
for (const p of R.parts) {
|
||||
if (wx === 'rain' || wx === 'storm') {
|
||||
const spd = wx === 'storm' ? 900 : 620;
|
||||
p.vy = spd; p.vx = 90 + Math.sin(p.ph) * 40;
|
||||
} else if (wx === 'snow') { p.vy = 40 + Math.sin(p.ph) * 12; p.vx = Math.sin(R.time * 0.7 + p.ph) * 26; }
|
||||
else if (wx === 'wind') { p.vy = 14; p.vx = 120 + Math.sin(R.time + p.ph) * 50; }
|
||||
else if (wx === 'fog') { p.vy = 0; p.vx = 12; }
|
||||
p.x += p.vx * dt; p.y += p.vy * dt; p.ph += dt * 3;
|
||||
if (p.y > R.H + 10) { p.y = -10; p.x = Math.random() * R.W; }
|
||||
if (p.x > R.W + 10) p.x = -10;
|
||||
if (p.x < -10) p.x = R.W + 10;
|
||||
}
|
||||
};
|
||||
R.drawParts = function (g, st) {
|
||||
const wx = st.weatherId || 'clear';
|
||||
g.save();
|
||||
if (wx === 'rain' || wx === 'storm') {
|
||||
g.strokeStyle = wx === 'storm' ? 'rgba(180,195,205,0.5)' : 'rgba(170,185,195,0.4)';
|
||||
g.lineWidth = 1;
|
||||
g.beginPath();
|
||||
for (const p of R.parts) { g.moveTo(p.x, p.y); g.lineTo(p.x - p.vx * 0.02, p.y - p.vy * 0.02); }
|
||||
g.stroke();
|
||||
} else if (wx === 'snow') {
|
||||
g.fillStyle = 'rgba(245,248,250,0.8)';
|
||||
for (const p of R.parts) { g.beginPath(); g.arc(p.x, p.y, 1.6, 0, 7); g.fill(); }
|
||||
} else if (wx === 'wind') {
|
||||
for (const p of R.parts) {
|
||||
g.save(); g.translate(p.x, p.y); g.rotate(Math.sin(p.ph) * 1.2);
|
||||
g.fillStyle = `rgba(${168 + W.ri(0, 30)},${120 + W.ri(0, 30)},60,0.65)`;
|
||||
g.beginPath(); g.ellipse(0, 0, 3.4, 1.6, 0, 0, 7); g.fill(); g.restore();
|
||||
}
|
||||
} else if (wx === 'fog') {
|
||||
for (const p of R.parts) {
|
||||
const gr = g.createRadialGradient(p.x, R.H * 0.6 + p.ph * 8, 10, p.x, R.H * 0.6 + p.ph * 8, 190);
|
||||
gr.addColorStop(0, 'rgba(222,226,220,0.16)'); gr.addColorStop(1, 'rgba(222,226,220,0)');
|
||||
g.fillStyle = gr; g.fillRect(p.x - 190, R.H * 0.6 - 190, 380, 380);
|
||||
}
|
||||
}
|
||||
g.restore();
|
||||
};
|
||||
|
||||
/* ================= lighting ================= */
|
||||
R.drawLighting = function (g, st) {
|
||||
const phase = W.C.PHASES[U.clamp(st.phase, 0, 3)];
|
||||
let tint = null, dark = 0;
|
||||
if (phase.id === 'morning') { tint = 'rgba(255,214,150,0.10)'; }
|
||||
else if (phase.id === 'afternoon') { tint = 'rgba(255,250,235,0.05)'; }
|
||||
else if (phase.id === 'evening') { tint = 'rgba(255,170,110,0.16)'; dark = 0.08; }
|
||||
else { dark = 0.42; }
|
||||
const wx = st.weatherId;
|
||||
if (wx === 'rain') dark += 0.14; if (wx === 'storm') dark += 0.24; if (wx === 'fog') dark += 0.06;
|
||||
if (dark > 0) { g.fillStyle = `rgba(24,28,46,${U.clamp(dark, 0, 0.6)})`; g.fillRect(0, 0, R.W, R.H); }
|
||||
if (tint) { g.fillStyle = tint; g.fillRect(0, 0, R.W, R.H); }
|
||||
if (phase.id === 'night') {
|
||||
// moon
|
||||
g.save();
|
||||
g.fillStyle = 'rgba(240,240,228,0.9)';
|
||||
g.beginPath(); g.arc(R.W * 0.82, R.H * 0.16, 22, 0, 7); g.fill();
|
||||
g.fillStyle = `rgba(236,229,212,0.12)`;
|
||||
g.beginPath(); g.arc(R.W * 0.82, R.H * 0.16, 60, 0, 7); g.fill();
|
||||
g.restore();
|
||||
}
|
||||
// lantern glows at night/evening
|
||||
if (R.mode !== 'combat' && R.mapOrigin && (phase.id === 'night' || phase.id === 'evening')) {
|
||||
g.save(); g.globalCompositeOperation = 'lighter';
|
||||
const md = R.mapData;
|
||||
if (md) for (const pr of md.props) {
|
||||
if (pr.kind === 'lantern' || pr.kind === 'campfire' || pr.kind === 'forge' || String(pr.kind).startsWith('b_forge')) {
|
||||
const p = R.TILE(pr.x, pr.y, R.cam);
|
||||
const gr = g.createRadialGradient(p.x, p.y - 10, 4, p.x, p.y - 10, 70);
|
||||
gr.addColorStop(0, 'rgba(255,190,90,0.28)'); gr.addColorStop(1, 'rgba(255,190,90,0)');
|
||||
g.fillStyle = gr; g.fillRect(p.x - 70, p.y - 80, 140, 140);
|
||||
}
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
// vignette
|
||||
const vg = g.createRadialGradient(R.W / 2, R.H / 2, R.H * 0.4, R.W / 2, R.H / 2, R.H * 0.85);
|
||||
vg.addColorStop(0, 'rgba(40,36,30,0)'); vg.addColorStop(1, 'rgba(40,36,30,0.32)');
|
||||
g.fillStyle = vg; g.fillRect(0, 0, R.W, R.H);
|
||||
};
|
||||
|
||||
/* ================= master render ================= */
|
||||
R.render = function (st, dt) {
|
||||
const g = R.ctx; if (!g) return;
|
||||
R.lastSt = st; R.time += dt;
|
||||
g.setTransform(R.dpr, 0, 0, R.dpr, 0, 0);
|
||||
// paper
|
||||
if (!R.paper) R.paper = R.makePaper();
|
||||
const pat = g.createPattern(R.paper, 'repeat');
|
||||
g.fillStyle = pat; g.fillRect(0, 0, R.W, R.H);
|
||||
|
||||
// shake offset
|
||||
let shx = 0, shy = 0;
|
||||
if (R.shakeT > 0) { R.shakeT -= dt; const m = R.shakeMag * (R.shakeT / 0.35); shx = W.rf(-m, m); shy = W.rf(-m, m); if (R.shakeT <= 0) R.shakeMag = 0; }
|
||||
g.translate(shx, shy);
|
||||
|
||||
// mountains backdrop (parallax)
|
||||
const theme = st.combat ? st.combat.theme : (W.THEMES[W.locById(st.locId).theme] ? W.locById(st.locId).theme : 'sect');
|
||||
const mtn = R.mountains(theme, R.W, R.H);
|
||||
g.save(); g.globalAlpha = 0.9;
|
||||
const pxo = -((R.cam.x * 0.18) % R.W);
|
||||
g.drawImage(mtn, pxo, 0, R.W, R.H * 0.62);
|
||||
g.drawImage(mtn, pxo + R.W, 0, R.W, R.H * 0.62);
|
||||
g.restore();
|
||||
|
||||
if (st.combat) R.renderCombat(st, g, dt);
|
||||
else R.renderWorld(st, g, dt);
|
||||
|
||||
// particles above world
|
||||
R.ensureParts(st); R.stepParts(dt, st); R.drawParts(g, st);
|
||||
R.drawLighting(g, st);
|
||||
|
||||
g.setTransform(R.dpr, 0, 0, R.dpr, 0, 0);
|
||||
// floating combat text
|
||||
drawFloats(g, dt);
|
||||
// cinematics overlay
|
||||
if (W.fx) W.fx.render(g, dt, R);
|
||||
// location label
|
||||
if (!st.combat) {
|
||||
const loc = W.locById(st.locId);
|
||||
g.save();
|
||||
g.font = '500 15px Georgia, "Times New Roman", serif';
|
||||
g.textAlign = 'left';
|
||||
g.fillStyle = 'rgba(60,52,42,0.75)';
|
||||
g.fillText(loc ? loc.n + ' · ' + loc.cn : '', 24, R.H - 26);
|
||||
g.restore();
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- explore/sect world ---------- */
|
||||
R.renderWorld = function (st, g, dt) {
|
||||
const md = R.mapFor(st);
|
||||
if (!md) return;
|
||||
if (!R.mapPaintedKey || R.mapPaintedKey !== R.mapKey) { R.paintMap(md); R.mapPaintedKey = R.mapKey; }
|
||||
const c = R.mapCanvas, o = R.mapOrigin;
|
||||
g.drawImage(c, (0 - R.cam.x) * R.cam.z + R.W / 2 - o.ox * R.cam.z, (0 - R.cam.y) * R.cam.z + R.H / 2 - o.oy * R.cam.z, c.width * R.cam.z, c.height * R.cam.z);
|
||||
|
||||
const T = (x, y) => ({ x: ((x - y) * R.TW / 2 + o.ox - R.cam.x) * R.cam.z + R.W / 2, y: ((x + y) * R.TH / 2 + o.oy - R.cam.y) * R.cam.z + R.H / 2 });
|
||||
|
||||
// hover highlight
|
||||
if (R.hover) {
|
||||
const p = T(R.hover.x, R.hover.y);
|
||||
g.strokeStyle = 'rgba(163,51,39,0.8)'; g.lineWidth = 1.5;
|
||||
g.beginPath(); g.moveTo(p.x, p.y - R.TH / 2 * R.cam.z); g.lineTo(p.x + R.TW / 2 * R.cam.z, p.y); g.lineTo(p.x, p.y + R.TH / 2 * R.cam.z); g.lineTo(p.x - R.TW / 2 * R.cam.z, p.y); g.closePath(); g.stroke();
|
||||
}
|
||||
// collect drawables: npcs + player + party ghosts
|
||||
const items = [];
|
||||
for (const n of R.npcs) {
|
||||
// wander AI
|
||||
if (Math.abs(n.x - n.tx) < 0.05 && Math.abs(n.y - n.ty) < 0.05 && W.chance(0.008)) {
|
||||
n.tx = U.clamp(n.x + W.ri(-3, 3), 1, md.w - 2); n.ty = U.clamp(n.y + W.ri(-2, 2), 1, md.h - 2);
|
||||
}
|
||||
n.x += U.clamp(n.tx - n.x, -dt * 1.2, dt * 1.2); n.y += U.clamp(n.ty - n.y, -dt * 1.2, dt * 1.2);
|
||||
n.facing = (n.tx - n.x) >= 0 ? 1 : -1; n.moving = Math.abs(n.tx - n.x) > 0.05;
|
||||
items.push({ d: n.x + n.y, fn: () => R.drawUnitSprite(g, n, ...xy(n), R.time, false) });
|
||||
}
|
||||
const party = W.sim.party(st);
|
||||
party.forEach((c, i) => {
|
||||
const off = i * 1.1;
|
||||
items.push({
|
||||
d: md.poi.x + off + md.poi.y, fn: () => R.drawUnitSprite(g, { sprite: c.isPlayer ? 'player' : 'ally', facing: 1, x: 1, moving: false }, ...xy({ x: md.poi.x + off, y: md.poi.y }), R.time + i, false),
|
||||
});
|
||||
});
|
||||
function xy(o) { const p = T(o.x, o.y); return [p.x, p.y]; }
|
||||
items.sort((a, b) => a.d - b.d);
|
||||
for (const it of items) it.fn();
|
||||
};
|
||||
|
||||
/* ---------- combat ---------- */
|
||||
R.renderCombat = function (st, g, dt) {
|
||||
const cbt = st.combat; if (!cbt) return;
|
||||
const grid = cbt.grid;
|
||||
const th = W.THEMES[cbt.theme] || W.THEMES.wild || W.THEMES.sect;
|
||||
// board position: centered
|
||||
const ox = R.W / 2, oy = R.H / 2 - grid.h * R.TH * R.cam.z / 2 + 40;
|
||||
const T = (x, y) => ({ x: ((x - y) * R.TW / 2 - R.cam.x) * R.cam.z + ox, y: ((x + y) * R.TH / 2 - R.cam.y) * R.cam.z + oy });
|
||||
R._cbtT = T;
|
||||
|
||||
// ground plate
|
||||
g.save();
|
||||
g.fillStyle = 'rgba(210,204,186,0.35)';
|
||||
g.beginPath();
|
||||
const corners = [T(0, 0), T(grid.w, 0), T(grid.w, grid.h), T(0, grid.h)];
|
||||
g.moveTo(corners[0].x, corners[0].y);
|
||||
for (const cc of corners.slice(1)) g.lineTo(cc.x, cc.y);
|
||||
g.closePath(); g.fill();
|
||||
g.restore();
|
||||
|
||||
// move highlights under tiles
|
||||
if (R.highlights && R.highlights.move) {
|
||||
for (const cell of R.highlights.move) {
|
||||
const p = T(cell.x, cell.y);
|
||||
g.fillStyle = 'rgba(120,150,130,0.28)';
|
||||
g.beginPath(); g.moveTo(p.x, p.y - R.TH / 2 * R.cam.z); g.lineTo(p.x + R.TW / 2 * R.cam.z, p.y); g.lineTo(p.x, p.y + R.TH / 2 * R.cam.z); g.lineTo(p.x - R.TW / 2 * R.cam.z, p.y); g.closePath(); g.fill();
|
||||
}
|
||||
}
|
||||
// tiles
|
||||
for (let y = 0; y < grid.h; y++) for (let x = 0; x < grid.w; x++) {
|
||||
const p = T(x, y);
|
||||
drawTile(g, p.x, p.y, grid.tiles[y][x] === 2 ? 'water' : 'ground', th, x, y);
|
||||
if (grid.tiles[y][x] === 1) drawProp(g, p.x, p.y, th && cbt.theme === 'bamboo' ? 'bamboo' : (cbt.theme === 'tomb' ? 'grave' : (cbt.theme === 'valley' ? 'deadtree' : 'rock')), 0.4, 1, th);
|
||||
}
|
||||
// target rings
|
||||
if (R.highlights && R.highlights.targets) {
|
||||
for (const uu of R.highlights.targets) {
|
||||
if (uu.dead) continue;
|
||||
const p = T(uu.x, uu.y);
|
||||
const rr = 16 * R.cam.z + Math.sin(R.time * 6) * 2;
|
||||
g.strokeStyle = uu.side === 'enemy' ? 'rgba(178,52,38,0.9)' : 'rgba(90,140,110,0.9)';
|
||||
g.lineWidth = 2;
|
||||
g.beginPath(); g.ellipse(p.x, p.y, rr, rr / 2, 0, 0, 7); g.stroke();
|
||||
}
|
||||
}
|
||||
// units sorted
|
||||
const us = cbt.units.filter(u => !u.dead).sort((a, b) => (a.x + a.y) - (b.x + b.y));
|
||||
for (const u of us) {
|
||||
const p = T(u.x, u.y);
|
||||
u.sx = p.x; u.sy = p.y;
|
||||
R.drawUnitSprite(g, u, p.x, p.y, R.time, u === R.selUnit);
|
||||
// hp bars
|
||||
const bw = 34, hpFrac = U.clamp(u.hp / u.maxHp, 0, 1);
|
||||
g.fillStyle = 'rgba(40,36,30,0.55)';
|
||||
g.fillRect(p.x - bw / 2, p.y - 40, bw, 4.5);
|
||||
g.fillStyle = u.side === 'enemy' ? (hpFrac > 0.4 ? '#b04a38' : '#d06a4a') : (hpFrac > 0.4 ? '#5d8a5f' : '#c9a44a');
|
||||
g.fillRect(p.x - bw / 2 + 0.5, p.y - 39.5, (bw - 1) * hpFrac, 3.5);
|
||||
if (u.maxQi) {
|
||||
g.fillStyle = 'rgba(40,36,30,0.45)'; g.fillRect(p.x - bw / 2, p.y - 34.5, bw, 2.5);
|
||||
g.fillStyle = '#5b7fa3'; g.fillRect(p.x - bw / 2 + 0.5, p.y - 34, (bw - 1) * U.clamp(u.qi / u.maxQi, 0, 1), 1.6);
|
||||
}
|
||||
// statuses
|
||||
let sx = p.x - 10;
|
||||
for (const s of u.statuses) {
|
||||
g.fillStyle = { poison: '#6a8a3a', bleed: '#a34a3a', stun: '#c9a44a', slow: '#7a8aa3', root: '#6a8a5a', fear: '#8a6a9a', defUp: '#9ab0c9', atkUp: '#c97a4a', burn: '#d07a3a' }[s.k] || '#999';
|
||||
g.beginPath(); g.arc(sx, p.y - 47, 3.4, 0, 7); g.fill(); sx += 9;
|
||||
}
|
||||
if (u.boss) {
|
||||
g.fillStyle = 'rgba(163,51,39,0.9)';
|
||||
g.font = 'bold 10px Georgia, serif';
|
||||
g.textAlign = 'center';
|
||||
g.fillText('◈ ' + u.name, p.x, p.y - 52);
|
||||
g.textAlign = 'left';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
R.pickUnitAt = function (cbt, sx, sy) {
|
||||
if (!cbt) return null;
|
||||
let best = null, bd = 1e9;
|
||||
for (const u of cbt.units) {
|
||||
if (u.dead || u.sx === undefined) continue;
|
||||
const d = Math.abs(u.sx - sx) + Math.abs((u.sy - 14) - sy);
|
||||
if (d < bd) { bd = d; best = u; }
|
||||
}
|
||||
return bd < 34 ? best : null;
|
||||
};
|
||||
R.pickTileAt = function (st, sx, sy) {
|
||||
if (st.combat && R._cbtT) {
|
||||
const inv = { x: ((sx - R._cbtT(0, 0).x) / R.cam.z + R.cam.x) / (R.TW / 2), y: ((sy - R._cbtT(0, 0).y) / R.cam.z + R.cam.y) / (R.TH / 2) };
|
||||
return { x: Math.round((inv.x + inv.y) / 2), y: Math.round((inv.y - inv.x) / 2) };
|
||||
}
|
||||
return R.UNTILE(sx, sy, R.cam);
|
||||
};
|
||||
|
||||
/* ---------------- floating numbers ---------------- */
|
||||
W.BUS.on('hit', d => { R.floats.push({ x: d.unit.sx || 0, y: (d.unit.sy || 0) - 30, txt: '' + d.dmg, col: d.crit ? '#d84a2f' : (d.kind === 'qi' ? '#7a6ac9' : '#3a352f'), life: 1, size: d.crit ? 22 : 16 }); });
|
||||
W.BUS.on('float', d => {
|
||||
const colmap = { heal: '#5d8a5f', poison: '#6a8a3a', bleed: '#a34a3a', guard: '#7a8aa3', buff: '#c9a44a' };
|
||||
R.floats.push({ x: d.unit.sx || 0, y: (d.unit.sy || 0) - 30, txt: d.txt, col: colmap[d.kind] || '#555', life: 1, size: 14 });
|
||||
});
|
||||
function drawFloats(g, dt) {
|
||||
for (const f of R.floats.slice()) {
|
||||
f.life -= dt * 0.9; f.y -= dt * 34;
|
||||
if (f.life <= 0) { R.floats.splice(R.floats.indexOf(f), 1); continue; }
|
||||
g.save();
|
||||
g.globalAlpha = U.clamp(f.life, 0, 1);
|
||||
g.font = `bold ${f.size}px Georgia, serif`;
|
||||
g.textAlign = 'center';
|
||||
g.strokeStyle = 'rgba(236,229,212,0.8)'; g.lineWidth = 3;
|
||||
g.strokeText(f.txt, f.x, f.y);
|
||||
g.fillStyle = f.col; g.fillText(f.txt, f.x, f.y);
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,735 @@
|
||||
/* =========================================================================
|
||||
Renderer part 2 — procedural portraits, event scene art, cinematics
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const U = W.U;
|
||||
const R = () => W.rend;
|
||||
const P_CACHE = new Map();
|
||||
const S_CACHE = new Map();
|
||||
|
||||
/* ================= PORTRAITS ================= */
|
||||
function portraitKey(c, expr) {
|
||||
return [c.id, expr, c.gender, c.age > 45 ? 'old' : c.age > 30 ? 'mid' : 'young', c.traits.join(','), c.isPlayer ? 'P' : ''].join('|');
|
||||
}
|
||||
W.portrait = function (c, expr) {
|
||||
expr = expr || 'neutral';
|
||||
const key = portraitKey(c, expr);
|
||||
if (P_CACHE.has(key)) return P_CACHE.get(key);
|
||||
const w = 120, h = 150;
|
||||
const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
|
||||
const g = cv.getContext('2d');
|
||||
// paper wash
|
||||
g.fillStyle = '#e9e1cd'; g.fillRect(0, 0, w, h);
|
||||
const gr = g.createRadialGradient(w / 2, h * 0.42, 8, w / 2, h * 0.5, w);
|
||||
gr.addColorStop(0, 'rgba(255,252,240,0.7)'); gr.addColorStop(1, 'rgba(190,180,155,0.35)');
|
||||
g.fillStyle = gr; g.fillRect(0, 0, w, h);
|
||||
|
||||
const young = c.age < 26, mid = c.age >= 26 && c.age <= 45;
|
||||
const skinY = mid ? 74 : 70;
|
||||
// robe & shoulders
|
||||
let accent = c.isPlayer ? '#39627d' : ['#4a6a58', '#7d4a4a', '#55507a', '#7a6a3a', '#3a6a6a'][U.hash(c.id) % 5];
|
||||
if ((c.traits || []).includes('cruel') || (c.hidden && c.hiddenRevealed)) accent = '#5a2a3a';
|
||||
g.fillStyle = shadeC(accent, -10);
|
||||
g.beginPath();
|
||||
g.moveTo(-8, h); g.quadraticCurveTo(w * 0.18, h * 0.62, w * 0.32, h * 0.58);
|
||||
g.quadraticCurveTo(w * 0.5, h * 0.66, w * 0.68, h * 0.58);
|
||||
g.quadraticCurveTo(w * 0.82, h * 0.62, w + 8, h);
|
||||
g.closePath(); g.fill();
|
||||
// collar V
|
||||
g.fillStyle = '#efe9d8';
|
||||
g.beginPath(); g.moveTo(w * 0.36, h * 0.56); g.lineTo(w * 0.5, h * 0.72); g.lineTo(w * 0.64, h * 0.56); g.lineTo(w * 0.64, h); g.lineTo(w * 0.36, h); g.closePath(); g.fill();
|
||||
g.strokeStyle = shadeC(accent, 20); g.lineWidth = 3;
|
||||
g.beginPath(); g.moveTo(w * 0.38, h * 0.57); g.lineTo(w * 0.5, h * 0.73); g.lineTo(w * 0.62, h * 0.57); g.stroke();
|
||||
|
||||
// neck & head
|
||||
g.fillStyle = '#dccaa8';
|
||||
g.fillRect(w * 0.46, h * 0.47, w * 0.08, h * 0.12);
|
||||
const hx = w * 0.5, hy = h * 0.34, hr = 26;
|
||||
g.fillStyle = '#e2d2b2';
|
||||
g.beginPath(); g.ellipse(hx, hy, hr * 0.82, hr, 0, 0, 7); g.fill();
|
||||
g.strokeStyle = 'rgba(70,60,48,0.5)'; g.lineWidth = 1.2;
|
||||
g.beginPath(); g.ellipse(hx, hy, hr * 0.82, hr, 0, 0, 7); g.stroke();
|
||||
|
||||
// hair
|
||||
g.fillStyle = c.age > 55 ? '#b9b2a4' : '#26221e';
|
||||
g.beginPath();
|
||||
g.ellipse(hx, hy - hr * 0.52, hr * 0.88, hr * 0.62, 0, Math.PI, Math.PI * 2); g.fill();
|
||||
if (c.gender === 'f') {
|
||||
// long hair sides
|
||||
g.beginPath();
|
||||
g.moveTo(hx - hr * 0.85, hy - hr * 0.3);
|
||||
g.quadraticCurveTo(hx - hr * 1.15, hy + hr * 0.9, hx - hr * 0.75, hy + hr * 1.25);
|
||||
g.lineTo(hx - hr * 0.55, hy + hr * 0.5); g.closePath(); g.fill();
|
||||
g.beginPath();
|
||||
g.moveTo(hx + hr * 0.85, hy - hr * 0.3);
|
||||
g.quadraticCurveTo(hx + hr * 1.15, hy + hr * 0.9, hx + hr * 0.75, hy + hr * 1.25);
|
||||
g.lineTo(hx + hr * 0.55, hy + hr * 0.5); g.closePath(); g.fill();
|
||||
// hairpin flower
|
||||
g.fillStyle = '#a34a3a';
|
||||
g.beginPath(); g.arc(hx + hr * 0.62, hy - hr * 0.72, 3.2, 0, 7); g.fill();
|
||||
g.fillStyle = '#e8c66a';
|
||||
g.beginPath(); g.arc(hx + hr * 0.62, hy - hr * 0.72, 1.4, 0, 7); g.fill();
|
||||
} else {
|
||||
g.beginPath(); g.arc(hx, hy - hr * 1.02, 6.5, 0, 7); g.fill(); // topknot
|
||||
g.fillStyle = '#39322a';
|
||||
g.fillRect(hx - 7, hy - hr * 1.06, 14, 2.4); // band
|
||||
}
|
||||
if (c.age > 50 && c.gender === 'm') {
|
||||
g.strokeStyle = '#b9b2a4'; g.lineWidth = 2;
|
||||
stroke(g, hx - 8, hy + hr * 0.55, hx + 8, hy + hr * 0.55, 2);
|
||||
stroke(g, hx - 6, hy + hr * 0.72, hx + 6, hy + hr * 0.72, 1.6);
|
||||
}
|
||||
|
||||
// brows & eyes & mouth by expression
|
||||
const eyeY = hy - 2, lx = hx - 9, rx = hx + 9;
|
||||
g.lineWidth = 2; g.lineCap = 'round';
|
||||
const brow = (x, dir, tilt) => { g.strokeStyle = '#33291f'; g.beginPath(); g.moveTo(x - 5, eyeY - 7 + tilt * dir); g.quadraticCurveTo(x, eyeY - 10 + tilt * dir * 0.5, x + 5, eyeY - 7); g.stroke(); };
|
||||
const eye = (x, open) => {
|
||||
g.strokeStyle = '#26221e'; g.fillStyle = '#fff';
|
||||
if (open === 0) { g.beginPath(); g.moveTo(x - 4, eyeY); g.quadraticCurveTo(x, eyeY + 3, x + 4, eyeY); g.stroke(); return; }
|
||||
g.beginPath(); g.ellipse(x, eyeY, 3.6, 2.4 * open, 0, 0, 7); g.fill(); g.stroke();
|
||||
g.fillStyle = '#26221e'; g.beginPath(); g.arc(x + 0.5, eyeY, 1.3, 0, 7); g.fill();
|
||||
};
|
||||
switch (expr) {
|
||||
case 'happy': brow(lx, -1, 0.5); brow(rx, -1, 0.5); eye(lx, 0.5); eye(rx, 0.5);
|
||||
smile(g, hx, hy + 10, true); blush(g, lx, rx, eyeY + 8); break;
|
||||
case 'angry': brow(lx, -1, -2.4); brow(rx, 1, -2.4); eye(lx, 0.8); eye(rx, 0.8);
|
||||
frown(g, hx, hy + 11); break;
|
||||
case 'sad': brow(lx, 1, 2); brow(rx, -1, 2); eye(lx, 0.6); eye(rx, 0.6);
|
||||
frown(g, hx, hy + 11, true); tear(g, lx, eyeY + 4); break;
|
||||
case 'injured': brow(lx, 1, 1); brow(rx, -1, 1); eye(lx, 0.35); eye(rx, 0.35);
|
||||
flatMouth(g, hx, hy + 11); bruise(g, rx, eyeY - 4); bandage(g, hx, hy - 6); break;
|
||||
case 'shocked': brow(lx, -1, 3); brow(rx, -1, 3); eye(lx, 1.5); eye(rx, 1.5);
|
||||
oMouth(g, hx, hy + 11); break;
|
||||
case 'determined': brow(lx, -1, -1.2); brow(rx, 1, -1.2); eye(lx, 0.9); eye(rx, 0.9);
|
||||
flatMouth(g, hx, hy + 11, true); break;
|
||||
default: brow(lx, -1, 0); brow(rx, -1, 0); eye(lx, 0.9); eye(rx, 0.9);
|
||||
flatMouth(g, hx, hy + 11);
|
||||
}
|
||||
// scar for vengeful/cruel
|
||||
if ((c.traits || []).some(t => t === 'vengeful' || t === 'cruel')) {
|
||||
g.strokeStyle = 'rgba(140,80,66,0.8)'; g.lineWidth = 1.6;
|
||||
g.beginPath(); g.moveTo(rx - 2, eyeY - 8); g.lineTo(rx + 3, eyeY + 6); g.stroke();
|
||||
}
|
||||
// red seal
|
||||
g.save();
|
||||
g.translate(w - 24, h - 22); g.rotate(-0.06);
|
||||
g.fillStyle = '#a33327';
|
||||
roundRect(g, -11, -11, 22, 22, 3); g.fill();
|
||||
g.fillStyle = '#efe6d4'; g.font = 'bold 13px "Kaiti SC","STKaiti","KaiTi",serif';
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
g.fillText(cnChar(c), 0, 1);
|
||||
g.restore();
|
||||
// frame
|
||||
g.strokeStyle = 'rgba(90,78,60,0.65)'; g.lineWidth = 2; g.strokeRect(1, 1, w - 2, h - 2);
|
||||
P_CACHE.set(key, cv);
|
||||
return cv;
|
||||
};
|
||||
function cnChar(c) { return (c.cn && c.cn[0]) || (c.name && c.name[0]) || '武'; }
|
||||
function stroke(g, x1, y1, x2, y2, w) { g.lineWidth = w; g.beginPath(); g.moveTo(x1, y1); g.lineTo(x2, y2); g.stroke(); }
|
||||
function smile(g, x, y, big) { g.strokeStyle = '#5a3a30'; g.lineWidth = 2; g.beginPath(); g.moveTo(x - 7, y); g.quadraticCurveTo(x, y + (big ? 6 : 4), x + 7, y); g.stroke(); }
|
||||
function frown(g, x, y, soft) { g.strokeStyle = '#5a3a30'; g.lineWidth = 2; g.beginPath(); g.moveTo(x - 6, y + 3); g.quadraticCurveTo(x, y - (soft ? 2 : 3), x + 6, y + 3); g.stroke(); }
|
||||
function flatMouth(g, x, y, firm) { g.strokeStyle = '#5a3a30'; g.lineWidth = firm ? 2.6 : 2; g.beginPath(); g.moveTo(x - 5, y); g.lineTo(x + 5, y); g.stroke(); }
|
||||
function oMouth(g, x, y) { g.strokeStyle = '#5a3a30'; g.lineWidth = 2; g.beginPath(); g.ellipse(x, y, 3.4, 4.4, 0, 0, 7); g.stroke(); }
|
||||
function blush(g, lx, rx, y) { g.fillStyle = 'rgba(196,110,90,0.35)'; g.beginPath(); g.ellipse(lx, y, 5, 2.4, 0, 0, 7); g.fill(); g.beginPath(); g.ellipse(rx, y, 5, 2.4, 0, 0, 7); g.fill(); }
|
||||
function tear(g, x, y) { g.fillStyle = 'rgba(130,160,190,0.7)'; g.beginPath(); g.ellipse(x, y + 6, 1.6, 3, 0, 0, 7); g.fill(); }
|
||||
function bruise(g, x, y) { g.fillStyle = 'rgba(140,100,120,0.4)'; g.beginPath(); g.ellipse(x, y, 6, 4, 0.4, 0, 7); g.fill(); }
|
||||
function bandage(g, x, y) { g.strokeStyle = '#e8e0cc'; g.lineWidth = 4.5; g.beginPath(); g.moveTo(x - 16, y - 4); g.lineTo(x + 16, y - 10); g.stroke(); g.strokeStyle = 'rgba(90,80,66,0.4)'; g.lineWidth = 1; g.beginPath(); g.moveTo(x - 16, y - 4); g.lineTo(x + 16, y - 10); g.stroke(); }
|
||||
function shadeC(hex, amt) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const r = U.clamp((n >> 16) + amt, 0, 255), gg = U.clamp(((n >> 8) & 255) + amt, 0, 255), b = U.clamp((n & 255) + amt, 0, 255);
|
||||
return '#' + ((r << 16) | (gg << 8) | b).toString(16).padStart(6, '0');
|
||||
}
|
||||
function roundRect(g, x, y, w, h, r) {
|
||||
g.beginPath();
|
||||
g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r);
|
||||
g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath();
|
||||
}
|
||||
|
||||
/* ================= EVENT SCENE ART ================= */
|
||||
const SCENES = {
|
||||
bridge_rain: { place: 'bridge', weather: 'rain', hour: 'dusk' },
|
||||
town_night: { place: 'town', hour: 'night', lanterns: true },
|
||||
teahouse: { place: 'interior', props: ['table', 'lantern'] },
|
||||
village_dusk: { place: 'village', hour: 'dusk' },
|
||||
village_rain: { place: 'village', weather: 'rain' },
|
||||
mountain_pass: { place: 'pass', props: ['fallenpine'] },
|
||||
temple_court: { place: 'temple' },
|
||||
arena: { place: 'arena', crowd: true },
|
||||
valley_mist: { place: 'valley', fog: 1 },
|
||||
blood_moon: { place: 'ridge', hour: 'bloodmoon' },
|
||||
camp_fire: { place: 'camp', props: ['fire'], hour: 'night' },
|
||||
market_day: { place: 'town', props: ['stalls'] },
|
||||
city_gate: { place: 'gate' },
|
||||
bamboo_light: { place: 'bamboo', godrays: 1 },
|
||||
tomb_dark: { place: 'tomb', hour: 'night', props: ['torches'] },
|
||||
tomb_inner: { place: 'tomb', hour: 'night', props: ['coffin', 'torches'] },
|
||||
valley_hidden: { place: 'valley', props: ['peach'] },
|
||||
village_day: { place: 'village' },
|
||||
camp_throne: { place: 'camp', props: ['throne'] },
|
||||
camp_palisade: { place: 'camp', props: ['palisade'] },
|
||||
caravan_dusk: { place: 'pass', props: ['cart'], hour: 'dusk' },
|
||||
inn_night: { place: 'interior', hour: 'night', props: ['bed'] },
|
||||
mansion_hall: { place: 'interior', props: ['pillars', 'table'] },
|
||||
river_night: { place: 'river', hour: 'night', props: ['boat'], lanterns: true },
|
||||
sect_yard: { place: 'sectyard' },
|
||||
sect_kitchen: { place: 'interior', props: ['pots'] },
|
||||
sect_night: { place: 'sectyard', hour: 'night' },
|
||||
sect_feast: { place: 'sectyard', props: ['fire', 'tables'] },
|
||||
sect_wall_night: { place: 'wall', hour: 'night', lanterns: true },
|
||||
sect_gate: { place: 'gate', props: ['staff'] },
|
||||
sect_gate_rain: { place: 'gate', weather: 'rain' },
|
||||
sect_hall_night: { place: 'interior', hour: 'night', props: ['pillars'] },
|
||||
library_night: { place: 'interior', hour: 'night', props: ['shelves'] },
|
||||
shrine_rain: { place: 'shrine', weather: 'rain' },
|
||||
funeral_rain: { place: 'village', weather: 'rain', props: ['banners'] },
|
||||
camp_night: { place: 'camp', hour: 'night', props: ['fire'] },
|
||||
river_fog: { place: 'river', fog: 1, props: ['boat'] },
|
||||
sword_mound: { place: 'ridge', props: ['swords'] },
|
||||
};
|
||||
|
||||
W.sceneArt = function (sceneId, w, h) {
|
||||
const key = sceneId + '|' + w + 'x' + h;
|
||||
if (S_CACHE.has(key)) return S_CACHE.get(key);
|
||||
const cfg = SCENES[sceneId] || { place: 'valley', fog: 1 };
|
||||
const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
|
||||
const g = cv.getContext('2d');
|
||||
paintScene(g, cfg, w, h);
|
||||
S_CACHE.set(key, cv);
|
||||
return cv;
|
||||
};
|
||||
|
||||
function paintScene(g, cfg, w, h) {
|
||||
const night = cfg.hour === 'night', dusk = cfg.hour === 'dusk', blood = cfg.hour === 'bloodmoon';
|
||||
// sky
|
||||
const grd = g.createLinearGradient(0, 0, 0, h);
|
||||
if (blood) { grd.addColorStop(0, '#3a1620'); grd.addColorStop(0.6, '#6b2836'); grd.addColorStop(1, '#402028'); }
|
||||
else if (night) { grd.addColorStop(0, '#232a3a'); grd.addColorStop(0.7, '#39415a'); grd.addColorStop(1, '#4a4f63'); }
|
||||
else if (dusk) { grd.addColorStop(0, '#d9b98a'); grd.addColorStop(0.6, '#c99a72'); grd.addColorStop(1, '#a98268'); }
|
||||
else { grd.addColorStop(0, '#dfe3d8'); grd.addColorStop(0.65, '#c2cabd'); grd.addColorStop(1, '#a8b2a4'); }
|
||||
g.fillStyle = grd; g.fillRect(0, 0, w, h);
|
||||
// moon/sun
|
||||
g.save();
|
||||
if (blood) { g.fillStyle = 'rgba(200,60,50,0.95)'; disc(g, w * 0.76, h * 0.24, 34); glow(g, w * 0.76, h * 0.24, 90, 'rgba(180,40,40,0.25)'); }
|
||||
else if (night) { g.fillStyle = 'rgba(238,238,224,0.92)'; disc(g, w * 0.76, h * 0.2, 24); glow(g, w * 0.76, h * 0.2, 70, 'rgba(230,230,215,0.18)'); }
|
||||
else { g.fillStyle = 'rgba(255,250,235,0.8)'; glow(g, w * 0.72, h * 0.22, 80, 'rgba(255,248,225,0.5)'); }
|
||||
g.restore();
|
||||
// mountains layers
|
||||
mtnLayer(g, w, h, h * 0.52, 'rgba(90,96,88,0.5)');
|
||||
mtnLayer(g, w, h, h * 0.62, 'rgba(70,76,68,0.65)');
|
||||
// ground
|
||||
g.fillStyle = night ? 'rgba(52,56,52,0.9)' : dusk ? 'rgba(120,104,84,0.85)' : 'rgba(140,146,124,0.9)';
|
||||
g.fillRect(0, h * 0.66, w, h * 0.34);
|
||||
|
||||
const P = {
|
||||
bridge: () => {
|
||||
// arched bridge silhouette over water gap
|
||||
water(g, w, h * 0.78, night ? '#2e3a44' : '#5a7276');
|
||||
g.strokeStyle = night ? '#1c2026' : '#3a3630'; g.lineWidth = 7;
|
||||
g.beginPath(); g.moveTo(w * 0.08, h * 0.72); g.quadraticCurveTo(w * 0.5, h * 0.5, w * 0.92, h * 0.72); g.stroke();
|
||||
g.lineWidth = 3;
|
||||
for (const t of [0.25, 0.4, 0.5, 0.6, 0.75]) {
|
||||
const bx = w * (0.08 + 0.84 * t), by = h * (0.72 - 0.22 * Math.sin(Math.PI * t));
|
||||
g.beginPath(); g.moveTo(bx, by); g.lineTo(bx, by - 16); g.stroke();
|
||||
}
|
||||
figure(g, w * 0.5, h * 0.56, night ? '#14161a' : '#2e2a26', 1.1, true);
|
||||
},
|
||||
town: () => {
|
||||
skyline(g, w, h * 0.66, 7, night);
|
||||
if (cfg.lanterns !== false && night) for (let i = 0; i < 6; i++) { glow(g, w * (0.12 + i * 0.15), h * 0.6, 26, 'rgba(255,180,90,0.5)'); }
|
||||
},
|
||||
interior: () => {
|
||||
g.fillStyle = night ? '#2c2823' : '#4a4238'; g.fillRect(0, 0, w, h);
|
||||
// floor perspective
|
||||
g.fillStyle = night ? '#3a342c' : '#5c5244';
|
||||
g.beginPath(); g.moveTo(0, h); g.lineTo(w * 0.3, h * 0.55); g.lineTo(w * 0.7, h * 0.55); g.lineTo(w, h); g.closePath(); g.fill();
|
||||
if ((cfg.props || []).includes('pillars')) { g.fillStyle = night ? '#211d19' : '#38302a'; for (const px of [w * 0.16, w * 0.5, w * 0.84]) g.fillRect(px - 12, 0, 24, h * 0.62); }
|
||||
if ((cfg.props || []).includes('shelves')) { g.fillStyle = '#241f1a'; for (let i = 0; i < 4; i++) g.fillRect(w * 0.05 + i * w * 0.24, h * 0.2, w * 0.18, h * 0.4); }
|
||||
if ((cfg.props || []).includes('table')) { g.fillStyle = '#3a2f26'; g.fillRect(w * 0.36, h * 0.66, w * 0.28, 10); g.fillRect(w * 0.4, h * 0.68, 8, h * 0.2); g.fillRect(w * 0.58, h * 0.68, 8, h * 0.2); glow(g, w * 0.5, h * 0.62, 30, 'rgba(255,190,110,0.4)'); }
|
||||
if (cfg.props && cfg.props.includes('lantern')) glow(g, w * 0.5, h * 0.3, 40, 'rgba(255,180,90,0.4)');
|
||||
if ((cfg.props || []).includes('bed')) { g.fillStyle = '#2e2822'; g.fillRect(w * 0.1, h * 0.7, w * 0.34, 14); }
|
||||
if ((cfg.props || []).includes('pots')) { for (let i = 0; i < 3; i++) { g.fillStyle = '#33302a'; disc(g, w * (0.3 + i * 0.18), h * 0.72, 14); } glow(g, w * 0.5, h * 0.7, 50, 'rgba(255,170,80,0.3)'); }
|
||||
},
|
||||
village: () => {
|
||||
for (let i = 0; i < 4; i++) hut(g, w * (0.12 + i * 0.24), h * 0.68, 1, night, dusk);
|
||||
paddies(g, w, h, night);
|
||||
if ((cfg.props || []).includes('banners')) for (let i = 0; i < 5; i++) bannerWhite(g, w * (0.15 + i * 0.18), h * 0.72);
|
||||
},
|
||||
pass: () => {
|
||||
mtnLayer(g, w, h, h * 0.4, 'rgba(80,86,78,0.7)');
|
||||
if ((cfg.props || []).includes('fallenpine')) { g.strokeStyle = '#2c2a24'; g.lineWidth = 9; g.beginPath(); g.moveTo(w * 0.15, h * 0.8); g.lineTo(w * 0.85, h * 0.72); g.stroke(); }
|
||||
if ((cfg.props || []).includes('cart')) { g.fillStyle = '#4a3c2e'; g.fillRect(w * 0.4, h * 0.68, w * 0.24, 16); disc(g, w * 0.45, h * 0.76, 10, '#2e2822'); disc(g, w * 0.6, h * 0.76, 10, '#2e2822'); }
|
||||
},
|
||||
temple: () => {
|
||||
// courtyard + hall
|
||||
g.fillStyle = night ? '#2a2e33' : '#5a5a50'; g.fillRect(0, h * 0.6, w, h * 0.4);
|
||||
hall(g, w * 0.5, h * 0.6, 1.6, night);
|
||||
for (let i = 0; i < 3; i++) incense(g, w * (0.4 + i * 0.1), h * 0.78);
|
||||
lanternRow(g, w, h * 0.58, 5, night);
|
||||
},
|
||||
arena: () => {
|
||||
g.fillStyle = night ? '#33302c' : '#8a7a5e'; g.fillRect(0, h * 0.55, w, h * 0.45);
|
||||
platform(g, w * 0.5, h * 0.62, 1.4);
|
||||
if (cfg.crowd) for (let i = 0; i < 26; i++) blobHead(g, w * (0.03 + (i % 13) * 0.075), h * (0.52 + Math.floor(i / 13) * 0.05), night);
|
||||
flags(g, w, h * 0.5);
|
||||
},
|
||||
valley: () => {
|
||||
mtnLayer(g, w, h, h * 0.45, 'rgba(84,70,92,0.6)');
|
||||
mist(g, w, h, 0.5);
|
||||
if ((cfg.props || []).includes('peach')) for (let i = 0; i < 4; i++) blossomTree(g, w * (0.15 + i * 0.24), h * 0.74);
|
||||
},
|
||||
ridge: () => {
|
||||
mtnLayer(g, w, h, h * 0.42, 'rgba(76,72,66,0.75)');
|
||||
if ((cfg.props || []).includes('swords')) for (let i = 0; i < 30; i++) plantedSword(g, w * (0.04 + (i % 15) * 0.066), h * (0.72 + Math.floor(i / 15) * 0.09));
|
||||
},
|
||||
camp: () => {
|
||||
for (let i = 0; i < 4; i++) tentShape(g, w * (0.12 + i * 0.24), h * 0.72, night);
|
||||
if ((cfg.props || []).includes('fire')) { fireGlow(g, w * 0.5, h * 0.8); }
|
||||
if ((cfg.props || []).includes('palisade')) { g.fillStyle = '#2e2a24'; for (let i = 0; i < 14; i++) g.fillRect(w * i / 14, h * 0.5, 10, h * 0.2); }
|
||||
if ((cfg.props || []).includes('throne')) { g.fillStyle = '#3a322a'; g.fillRect(w * 0.44, h * 0.5, w * 0.12, h * 0.2); g.fillStyle = '#5a4a38'; g.fillRect(w * 0.46, h * 0.46, w * 0.08, 10); }
|
||||
},
|
||||
gate: () => {
|
||||
wallGate(g, w, h, night);
|
||||
if ((cfg.props || []).includes('staff')) { g.strokeStyle = '#2e2a24'; g.lineWidth = 4; g.beginPath(); g.moveTo(w * 0.62, h * 0.86); g.lineTo(w * 0.62, h * 0.6); g.stroke(); }
|
||||
},
|
||||
bamboo: () => {
|
||||
for (let i = 0; i < 16; i++) bambooStalk(g, w * (i / 16) + 10, h, 0.7 + (i % 3) * 0.2, night);
|
||||
if (cfg.godrays) godrays(g, w, h);
|
||||
},
|
||||
tomb: () => {
|
||||
g.fillStyle = night ? '#17181c' : '#2c2c30'; g.fillRect(0, 0, w, h);
|
||||
arches(g, w, h, 4);
|
||||
if ((cfg.props || []).includes('torches')) { torch(g, w * 0.2, h * 0.6); torch(g, w * 0.8, h * 0.6); }
|
||||
if ((cfg.props || []).includes('coffin')) { g.fillStyle = '#241f1a'; g.fillRect(w * 0.38, h * 0.66, w * 0.24, 22); g.fillStyle = '#a33327'; g.fillRect(w * 0.47, h * 0.67, 10, 10); }
|
||||
},
|
||||
shrine: () => {
|
||||
hut(g, w * 0.5, h * 0.66, 1.3, night, false, true);
|
||||
rainStreaks(g, w, h, 60);
|
||||
},
|
||||
river: () => {
|
||||
water(g, w, h * 0.7, night ? '#232e38' : '#4a6a70');
|
||||
if ((cfg.props || []).includes('boat')) boat(g, w * 0.55, h * 0.76, night);
|
||||
if (cfg.fog) mist(g, w, h, 0.8);
|
||||
if (cfg.lanterns) for (let i = 0; i < 4; i++) glow(g, w * (0.2 + i * 0.2), h * 0.72, 20, 'rgba(255,170,80,0.4)');
|
||||
},
|
||||
sectyard: () => {
|
||||
g.fillStyle = night ? '#2e3230' : '#94967e'; g.fillRect(0, h * 0.62, w, h * 0.38);
|
||||
posts(g, w, h);
|
||||
hall(g, w * 0.5, h * 0.62, 1.2, night);
|
||||
if ((cfg.props || []).includes('fire')) fireGlow(g, w * 0.3, h * 0.8);
|
||||
if ((cfg.props || []).includes('tables')) for (let i = 0; i < 3; i++) { g.fillStyle = '#4a3c2e'; g.fillRect(w * (0.2 + i * 0.25), h * 0.74, 40, 8); }
|
||||
},
|
||||
wall: () => {
|
||||
g.fillStyle = night ? '#262a30' : '#6a6a5c'; g.fillRect(0, h * 0.55, w, h * 0.45);
|
||||
battlement(g, w, h * 0.55);
|
||||
mountainsFar(g, w, h, night);
|
||||
},
|
||||
};
|
||||
(P[cfg.place] || P.valley)();
|
||||
// weather overlays
|
||||
if (cfg.weather === 'rain') rainStreaks(g, w, h, 120);
|
||||
if (cfg.fog) mist(g, w, h, cfg.fog);
|
||||
// grade
|
||||
if (night) { g.fillStyle = 'rgba(20,24,44,0.25)'; g.fillRect(0, 0, w, h); }
|
||||
const vg = g.createRadialGradient(w / 2, h / 2, h * 0.3, w / 2, h / 2, h * 0.8);
|
||||
vg.addColorStop(0, 'rgba(40,36,30,0)'); vg.addColorStop(1, 'rgba(40,36,30,0.4)');
|
||||
g.fillStyle = vg; g.fillRect(0, 0, w, h);
|
||||
// paper grain
|
||||
for (let i = 0; i < 500; i++) { g.fillStyle = `rgba(90,80,60,${Math.random() * 0.05})`; g.fillRect(Math.random() * w, Math.random() * h, 1.5, 1); }
|
||||
}
|
||||
|
||||
/* --- scene helpers --- */
|
||||
function disc(g, x, y, r, col) { if (col) g.fillStyle = col; g.beginPath(); g.arc(x, y, r, 0, 7); g.fill(); }
|
||||
function glow(g, x, y, r, col) { const gr = g.createRadialGradient(x, y, 2, x, y, r); gr.addColorStop(0, col); gr.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = gr; g.fillRect(x - r, y - r, r * 2, r * 2); }
|
||||
function mtnLayer(g, w, h, baseY, col) {
|
||||
g.fillStyle = col; g.beginPath(); g.moveTo(0, h);
|
||||
let x = 0;
|
||||
while (x < w) { const pw = 60 + Math.random() * 130; g.lineTo(x + pw / 2, baseY - 20 - Math.random() * 90); g.lineTo(x + pw, baseY); x += pw; }
|
||||
g.lineTo(w, h); g.closePath(); g.fill();
|
||||
}
|
||||
function mountainsFar(g, w, h, night) {
|
||||
g.fillStyle = night ? 'rgba(40,46,60,0.8)' : 'rgba(110,118,108,0.6)';
|
||||
g.beginPath(); g.moveTo(0, h * 0.55);
|
||||
let x = 0; while (x < w) { const pw = 90 + Math.random() * 100; g.lineTo(x + pw / 2, h * 0.4 - Math.random() * 40); g.lineTo(x + pw, h * 0.52); x += pw; }
|
||||
g.lineTo(w, h * 0.55); g.closePath(); g.fill();
|
||||
}
|
||||
function water(g, x0, y, col) { g.fillStyle = col; g.fillRect(0, y, x0, 9999); g.strokeStyle = 'rgba(220,230,228,0.25)'; g.lineWidth = 1.5; for (let i = 0; i < 5; i++) { g.beginPath(); const yy = y + 8 + i * 12; g.moveTo(30 + Math.random() * 100, yy); g.bezierCurveTo(x0 * 0.4, yy - 3, x0 * 0.6, yy + 3, x0 - 40, yy); g.stroke(); } }
|
||||
function skyline(g, w, baseY, n, night) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const bx = w * (0.08 + i * 0.135), bw2 = w * 0.1, bh = 40 + Math.random() * 60;
|
||||
g.fillStyle = night ? '#1e222a' : '#5a564c';
|
||||
g.fillRect(bx, baseY - bh, bw2, bh);
|
||||
roof(g, bx + bw2 / 2, baseY - bh, bw2 * 0.8, night);
|
||||
if (night) { g.fillStyle = 'rgba(255,190,110,0.85)'; g.fillRect(bx + 6, baseY - bh + 12, 7, 9); g.fillRect(bx + bw2 - 13, baseY - bh + 20, 7, 9); }
|
||||
}
|
||||
}
|
||||
function roof(g, cx, y, wd, night) {
|
||||
g.fillStyle = night ? '#161a20' : '#3e3a34';
|
||||
g.beginPath(); g.moveTo(cx - wd / 2 - 8, y); g.quadraticCurveTo(cx, y - 14, cx + wd / 2 + 8, y); g.lineTo(cx + wd / 2 - 6, y + 6); g.lineTo(cx - wd / 2 + 6, y + 6); g.closePath(); g.fill();
|
||||
}
|
||||
function hut(g, x, y, s, night, dusk, broken) {
|
||||
g.fillStyle = night ? '#22261f' : dusk ? '#7a6a52' : '#8a8468';
|
||||
g.fillRect(x - 22 * s, y - 26 * s, 44 * s, 26 * s);
|
||||
roof(g, x, y - 26 * s, 52 * s, night);
|
||||
g.fillStyle = night ? '#141511' : '#4a4436';
|
||||
g.fillRect(x - 6 * s, y - 14 * s, 12 * s, 14 * s);
|
||||
if (!broken && !night && Math.random() < 0.7) { g.fillStyle = '#6a7a4a'; g.fillRect(x - 30 * s, y - 6 * s, 8 * s, 6 * s); }
|
||||
}
|
||||
function paddies(g, w, h, night) {
|
||||
g.strokeStyle = night ? 'rgba(90,110,120,0.3)' : 'rgba(120,150,140,0.5)';
|
||||
for (let i = 0; i < 3; i++) { g.lineWidth = 2; g.beginPath(); g.moveTo(0, h * (0.8 + i * 0.06)); g.quadraticCurveTo(w / 2, h * (0.78 + i * 0.06), w, h * (0.8 + i * 0.06)); g.stroke(); }
|
||||
}
|
||||
function bannerWhite(g, x, y) {
|
||||
g.strokeStyle = '#4a443a'; g.lineWidth = 2.4;
|
||||
g.beginPath(); g.moveTo(x, y); g.lineTo(x, y - 60); g.stroke();
|
||||
g.fillStyle = 'rgba(226,222,210,0.9)';
|
||||
g.fillRect(x - 1, y - 60, 16, 34);
|
||||
}
|
||||
function hall(g, x, y, s, night) {
|
||||
g.fillStyle = night ? '#2a2c30' : '#6a5f4c';
|
||||
g.fillRect(x - 50 * s, y - 54 * s, 100 * s, 54 * s);
|
||||
g.strokeStyle = 'rgba(30,28,24,0.6)'; g.strokeRect(x - 50 * s, y - 54 * s, 100 * s, 54 * s);
|
||||
g.fillStyle = night ? '#1c1e22' : '#463c30';
|
||||
for (const dx of [-36, -12, 12, 36]) g.fillRect(x + dx * s - 3 * s, y - 44 * s, 6 * s, 44 * s);
|
||||
g.fillStyle = night ? '#181a1f' : '#38322a';
|
||||
g.beginPath();
|
||||
g.moveTo(x - 66 * s, y - 54 * s);
|
||||
g.quadraticCurveTo(x - 70 * s, y - 66 * s, x - 58 * s, y - 68 * s);
|
||||
g.quadraticCurveTo(x, y - 80 * s, x + 58 * s, y - 68 * s);
|
||||
g.quadraticCurveTo(x + 70 * s, y - 66 * s, x + 66 * s, y - 54 * s);
|
||||
g.quadraticCurveTo(x, y - 62 * s, x - 66 * s, y - 54 * s);
|
||||
g.closePath(); g.fill();
|
||||
g.fillStyle = '#a33327'; g.fillRect(x - 12 * s, y - 44 * s, 24 * s, 12 * s);
|
||||
}
|
||||
function incense(g, x, y) {
|
||||
g.strokeStyle = '#8a7a62'; g.lineWidth = 2;
|
||||
g.beginPath(); g.moveTo(x, y); g.lineTo(x, y - 18); g.stroke();
|
||||
g.strokeStyle = 'rgba(200,200,200,0.4)'; g.lineWidth = 1.4;
|
||||
g.beginPath(); g.moveTo(x, y - 20);
|
||||
g.bezierCurveTo(x + 6, y - 34, x - 8, y - 44, x + 4, y - 60);
|
||||
g.stroke();
|
||||
disc(g, x + 4, y - 61, 2, 'rgba(230,230,220,0.7)');
|
||||
}
|
||||
function lanternRow(g, w, y, n, night) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = w * (0.15 + (i / (n - 1)) * 0.7);
|
||||
g.strokeStyle = '#3a342c'; g.lineWidth = 1.6;
|
||||
g.beginPath(); g.moveTo(x, y); g.lineTo(x, y - 22); g.stroke();
|
||||
disc(g, x, y - 26, 6, night ? '#d8904a' : '#b0563e');
|
||||
if (night) glow(g, x, y - 26, 22, 'rgba(255,180,90,0.5)');
|
||||
}
|
||||
}
|
||||
function platform(g, x, y, s) {
|
||||
g.fillStyle = '#5c5244'; g.fillRect(x - 70 * s, y, 140 * s, 14 * s);
|
||||
g.fillStyle = '#4a4236'; g.fillRect(x - 70 * s, y + 14 * s, 10 * s, 26 * s); g.fillRect(x + 60 * s, y + 14 * s, 10 * s, 26 * s);
|
||||
figure(g, x, y - 6, '#2c2a26', s, true);
|
||||
}
|
||||
function blobHead(g, x, y, night) { disc(g, x, y, 5, night ? '#191b20' : '#3c3830'); g.fillStyle = night ? '#15171b' : '#34302a'; g.fillRect(x - 5, y + 4, 10, 10); }
|
||||
function flags(g, w, y) { for (let i = 0; i < 4; i++) { const x = w * (0.1 + i * 0.26); g.strokeStyle = '#3a362e'; g.lineWidth = 2; g.beginPath(); g.moveTo(x, y + 40); g.lineTo(x, y - 30); g.stroke(); g.fillStyle = '#a3402e'; g.beginPath(); g.moveTo(x, y - 30); g.lineTo(x + 18, y - 24); g.lineTo(x, y - 16); g.closePath(); g.fill(); } }
|
||||
function mist(g, w, h, amt) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = h * (0.45 + i * 0.1);
|
||||
const gr = g.createLinearGradient(0, y - 30, 0, y + 30);
|
||||
gr.addColorStop(0, 'rgba(215,218,214,0)');
|
||||
gr.addColorStop(0.5, `rgba(215,218,214,${0.25 * amt})`);
|
||||
gr.addColorStop(1, 'rgba(215,218,214,0)');
|
||||
g.fillStyle = gr; g.fillRect(0, y - 30, w, 60);
|
||||
}
|
||||
}
|
||||
function blossomTree(g, x, y) {
|
||||
g.strokeStyle = '#3c342c'; g.lineWidth = 4;
|
||||
g.beginPath(); g.moveTo(x, y); g.quadraticCurveTo(x + 4, y - 20, x - 2, y - 34); g.stroke();
|
||||
for (let i = 0; i < 8; i++) disc(g, x - 14 + Math.random() * 28, y - 34 - Math.random() * 14, 3.4, 'rgba(196,140,150,0.8)');
|
||||
}
|
||||
function plantedSword(g, x, y) {
|
||||
g.strokeStyle = '#3a362e'; g.lineWidth = 2.2;
|
||||
const len = 18 + Math.random() * 14;
|
||||
const tilt = (Math.random() - 0.5) * 0.5;
|
||||
g.beginPath(); g.moveTo(x, y); g.lineTo(x + tilt * 8, y - len); g.stroke();
|
||||
g.beginPath(); g.moveTo(x + tilt * 8 - 4, y - len + 4); g.lineTo(x + tilt * 8 + 4, y - len + 2); g.stroke();
|
||||
}
|
||||
function tentShape(g, x, y, night) {
|
||||
g.fillStyle = night ? '#26231e' : '#8a7a5c';
|
||||
g.beginPath(); g.moveTo(x - 26, y); g.lineTo(x, y - 34); g.lineTo(x + 26, y); g.closePath(); g.fill();
|
||||
g.strokeStyle = 'rgba(30,28,24,0.6)'; g.stroke();
|
||||
}
|
||||
function fireGlow(g, x, y) {
|
||||
glow(g, x, y, 60, 'rgba(255,150,60,0.5)');
|
||||
g.strokeStyle = '#d87a3a'; g.lineWidth = 3;
|
||||
g.beginPath(); g.moveTo(x - 4, y); g.quadraticCurveTo(x, y - 16, x + 3, y - 4); g.stroke();
|
||||
}
|
||||
function wallGate(g, w, h, night) {
|
||||
g.fillStyle = night ? '#23262c' : '#6a6456';
|
||||
g.fillRect(0, h * 0.3, w, h * 0.4);
|
||||
// battlements
|
||||
for (let i = 0; i < 10; i++) g.fillRect(w * i / 10, h * 0.26, w / 18, h * 0.05);
|
||||
// gate opening
|
||||
g.fillStyle = night ? '#101216' : '#26221c';
|
||||
g.beginPath(); g.moveTo(w * 0.42, h * 0.7); g.lineTo(w * 0.42, h * 0.5); g.quadraticCurveTo(w * 0.5, h * 0.42, w * 0.58, h * 0.5); g.lineTo(w * 0.58, h * 0.7); g.closePath(); g.fill();
|
||||
roof(g, w * 0.5, h * 0.28, w * 0.3, night);
|
||||
}
|
||||
function battlement(g, w, y) {
|
||||
g.fillStyle = '#3a3a32';
|
||||
for (let i = 0; i < 12; i++) g.fillRect(w * i / 12, y - 16, 14, 16);
|
||||
}
|
||||
function bambooStalk(g, x, baseY, s, night) {
|
||||
const top = baseY - (120 + Math.random() * 160) * s;
|
||||
g.strokeStyle = night ? 'rgba(40,56,44,0.9)' : 'rgba(84,116,84,0.9)';
|
||||
g.lineWidth = 3 + s * 2;
|
||||
g.beginPath(); g.moveTo(x, baseY); g.quadraticCurveTo(x + 6 * s, baseY / 2, x + (Math.random() * 10 - 5), top); g.stroke();
|
||||
for (let seg = 1; seg < 6; seg++) {
|
||||
const yy = baseY + (top - baseY) * seg / 6;
|
||||
g.lineWidth = 2; g.beginPath(); g.moveTo(x - 3, yy); g.lineTo(x + 3, yy - 2); g.stroke();
|
||||
}
|
||||
}
|
||||
function godrays(g, w, h) {
|
||||
g.save(); g.globalAlpha = 0.14;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
g.fillStyle = '#fdf6da';
|
||||
g.beginPath();
|
||||
const x = w * (0.15 + i * 0.18);
|
||||
g.moveTo(x, 0); g.lineTo(x + 40, 0); g.lineTo(x + 110, h); g.lineTo(x + 40, h); g.closePath(); g.fill();
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
function arches(g, w, h, n) {
|
||||
g.strokeStyle = '#2e3038'; g.lineWidth = 10;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const ax = w * (i + 0.5) / n;
|
||||
g.beginPath(); g.moveTo(ax - 26, h); g.lineTo(ax - 26, h * 0.5); g.quadraticCurveTo(ax, h * 0.36, ax + 26, h * 0.5); g.lineTo(ax + 26, h); g.stroke();
|
||||
}
|
||||
}
|
||||
function torch(g, x, y) {
|
||||
g.strokeStyle = '#4a4034'; g.lineWidth = 3;
|
||||
g.beginPath(); g.moveTo(x, y + 30); g.lineTo(x, y); g.stroke();
|
||||
glow(g, x, y - 4, 40, 'rgba(255,160,60,0.55)');
|
||||
disc(g, x, y - 4, 4, '#e8934a');
|
||||
}
|
||||
function rainStreaks(g, w, h, n) {
|
||||
g.strokeStyle = 'rgba(190,205,212,0.4)'; g.lineWidth = 1.2;
|
||||
g.beginPath();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = Math.random() * w, y = Math.random() * h, l = 12 + Math.random() * 14;
|
||||
g.moveTo(x, y); g.lineTo(x - l * 0.3, y + l);
|
||||
}
|
||||
g.stroke();
|
||||
}
|
||||
function boat(g, x, y, night) {
|
||||
g.fillStyle = night ? '#1c1a16' : '#3c342a';
|
||||
g.beginPath(); g.moveTo(x - 50, y); g.quadraticCurveTo(x, y + 14, x + 50, y); g.quadraticCurveTo(x, y + 6, x - 50, y); g.closePath(); g.fill();
|
||||
g.strokeStyle = '#2a241c'; g.lineWidth = 2.4;
|
||||
g.beginPath(); g.moveTo(x + 20, y + 2); g.lineTo(x + 34, y - 34); g.stroke();
|
||||
}
|
||||
function posts(g, w, h) {
|
||||
g.strokeStyle = '#4a3e30'; g.lineWidth = 5;
|
||||
for (let i = 0; i < 3; i++) { g.beginPath(); g.moveTo(w * (0.3 + i * 0.2), h * 0.86); g.lineTo(w * (0.3 + i * 0.2), h * 0.68); g.stroke(); }
|
||||
}
|
||||
function figure(g, x, y, col, s, pose) {
|
||||
g.fillStyle = col;
|
||||
// simple standing silhouette
|
||||
g.beginPath();
|
||||
g.moveTo(x - 8 * s, y); g.quadraticCurveTo(x - 9 * s, y - 18 * s, x - 3 * s, y - 26 * s);
|
||||
g.arc(x, y - 29 * s, 5 * s, Math.PI * 1.1, Math.PI * 1.9);
|
||||
g.quadraticCurveTo(x + 9 * s, y - 18 * s, x + 8 * s, y); g.closePath(); g.fill();
|
||||
if (pose) { g.strokeStyle = col; g.lineWidth = 3 * s; g.beginPath(); g.moveTo(x + 6 * s, y - 20 * s); g.lineTo(x + 14 * s, y - 30 * s); g.stroke(); }
|
||||
}
|
||||
|
||||
/* ================= CINEMATICS ================= */
|
||||
const fx = W.fx = { anims: [], splash: null };
|
||||
W.BUS.on('cine', d => {
|
||||
const dur = d.fxT >= 3 ? 1.25 : 0.8;
|
||||
fx.anims.push(Object.assign({ t: 0, dur }, d));
|
||||
if (d.fxT >= 2 && d.name) fx.splash = { name: d.name, cn: d.cn, t: 0, dur: 1.4, tier: d.fxT };
|
||||
});
|
||||
|
||||
fx.render = function (g, dt, Rn) {
|
||||
for (const a of fx.anims.slice()) {
|
||||
a.t += dt;
|
||||
const p = U.clamp(a.t / a.dur, 0, 1);
|
||||
drawCine(g, a, p, Rn);
|
||||
if (p >= 1) fx.anims.splice(fx.anims.indexOf(a), 1);
|
||||
}
|
||||
if (fx.splash) {
|
||||
const s = fx.splash; s.t += dt;
|
||||
const p = s.t / s.dur;
|
||||
if (p >= 1) fx.splash = null;
|
||||
else {
|
||||
const alpha = p < 0.2 ? p / 0.2 : (p > 0.8 ? (1 - p) / 0.2 : 1);
|
||||
g.save();
|
||||
g.globalAlpha = alpha * 0.95;
|
||||
const sx = W.rend.W - 86;
|
||||
g.fillStyle = 'rgba(28,24,20,0.82)';
|
||||
g.fillRect(sx - 8, 90, 74, 260);
|
||||
g.fillStyle = '#e8dfc8';
|
||||
g.font = '600 26px Georgia, serif';
|
||||
g.textAlign = 'center';
|
||||
const nm = s.name.toUpperCase();
|
||||
let yy = 132;
|
||||
for (const word of nm.split(' ')) { g.fillText(word, sx + 29, yy); yy += 30; }
|
||||
g.fillStyle = '#c8503c';
|
||||
g.font = 'bold 34px "Kaiti SC","STKaiti","KaiTi",serif';
|
||||
const cn = (s.cn || '').slice(0, 4);
|
||||
let cy = 300;
|
||||
for (const ch of cn.split('').reverse()) { g.fillText(ch, sx + 29, cy); cy -= 38; }
|
||||
g.fillStyle = '#a33327';
|
||||
g.fillRect(sx + 17, 330, 26, 8);
|
||||
g.restore();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function targetPos(a, Rn) {
|
||||
const u = a.target || a.unit;
|
||||
return { x: (u && u.sx) || W.rend.W / 2, y: (u && u.sy) || W.rend.H / 2 };
|
||||
}
|
||||
function fromPos(a) {
|
||||
const u = a.unit;
|
||||
return { x: (u && u.sx) || W.rend.W / 2, y: (u && u.sy) || W.rend.H / 2 };
|
||||
}
|
||||
|
||||
function drawCine(g, a, p, Rn) {
|
||||
const tp = targetPos(a, Rn), fp = fromPos(a);
|
||||
g.save();
|
||||
switch (a.fx) {
|
||||
case 'slash': {
|
||||
const n = a.fxT >= 3 ? 3 : 1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const pp = U.clamp(p * 1.6 - i * 0.18, 0, 1);
|
||||
if (pp <= 0 || pp >= 1) continue;
|
||||
g.strokeStyle = `rgba(245,245,240,${1 - pp})`;
|
||||
g.lineWidth = 4 - i;
|
||||
g.beginPath();
|
||||
const y0 = tp.y - 40 + i * 22;
|
||||
g.moveTo(tp.x - 120 + pp * 60, y0 - 20);
|
||||
g.quadraticCurveTo(tp.x, y0 + 8, tp.x + 120 - pp * 60, y0 - 24);
|
||||
g.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'palm': case 'claw': case 'fist': {
|
||||
const rr = p * 90;
|
||||
g.strokeStyle = `rgba(240,235,220,${(1 - p) * 0.9})`;
|
||||
g.lineWidth = 5 * (1 - p) + 1;
|
||||
g.beginPath(); g.arc(tp.x, tp.y - 10, rr, 0, 7); g.stroke();
|
||||
g.strokeStyle = `rgba(163,51,39,${(1 - p) * 0.6})`;
|
||||
g.beginPath(); g.arc(tp.x, tp.y - 10, rr * 0.7, 0, 7); g.stroke();
|
||||
if (a.fx === 'claw') for (let i = -1; i <= 1; i++) {
|
||||
g.strokeStyle = `rgba(60,50,44,${1 - p})`; g.lineWidth = 3;
|
||||
g.beginPath(); g.moveTo(tp.x - 50, tp.y - 20 + i * 18);
|
||||
g.quadraticCurveTo(tp.x + i * 10, tp.y - 10 + i * 20, tp.x + 50, tp.y - 16 + i * 18); g.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'needle': case 'spear': {
|
||||
g.strokeStyle = `rgba(220,220,210,${1 - p})`;
|
||||
g.lineWidth = 2;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const ang = -0.5 + i * 0.16;
|
||||
const len = 60 + p * 90;
|
||||
g.beginPath();
|
||||
g.moveTo(fp.x, fp.y - 16);
|
||||
g.lineTo(fp.x + Math.cos(ang) * len, fp.y - 16 + Math.sin(ang) * len * 0.4);
|
||||
g.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'staff': {
|
||||
g.strokeStyle = `rgba(200,190,160,${1 - p})`;
|
||||
g.lineWidth = 6 * (1 - p) + 1;
|
||||
g.beginPath(); g.arc(fp.x, fp.y - 14, 30 + p * 70, -0.8 + p * 2, 0.6 + p * 2); g.stroke();
|
||||
break;
|
||||
}
|
||||
case 'drunken': {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ph = (p * 3 + i) % 1;
|
||||
g.strokeStyle = `rgba(216,150,60,${1 - ph})`;
|
||||
g.lineWidth = 3;
|
||||
g.beginPath(); g.arc(tp.x + Math.sin(ph * 9 + i) * 24, tp.y - 10 - ph * 40, 8 + ph * 12, 0, 7); g.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'thunder': {
|
||||
if (p < 0.4) { g.fillStyle = `rgba(240,244,255,${(0.4 - p) * 1.2})`; g.fillRect(0, 0, W.rend.W, W.rend.H); }
|
||||
g.strokeStyle = `rgba(150,170,255,${1 - p})`;
|
||||
g.lineWidth = 3.5;
|
||||
g.beginPath();
|
||||
let bx = tp.x + W.rf(-30, 30), by = tp.y - 320;
|
||||
g.moveTo(bx, by);
|
||||
for (let i = 0; i < 6; i++) { bx += W.rf(-34, 34); by += 52; g.lineTo(bx, by); }
|
||||
g.stroke();
|
||||
g.strokeStyle = `rgba(240,240,255,${(1 - p) * 0.8})`;
|
||||
g.lineWidth = 1.4; g.stroke();
|
||||
break;
|
||||
}
|
||||
case 'cold': {
|
||||
g.fillStyle = `rgba(190,215,235,${(1 - p) * 0.22})`;
|
||||
g.fillRect(0, 0, W.rend.W, W.rend.H);
|
||||
g.strokeStyle = `rgba(220,240,250,${1 - p})`;
|
||||
g.lineWidth = 1.6;
|
||||
const rr = p * 120;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const ang = i / 6 * Math.PI * 2 + p;
|
||||
g.beginPath();
|
||||
g.moveTo(tp.x + Math.cos(ang) * rr * 0.3, tp.y - 10 + Math.sin(ang) * rr * 0.3);
|
||||
g.lineTo(tp.x + Math.cos(ang) * rr, tp.y - 10 + Math.sin(ang) * rr);
|
||||
g.stroke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'poison': {
|
||||
g.fillStyle = `rgba(122,150,60,${(1 - p) * 0.5})`;
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const ang = i * 2.4, rr = p * 70;
|
||||
g.beginPath(); g.arc(tp.x + Math.cos(ang) * rr, tp.y - 10 + Math.sin(ang) * rr * 0.6, 4 * (1 - p) + 2, 0, 7); g.fill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'drain': case 'shadow': {
|
||||
const n = a.fx === 'shadow' ? 4 : 8;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const pp = (p * 1.4 + i / n) % 1;
|
||||
const xx = fp.x + (tp.x - fp.x) * pp, yy = fp.y - 16 + (tp.y - 16 - fp.y + 16) * pp;
|
||||
g.fillStyle = a.fx === 'shadow' ? `rgba(30,28,36,${(1 - pp) * 0.8})` : `rgba(140,90,190,${(1 - pp) * 0.7})`;
|
||||
g.beginPath(); g.arc(xx, yy, 3.4, 0, 7); g.fill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'blood': {
|
||||
g.strokeStyle = `rgba(150,30,30,${1 - p})`;
|
||||
g.lineWidth = 4 * (1 - p) + 1;
|
||||
g.beginPath();
|
||||
g.arc(tp.x, tp.y - 12, p * 80, p * 6, p * 6 + 2.2); g.stroke();
|
||||
g.beginPath();
|
||||
g.arc(tp.x, tp.y - 12, p * 55, -p * 5, -p * 5 + 2.8); g.stroke();
|
||||
g.fillStyle = `rgba(160,30,30,${1 - p})`;
|
||||
for (let i = 0; i < 10; i++) { g.beginPath(); g.arc(tp.x + W.rf(-60, 60) * p, tp.y - 10 - W.rf(0, 60) * p, 2.4, 0, 7); g.fill(); }
|
||||
break;
|
||||
}
|
||||
case 'heal': {
|
||||
g.strokeStyle = `rgba(140,200,140,${1 - p})`;
|
||||
g.lineWidth = 2;
|
||||
g.beginPath(); g.arc(tp.x, tp.y - 10, 14 + p * 44, 0, 7); g.stroke();
|
||||
g.fillStyle = `rgba(190,230,160,${1 - p})`;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const ang = i / 8 * Math.PI * 2;
|
||||
g.beginPath(); g.arc(tp.x + Math.cos(ang + p * 2) * (10 + p * 36), tp.y - 10 + Math.sin(ang + p * 3) * (8 + p * 20) - p * 30, 2.6, 0, 7); g.fill();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'buff': {
|
||||
g.strokeStyle = `rgba(230,210,150,${1 - p})`;
|
||||
g.lineWidth = 3 * (1 - p) + 1;
|
||||
g.beginPath(); g.ellipse(fp.x, fp.y - 10 - p * 30, 26, 8, 0, 0, 7); g.stroke();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// generic impact burst
|
||||
const rr = p * 60;
|
||||
g.strokeStyle = `rgba(240,236,224,${1 - p})`;
|
||||
g.lineWidth = 3;
|
||||
g.beginPath(); g.arc(tp.x, tp.y - 10, rr, 0, 7); g.stroke();
|
||||
}
|
||||
}
|
||||
g.restore();
|
||||
}
|
||||
})();
|
||||
+1009
File diff suppressed because it is too large
Load Diff
+719
@@ -0,0 +1,719 @@
|
||||
/* =========================================================================
|
||||
App shell — boot, main loop, input, game-flow orchestration,
|
||||
achievements, save integration
|
||||
========================================================================= */
|
||||
(function () {
|
||||
const U = W.U;
|
||||
const APP = W.app = {
|
||||
animatingCombat: false, cbtSt: { mode: null, menu: null },
|
||||
drag: null, keys: {},
|
||||
};
|
||||
|
||||
/* ---------------- achievements ---------------- */
|
||||
const ACH_DEFS = [
|
||||
{ id: 'first_blood', n: 'First Blood', cn: '初试锋芒', d: 'Win your first battle.' },
|
||||
{ id: 'first_disciple', n: 'First Disciple', cn: '开山收徒', d: 'Recruit your first disciple.' },
|
||||
{ id: 'builder', n: 'Sect Founder', cn: '立派成家', d: 'Raise six different buildings.' },
|
||||
{ id: 'grandmaster', n: 'Grandmaster', cn: '宗师之境', d: 'Reach the Grandmaster realm.' },
|
||||
{ id: 'combo_master', n: 'Combo Master', cn: '合击宗师', d: 'Have three combinations active at once.' },
|
||||
{ id: 'demon_path', n: "Demon's Path", cn: '入魔', d: 'Learn a forbidden art.' },
|
||||
{ id: 'romance', n: 'Jianghu Romance', cn: '江湖情缘', d: 'Fall in love amid the chaos.' },
|
||||
{ id: 'hundred_days', n: '100 Days', cn: '百日功成', d: 'Survive all one hundred days.' },
|
||||
{ id: 'perfect_run', n: 'Perfect Run', cn: '全员生还', d: 'Reach day 100 having lost no one.' },
|
||||
{ id: 'betrayer', n: 'Betrayer', cn: '背盟者', d: 'Betray an alliance you swore to.' },
|
||||
{ id: 'conqueror', n: 'Iron Lotus', cn: '铁莲霸业', d: 'Destroy a rival sect.' },
|
||||
{ id: 'legend_end', n: 'Legend of the Jianghu', cn: '武林传奇', d: 'Earn a legendary ending.' },
|
||||
];
|
||||
const ach = W.ach = {
|
||||
got: W.store.get('wuxia_ach_v1', {}),
|
||||
list() { return ACH_DEFS.map(a => Object.assign({ got: !!ach.got[a.id] }, a)); },
|
||||
unlock(id) {
|
||||
if (ach.got[id]) return;
|
||||
const def = ACH_DEFS.find(a => a.id === id); if (!def) return;
|
||||
ach.got[id] = Date.now();
|
||||
W.store.set('wuxia_ach_v1', ach.got);
|
||||
W.ui.toast(`🏅 Achievement — ${def.n} <span class="cn">${def.cn}</span>`, 'ach');
|
||||
W.audio.sfx('levelup');
|
||||
},
|
||||
check(kind, arg) {
|
||||
switch (kind) {
|
||||
case 'grandmaster': if (arg >= 5) ach.unlock('grandmaster'); break;
|
||||
case 'builder': if (Object.keys(arg.sect.buildings).length >= 6) ach.unlock('builder'); break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------------- settings accessor ---------------- */
|
||||
W.defaultSettings = { music: 0.55, sfx: 0.75, fx: 2, cnText: true };
|
||||
W.settings = () => (W.state ? W.state.settings : (W.storedSettings = W.storedSettings || W.store.get('wuxia_settings_v1', W.defaultSettings)));
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
APP.boot = function () {
|
||||
W.ui.init(document.getElementById('ui'));
|
||||
W.rend.init(document.getElementById('cv'));
|
||||
bindInput();
|
||||
requestAnimationFrame(loop);
|
||||
W.ui.showTitle();
|
||||
document.addEventListener('pointerdown', function once() {
|
||||
W.audio.init();
|
||||
const s = W.settings(); W.audio.setVol(s.music, s.sfx);
|
||||
document.removeEventListener('pointerdown', once);
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------------- main loop ---------------- */
|
||||
let lastT = performance.now();
|
||||
function loop(t) {
|
||||
const dt = Math.min(0.05, (t - lastT) / 1000); lastT = t;
|
||||
const st = W.state;
|
||||
if (st) {
|
||||
updateCamera(st, dt);
|
||||
updateMood(st);
|
||||
try { W.rend.render(st, dt); } catch (e) { console.error('render', e); }
|
||||
} else {
|
||||
// title backdrop
|
||||
try { W.rend.render({ locId: 'home', phase: 2, weatherId: 'wind', settings: W.settings(), chars: {}, party: [], sect: { buildings: {} }, combat: null }, dt); } catch (e) { }
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function updateCamera(st, dt) {
|
||||
const R = W.rend;
|
||||
if (st.combat) {
|
||||
// center the board
|
||||
const grid = st.combat.grid;
|
||||
const targetZ = U.clamp(Math.min(R.W / ((grid.w * 40) + 260), R.H / ((grid.h * 34) + 300)), 0.75, 1.35);
|
||||
R.cam.z += (targetZ - R.cam.z) * Math.min(1, dt * 4);
|
||||
R.cam.x += (0 - R.cam.x) * Math.min(1, dt * 4);
|
||||
R.cam.y += (-30 - R.cam.y) * Math.min(1, dt * 4);
|
||||
return;
|
||||
}
|
||||
const md = R.mapData;
|
||||
if (md && md.poi) {
|
||||
const tx = (md.poi.x - md.poi.y) * R.TW / 2, ty = (md.poi.x + md.poi.y) * R.TH / 2 - 40;
|
||||
R.cam.x += (tx - R.cam.x) * Math.min(1, dt * 2.5);
|
||||
R.cam.y += (ty - R.cam.y) * Math.min(1, dt * 2.5);
|
||||
R.cam.z += (Math.min(1.15, R.cam.z + (1.05 - R.cam.z) * dt)) * 0; // keep zoom user-controlled
|
||||
}
|
||||
}
|
||||
function updateMood(st) {
|
||||
if (st.combat) { W.audio.setMood('combat'); return; }
|
||||
if (st.pendingEvent) { W.audio.setMood('event'); return; }
|
||||
if (st.locId === 'home') W.audio.setMood(W.C.PHASES[st.phase].id === 'night' ? 'night' : 'sect');
|
||||
else if (['town', 'city'].includes(W.locById(st.locId).type)) W.audio.setMood('town');
|
||||
else W.audio.setMood(W.C.PHASES[st.phase].id === 'night' ? 'night' : 'travel');
|
||||
const amb = W.weatherById(st.weatherId).ambient || (['river'].includes(W.locById(st.locId).theme) ? 'river' : (W.C.PHASES[st.phase].id === 'night' ? 'wind' : null));
|
||||
W.audio.ambient(amb);
|
||||
}
|
||||
|
||||
/* ---------------- input ---------------- */
|
||||
function bindInput() {
|
||||
const cv = document.getElementById('cv');
|
||||
cv.addEventListener('mousedown', e => { APP.drag = { x: e.clientX, y: e.clientY, moved: false }; });
|
||||
window.addEventListener('mousemove', e => {
|
||||
const st = W.state;
|
||||
if (APP.drag) {
|
||||
const dx = e.clientX - APP.drag.x, dy = e.clientY - APP.drag.y;
|
||||
if (Math.abs(dx) + Math.abs(dy) > 4) APP.drag.moved = true;
|
||||
if (!st || !st.combat) { W.rend.cam.x -= dx / W.rend.cam.z; W.rend.cam.y -= dy / W.rend.cam.z; }
|
||||
APP.drag.x = e.clientX; APP.drag.y = e.clientY;
|
||||
}
|
||||
if (st && !st.combat) {
|
||||
W.rend.hover = null;
|
||||
const md = W.rend.mapData;
|
||||
if (md) {
|
||||
const t = W.rend.UNTILE(e.clientX, e.clientY, W.rend.cam);
|
||||
const ox = W.rend.mapOrigin;
|
||||
// adjust for map origin offset
|
||||
const wx = (e.clientX - W.rend.W / 2) / W.rend.cam.z + W.rend.cam.x, wy = (e.clientY - W.rend.H / 2) / W.rend.cam.z + W.rend.cam.y;
|
||||
const gx = Math.floor((wx - ox.ox) / (W.rend.TW / 2) === 0 ? 0 : ((wx - ox.ox) / (W.rend.TW / 2) + wy / (W.rend.TH / 2)) / 2);
|
||||
const gy = Math.floor(((wy - ox.oy) / (W.rend.TH / 2) - (wx - ox.ox) / (W.rend.TW / 2)) / 2);
|
||||
if (gx >= 0 && gy >= 0 && gx < md.w && gy < md.h) W.rend.hover = { x: gx, y: gy };
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', e => {
|
||||
const wasDrag = APP.drag && APP.drag.moved;
|
||||
APP.drag = null;
|
||||
if (wasDrag) return;
|
||||
const st = W.state; if (!st) return;
|
||||
if (st.combat) handleCombatClick(e.clientX, e.clientY);
|
||||
});
|
||||
cv.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
const R = W.rend;
|
||||
R.cam.z = U.clamp(R.cam.z * (e.deltaY > 0 ? 0.92 : 1.08), 0.55, 1.8);
|
||||
}, { passive: false });
|
||||
window.addEventListener('keydown', e => {
|
||||
APP.keys[e.key.toLowerCase()] = true;
|
||||
const st = W.state;
|
||||
if (!st) return;
|
||||
if (e.key === 'Escape') { W.ui.closePanel(); W.ui.clearModal(); W.app.cbtState().mode = null; W.app.cbtState().menu = null; W.ui.refreshCombat(); }
|
||||
});
|
||||
window.addEventListener('keyup', e => { APP.keys[e.key.toLowerCase()] = false; });
|
||||
window.addEventListener('resize', () => { });
|
||||
}
|
||||
function camKeys(dt) {
|
||||
const R = W.rend, k = APP.keys, sp = 420 * dt / R.cam.z;
|
||||
if (k['arrowleft'] || k['a']) R.cam.x -= sp;
|
||||
if (k['arrowright'] || k['d']) R.cam.x += sp;
|
||||
if (k['arrowup'] || k['w']) R.cam.y -= sp;
|
||||
if (k['arrowdown'] || k['s']) R.cam.y += sp;
|
||||
}
|
||||
setInterval(() => camKeys(0.016), 16);
|
||||
|
||||
/* ================= GAME FLOW ================= */
|
||||
APP.newGame = function (opts) {
|
||||
W.state = W.simHelpers ? null : null;
|
||||
W.state = newStateWrapper(opts);
|
||||
const st = W.state;
|
||||
st.assign = {};
|
||||
W.ui.hideAllScreens();
|
||||
W.ui.showGame();
|
||||
W.ui.clearModal();
|
||||
W.audio.setMood('sect');
|
||||
APP.autosave();
|
||||
W.ui.notice('Day One · 立派',
|
||||
`${st.sect.name} (${st.sect.cn}) stands half-ruined above the valley road. You have ${st.res.gold} gold, some food, and ${W.sim.disciples(st).length} sworn followers.<br><br>
|
||||
The jianghu gives you <b>one hundred days</b>. Explore. Recruit. Learn forbidden techniques if you dare. Raise disciples, master combinations of martial arts, survive the wars — and decide what your sect becomes.<br><br>
|
||||
<span class="dim">Each day grants limited actions (AP). End the day to recover, train assigned disciples, and let the world move.</span>`,
|
||||
() => { });
|
||||
};
|
||||
function newStateWrapper(opts) {
|
||||
// wrap W's newState (defined in 20_state.js as local) — exposed via W.newGameState
|
||||
return W.newGameState(opts);
|
||||
}
|
||||
|
||||
APP.continueGame = function (slot) {
|
||||
const st = W.loadGame(slot);
|
||||
if (!st) { W.ui.toast('Could not load that save.', 'warn'); return; }
|
||||
APP.loadFromState(st);
|
||||
};
|
||||
APP.loadFromState = function (st) {
|
||||
W.state = st;
|
||||
st.assign = st.assign || {};
|
||||
st.pendingCombat = null; st.meeting = null;
|
||||
W.ui.hideAllScreens(); W.ui.showGame(); W.ui.clearModal();
|
||||
W.audio.sfx('paper');
|
||||
W.ui.toast('Welcome back. Day ' + st.day + '.');
|
||||
};
|
||||
|
||||
APP.quitToTitle = function () {
|
||||
W.state = null;
|
||||
W.ui.closePanel();
|
||||
W.ui.showTitle();
|
||||
};
|
||||
|
||||
APP.saveGame = function (slot) { if (W.state) { W.saveGame(slot, W.state); W.ui.toast('Saved to slot ' + slot + '.'); W.ui.refreshPanel(); } };
|
||||
APP.autosave = function () { if (W.state) W.saveGame('auto', W.state); };
|
||||
APP.loadGame = function (slot) { APP.continueGame(slot); };
|
||||
|
||||
APP.setSetting = function (k, v) {
|
||||
if (W.state) W.state.settings[k] = v;
|
||||
const s = W.settings(); s[k] = v;
|
||||
W.store.set('wuxia_settings_v1', s);
|
||||
if (k === 'music' || k === 'sfx') W.audio.setVol(s.music, s.sfx);
|
||||
W.ui.refreshPanel();
|
||||
};
|
||||
|
||||
/* ---------------- actions ---------------- */
|
||||
APP.doAction = function (id) {
|
||||
const st = W.state; if (!st || st.pendingEvent || st.combat) return;
|
||||
const r = W.sim.doAction(st, id);
|
||||
if (r.err) { W.ui.toast(r.err, 'warn'); return; }
|
||||
W.audio.sfx('brush');
|
||||
APP.handleActionResult(r);
|
||||
};
|
||||
APP.handleActionResult = function (r) {
|
||||
const st = W.state;
|
||||
if (r.event) { W.ui.refresh(); W.ui.showEvent(st.pendingEvent); return; }
|
||||
if (r.meeting) { W.ui.refresh(); W.ui.showMeeting(r.txt); return; }
|
||||
if (r.trade) { W.ui.tradeModal(); return; }
|
||||
if (r.combat) { APP.startCombatFlow(r.combat, r.txt); return; }
|
||||
if (r.txt) W.ui.toast(r.txt);
|
||||
W.ui.refresh();
|
||||
APP.checkTrivialEnd();
|
||||
};
|
||||
|
||||
/* ---------------- travel ---------------- */
|
||||
APP.travel = function (locId) {
|
||||
const st = W.state;
|
||||
const r = W.sim.startTravel(st, locId);
|
||||
if (r.err) return;
|
||||
W.ui.closePanel();
|
||||
W.ui.toast(`You set out for ${W.locById(locId).n} — about ${r.days} day${r.days > 1 ? 's' : ''} on the road.`);
|
||||
W.ui.refresh();
|
||||
};
|
||||
|
||||
/* ---------------- day cycle ---------------- */
|
||||
APP.endDay = function () {
|
||||
const st = W.state; if (!st || st.combat || st.pendingEvent) return;
|
||||
const logs = W.sim.endDay(st);
|
||||
W.ui.refresh();
|
||||
let sawMsg = false;
|
||||
for (const lg of logs) {
|
||||
if (lg.kind === 'msg') { W.ui.toast(lg.txt, lg.warn ? 'warn' : ''); sawMsg = true; }
|
||||
else if (lg.kind === 'arrived') { W.ui.toast('Arrived at ' + W.locById(lg.loc).n + '.'); W.audio.sfx('bell'); }
|
||||
else if (lg.kind === 'build') { W.ui.toast('Construction complete.'); W.audio.sfx('seal'); W.ach.check('builder', st); }
|
||||
else if (lg.kind === 'encounter') { APP.travelEncounter(); }
|
||||
else if (lg.kind === 'event') { APP.showPendingOrRoll(lg.id); }
|
||||
else if (lg.kind === 'war') { APP.warDeclaration(); }
|
||||
else if (lg.kind === 'raid_incoming') { APP.defenseWarning(lg.rival); }
|
||||
else if (lg.kind === 'final_warning') { APP.finalWarning(); }
|
||||
else if (lg.kind === 'end') { APP.finishRun(); return; }
|
||||
}
|
||||
APP.autosave();
|
||||
APP.checkCrossroads();
|
||||
APP.checkFinalBattle();
|
||||
APP.checkPlayerDeath();
|
||||
};
|
||||
|
||||
APP.travelEncounter = function () {
|
||||
const st = W.state;
|
||||
const ev = W.sim.rollEvent(st, 'travel');
|
||||
if (ev) { W.ui.showEvent(st.pendingEvent); return; }
|
||||
// fallback ambush
|
||||
const dest = W.locById(st.travel.to);
|
||||
const enemies = W.sim.encounterFor(st, Math.max(1, dest.danger));
|
||||
APP.startCombatFlow({ enemies, context: 'road' }, 'Shapes rise from the roadside rocks — an ambush.');
|
||||
};
|
||||
|
||||
APP.showPendingOrRoll = function (forcedId) {
|
||||
const st = W.state;
|
||||
if (forcedId) { const ev = W.sim.rollEvent(st, 'sect', forcedId); if (ev) { W.ui.showEvent(st.pendingEvent); return; } }
|
||||
if (st.pendingEvent) W.ui.showEvent(st.pendingEvent);
|
||||
};
|
||||
|
||||
/* ---------------- events ---------------- */
|
||||
APP.chooseEvent = function (i) {
|
||||
const st = W.state;
|
||||
const pe = st.pendingEvent;
|
||||
const fx = W.sim.chooseEvent(st, i);
|
||||
W.ui.refresh();
|
||||
const extras = [];
|
||||
if (fx.gold || fx.fame || fx.faction) {
|
||||
if (fx.gold) extras.push((fx.gold > 0 ? '+' : '') + fx.gold + ' gold');
|
||||
if (fx.fame) extras.push((fx.fame > 0 ? '+' : '') + fx.fame + ' reputation');
|
||||
if (fx.art) extras.push('Learned: ' + W.artById(fx.art).n);
|
||||
if (fx.item) extras.push('Obtained: ' + W.itemById(fx.item).n);
|
||||
}
|
||||
W.ui.showEventResult(fx && fx.txt ? fx.txt : 'The moment passes.', extras.join(' · '), () => {
|
||||
if (st.pendingCombat) APP.startCombatFlow(st.pendingCombat);
|
||||
else if (st.meeting && st.meeting.spar) APP.resolveMeeting('spar');
|
||||
else { W.ui.refresh(); APP.checkPlayerDeath(); }
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------------- meetings ---------------- */
|
||||
APP.resolveMeeting = function (choice) {
|
||||
const st = W.state;
|
||||
if (choice === 'leave') { st.meeting = null; W.ui.clearModal(); W.ui.refresh(); return; }
|
||||
const r = W.sim.resolveMeeting(st, choice);
|
||||
if (r.combat) {
|
||||
const spec = r.combat;
|
||||
if (spec.context === 'spar') { spec.enemies = []; spec.sparChar = st.chars[spec.sparVs]; delete spec.sparVs; }
|
||||
W.ui.clearModal();
|
||||
APP.startCombatFlow(spec, r.intro);
|
||||
return;
|
||||
}
|
||||
st.meeting = null;
|
||||
W.ui.showEventResult(r.txt || '…', '', () => { W.ui.refresh(); });
|
||||
};
|
||||
|
||||
/* ---------------- combat ---------------- */
|
||||
APP.startCombatFlow = function (spec, introTxt) {
|
||||
const st = W.state;
|
||||
st.meeting = null;
|
||||
spec._foes = W.sim.buildCombatEnemies(st, spec);
|
||||
if (introTxt) W.ui.showEventResult(introTxt, 'Steel clears leather.', () => APP.beginCombat(spec));
|
||||
else APP.beginCombat(spec);
|
||||
};
|
||||
APP.beginCombat = function (spec) {
|
||||
const st = W.state;
|
||||
W.ui.clearModal();
|
||||
W.combat.create(st, spec);
|
||||
W.ui.showCombat();
|
||||
W.audio.setMood('combat');
|
||||
W.audio.sfx('gong');
|
||||
APP.animatingCombat = false;
|
||||
APP.cbtSt = { mode: null, menu: null };
|
||||
setTimeout(() => APP.nextCombatTurn(), 700);
|
||||
};
|
||||
APP.nextCombatTurn = function () {
|
||||
const st = W.state; const cbt = st && st.combat;
|
||||
if (!cbt) return;
|
||||
if (cbt.over) { APP.finishCombat(); return; }
|
||||
const cur = W.combat.current(cbt);
|
||||
W.rend.highlights = null;
|
||||
W.rend.selUnit = cur && cur.side === 'ally' ? cur : null;
|
||||
APP.cbtSt.mode = null; APP.cbtSt.menu = null;
|
||||
W.ui.refreshCombat();
|
||||
if (!cur) { W.combat.advance(st, cbt); setTimeout(APP.nextCombatTurn, 60); return; }
|
||||
if (cur.side === 'enemy') {
|
||||
APP.animatingCombat = true;
|
||||
setTimeout(() => {
|
||||
if (!st.combat) return;
|
||||
W.combat.aiAct(st, cbt, cur);
|
||||
setTimeout(() => {
|
||||
APP.animatingCombat = false;
|
||||
if (!st.combat) return;
|
||||
if (st.combat.over) { APP.finishCombat(); return; }
|
||||
W.combat.advance(st, st.combat);
|
||||
APP.nextCombatTurn();
|
||||
}, 750);
|
||||
}, 450);
|
||||
} else {
|
||||
APP.animatingCombat = false;
|
||||
}
|
||||
};
|
||||
APP.playerActed = function () {
|
||||
const st = W.state; if (!st.combat) return;
|
||||
if (st.combat.over) { APP.finishCombat(); return; }
|
||||
W.combat.advance(st, st.combat);
|
||||
setTimeout(APP.nextCombatTurn, 350);
|
||||
};
|
||||
APP.finishCombat = function () {
|
||||
const st = W.state; const cbt = st.combat; if (!cbt || !cbt.result) return;
|
||||
const res = W.combat.finish(st, cbt);
|
||||
W.ui.hideCombat();
|
||||
W.rend.highlights = null;
|
||||
W.ui.showCombatResult(res);
|
||||
W.ui.refresh();
|
||||
};
|
||||
APP.afterCombat = function () {
|
||||
const st = W.state;
|
||||
W.ui.clearModal();
|
||||
if (st.ended && st.endingId) { APP.finishRun(true); return; }
|
||||
// special contexts resolution text
|
||||
W.ui.refresh();
|
||||
APP.checkPlayerDeath();
|
||||
APP.autosave();
|
||||
};
|
||||
|
||||
/* combat player commands */
|
||||
APP.cbtState = () => APP.cbtSt;
|
||||
APP.combatAnimating = () => APP.animatingCombat;
|
||||
APP.cbtMode = function (m) {
|
||||
const st = W.state; const cbt = st.combat; const cur = W.combat.current(cbt);
|
||||
APP.cbtSt.menu = null;
|
||||
APP.cbtSt.mode = APP.cbtSt.mode === m ? null : m;
|
||||
if (APP.cbtSt.mode === 'move') {
|
||||
W.rend.highlights = { move: W.combat.reachable(cbt, cur), targets: [] };
|
||||
} else if (APP.cbtSt.mode === 'attack') {
|
||||
W.rend.highlights = { move: [], targets: cbt.units.filter(u => u.side === 'enemy' && !u.dead && U.dist(cur.x, cur.y, u.x, u.y) <= 1) };
|
||||
if (!W.rend.highlights.targets.length) W.ui.toast('No adjacent enemy.');
|
||||
}
|
||||
W.ui.refreshCombat();
|
||||
};
|
||||
APP.cbtToggleTechMenu = function () {
|
||||
APP.cbtSt.mode = null; W.rend.highlights = null;
|
||||
APP.cbtSt.menu = APP.cbtSt.menu === 'tech' ? null : 'tech';
|
||||
W.ui.refreshCombat();
|
||||
};
|
||||
APP.cbtToggleItemMenu = function () {
|
||||
APP.cbtSt.mode = null; W.rend.highlights = null;
|
||||
APP.cbtSt.menu = APP.cbtSt.menu === 'item' ? null : 'item';
|
||||
W.ui.refreshCombat();
|
||||
};
|
||||
APP.cbtPickTech = function (artId) {
|
||||
const st = W.state; const cbt = st.combat; const cur = W.combat.current(cbt);
|
||||
const art = W.artById(artId);
|
||||
APP.cbtSt.menu = null;
|
||||
APP.cbtTechSel = artId;
|
||||
W.rend.highlights = { move: [], targets: W.combat.targetsFor(cbt, cur, art) };
|
||||
APP.cbtSt.mode = 'technique';
|
||||
W.ui.toast('Choose a target for ' + art.n + '.');
|
||||
W.ui.refreshCombat();
|
||||
};
|
||||
APP.cbtGuard = function () {
|
||||
const st = W.state;
|
||||
W.combat.guard(st, st.combat, W.combat.current(st.combat));
|
||||
APP.playerActed();
|
||||
};
|
||||
APP.cbtFlee = function () {
|
||||
const st = W.state;
|
||||
const ok = W.combat.flee(st, st.combat, W.combat.current(st.combat));
|
||||
if (ok) { APP.finishCombat(); }
|
||||
else APP.playerActed();
|
||||
};
|
||||
APP.cbtUseItem = function (itemId) {
|
||||
const st = W.state; const cbt = st.combat; const cur = W.combat.current(cbt);
|
||||
const it = W.itemById(itemId);
|
||||
APP.cbtSt.menu = null;
|
||||
if (it.heal && W.sim.useItemCount(st, itemId, 1)) {
|
||||
const amt = it.heal;
|
||||
cur.hp = Math.min(cur.maxHp, cur.hp + amt);
|
||||
W.BUS.emit('float', { unit: cur, txt: '+' + amt, kind: 'heal' });
|
||||
W.combat.log(st, `${cur.name} uses ${it.n}.`);
|
||||
} else if (it.inner && cur.ref && W.sim.useItemCount(st, itemId, 1)) {
|
||||
W.sim.gainInner(st, cur.ref, it.inner);
|
||||
W.combat.log(st, `${cur.name} swallows a ${it.n}. Inner force rises.`);
|
||||
} else if (it.cure && W.sim.useItemCount(st, itemId, 1)) {
|
||||
cur.statuses = cur.statuses.filter(s => !['poison', 'bleed'].includes(s.k));
|
||||
W.combat.log(st, `${cur.name} takes the antidote.`);
|
||||
} else if (it.morale && W.sim.useItemCount(st, itemId, 1)) {
|
||||
W.sim.addMorale(st, it.morale);
|
||||
W.combat.log(st, `${cur.name} passes the gourd around.`);
|
||||
} else { W.ui.toast('Cannot use that now.', 'warn'); return; }
|
||||
W.audio.sfx('heal');
|
||||
W.ui.refreshCombat();
|
||||
APP.playerActed();
|
||||
};
|
||||
|
||||
function handleCombatClick(sx, sy) {
|
||||
const st = W.state; if (!st || !st.combat || st.combat.over) return;
|
||||
if (APP.animatingCombat) return;
|
||||
const cur = W.combat.current(st.combat);
|
||||
if (!cur || cur.side !== 'ally') return;
|
||||
const unit = W.rend.pickUnitAt(st.combat, sx, sy);
|
||||
const mode = APP.cbtSt.mode;
|
||||
if (mode === 'move') {
|
||||
const t = W.rend.pickTileAt(st, sx, sy);
|
||||
if (t && W.combat.moveUnit(st, st.combat, cur, t.x, t.y)) {
|
||||
W.audio.sfx('whoosh');
|
||||
W.rend.highlights = null; APP.cbtSt.mode = null;
|
||||
APP.playerActed();
|
||||
} else W.ui.toast('Cannot move there.');
|
||||
} else if (mode === 'attack') {
|
||||
if (unit && unit.side === 'enemy' && U.dist(cur.x, cur.y, unit.x, unit.y) <= 1) {
|
||||
W.combat.attack(st, st.combat, cur, unit);
|
||||
W.rend.highlights = null; APP.cbtSt.mode = null;
|
||||
APP.playerActed();
|
||||
} else W.ui.toast('Pick an adjacent enemy.');
|
||||
} else if (mode === 'technique') {
|
||||
const art = W.artById(APP.cbtTechSel);
|
||||
const targets = W.combat.targetsFor(st.combat, cur, art);
|
||||
const tgt = unit && targets.includes(unit) ? unit : null;
|
||||
if (tgt || (art.cmb.kind !== 'heal' && art.cmb.kind !== 'buff')) {
|
||||
if (tgt) {
|
||||
W.combat.useTechnique(st, st.combat, cur, APP.cbtTechSel, tgt);
|
||||
W.rend.highlights = null; APP.cbtSt.mode = null;
|
||||
APP.playerActed();
|
||||
} else if (targets.length) W.ui.toast('Invalid target.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- sect ops ---------------- */
|
||||
APP.build = function (id) {
|
||||
const st = W.state;
|
||||
const b = W.buildingById(id);
|
||||
if (st.sect.queue.length) { W.ui.toast('The builders are busy.'); return; }
|
||||
for (const [k, v] of Object.entries(b.cost)) if (st.res[k] < v) { W.ui.toast('Not enough ' + W.C.RES_N[k] + '.', 'warn'); return; }
|
||||
for (const [k, v] of Object.entries(b.cost)) st.res[k] -= v;
|
||||
st.sect.queue.push({ id, left: b.days });
|
||||
W.audio.sfx('coin');
|
||||
W.ui.toast(b.n + ': construction begins.');
|
||||
W.ui.refresh(); W.ui.refreshPanel();
|
||||
};
|
||||
APP.assign = function (charId, kind) { const st = W.state; st.assign = st.assign || {}; st.assign[charId] = kind; W.ui.toast('Assignment updated.'); };
|
||||
APP.toggleParty = function (charId) {
|
||||
const st = W.state;
|
||||
if (st.party.includes(charId)) { if (st.party.length > 1) st.party = st.party.filter(i => i !== charId); }
|
||||
else if (st.party.length < 4) st.party.push(charId);
|
||||
else W.ui.toast('Only four may walk the roads together.', 'warn');
|
||||
W.ui.refresh(); W.ui.refreshPanel();
|
||||
};
|
||||
APP.equipArt = function (artId) {
|
||||
const st = W.state; const p = W.sim.player(st);
|
||||
if (p.equipped.length >= W.equipLimit()) { W.ui.toast('Four arts is the body\'s limit — unequip one first.', 'warn'); return; }
|
||||
p.equipped.push(artId);
|
||||
afterEquipChange(p);
|
||||
};
|
||||
APP.unequipArt = function (artId) {
|
||||
const st = W.state; const p = W.sim.player(st);
|
||||
p.equipped = p.equipped.filter(a => a !== artId);
|
||||
afterEquipChange(p);
|
||||
};
|
||||
APP.toggleEquipFor = function (charId, artId, on) {
|
||||
const st = W.state; const c = st.chars[charId];
|
||||
if (on) { if (c.equipped.length >= W.equipLimit()) { W.ui.toast('Slots full.', 'warn'); return; } c.equipped.push(artId); }
|
||||
else c.equipped = c.equipped.filter(a => a !== artId);
|
||||
afterEquipChange(c);
|
||||
W.ui.discipleModal(charId);
|
||||
};
|
||||
function afterEquipChange(c) {
|
||||
const st = W.state;
|
||||
W.simHelpers.refreshDerived(st, c);
|
||||
W.audio.sfx('seal');
|
||||
const combos = W.combosFor(c.equipped);
|
||||
if (combos.length >= 3) W.ach.unlock('combo_master');
|
||||
if (c.equipped.some(a => W.artById(a) && W.artById(a).cat === 'forbidden')) W.ach.unlock('demon_path');
|
||||
W.ui.refresh(); W.ui.refreshPanel();
|
||||
}
|
||||
APP.equipGear = function (charId, itemId) {
|
||||
const st = W.state; const c = st.chars[charId]; const it = W.itemById(itemId);
|
||||
const slot = it.type;
|
||||
if (c[slot]) W.sim.addItem(st, c[slot]);
|
||||
W.sim.useItemCount(st, itemId, 1);
|
||||
c[slot] = itemId;
|
||||
W.simHelpers.refreshDerived(st, c);
|
||||
W.audio.sfx('forge');
|
||||
W.ui.toast(c.name + ' equips ' + it.n + '.');
|
||||
W.ui.refresh(); W.ui.discipleModal(charId);
|
||||
};
|
||||
APP.sellItem = function (itemId) {
|
||||
const st = W.state;
|
||||
if (!W.sim.hasItem(st, itemId)) return;
|
||||
W.sim.useItemCount(st, itemId, 1);
|
||||
const price = W.sim.itemPrice(itemId);
|
||||
st.res.gold += price;
|
||||
W.audio.sfx('coin');
|
||||
W.ui.toast('Sold for ' + price + ' gold.');
|
||||
W.ui.tradeModalRefresh && W.ui.tradeModalRefresh();
|
||||
W.ui.refresh();
|
||||
W.ui.tradeModal();
|
||||
};
|
||||
APP.buyItem = function (itemId, price, raw) {
|
||||
const st = W.state;
|
||||
if (st.res.gold < price) { W.ui.toast('Not enough gold.', 'warn'); return; }
|
||||
st.res.gold -= price;
|
||||
if (raw) st.res.food += 10; else W.sim.addItem(st, itemId);
|
||||
W.audio.sfx('coin');
|
||||
W.ui.refresh();
|
||||
W.ui.tradeModal();
|
||||
};
|
||||
APP.talkTo = function (charId) {
|
||||
const st = W.state; const c = st.chars[charId];
|
||||
const lines = [];
|
||||
lines.push(c.relPlayer > 60 ? `"Where you go, I go, Sect Leader."` :
|
||||
c.relPlayer > 30 ? `"The mountain is quiet lately. Too quiet, or just enough?"` :
|
||||
`"I am here. That is what matters, is it not?"`);
|
||||
if (c.traits.includes('gluttonous')) lines.push('"Also — is it dinner time yet?"');
|
||||
if (c.traits.includes('scholarly')) lines.push('"I found a marginal note in the library manual. Someone disagreed with our founder."');
|
||||
if (c.memories.length) lines.push(`<span class="dim">Remembers: ${c.memories[c.memories.length - 1].txt}</span>`);
|
||||
W.adjustRelLocal && W.adjustRelLocal();
|
||||
W.sim.adjustRel(st, charId, 1);
|
||||
W.ui.notice(c.name, lines.join('<br>'));
|
||||
};
|
||||
APP.breakthrough = function (charId) {
|
||||
const st = W.state; const c = st.chars[charId];
|
||||
if (c.injuryDays > 0) { W.ui.notice('Not Yet', c.name + ' must heal before attempting a breakthrough.'); return; }
|
||||
if (c.inner < W.sim.innerNeeded(c)) { W.ui.notice('Inner Force Insufficient', `Needs ${W.sim.innerNeeded(c)} inner force (${Math.round(c.inner)} currently). Meditate, take qi pills, or fight.`); return; }
|
||||
const aided = W.sim.hasItem(st, 'breakthrough_pill');
|
||||
const r = W.sim.attemptBreakthrough(st, c, aided);
|
||||
if (aided && r.ok !== undefined) { /* pill consumed inside */ }
|
||||
W.audio.sfx(r.ok ? 'levelup' : 'hit');
|
||||
if (r.death) W.ui.refresh();
|
||||
W.ui.notice(r.ok ? 'BREAKTHROUGH · 破境' : 'The Gate Refuses', r.txt, () => { W.ui.refresh(); if (r.death) W.ui.discipleModalClose && null; W.ui.refreshPanel(); });
|
||||
};
|
||||
APP.usePill = function (charId) {
|
||||
const st = W.state; const c = st.chars[charId];
|
||||
if (W.sim.useItemCount(st, 'qi_pill', 1)) { W.sim.gainInner(st, c, 30); W.audio.sfx('heal'); W.ui.toast('+30 inner force.'); W.ui.discipleModal(charId); }
|
||||
};
|
||||
APP.applyMedicine = function (charId) {
|
||||
const st = W.state; const c = st.chars[charId];
|
||||
if (W.sim.useItemCount(st, 'medicine', 1)) {
|
||||
c.hp = Math.min(c.maxHp, c.hp + 35); c.injuryDays = Math.max(0, c.injuryDays - 1);
|
||||
W.audio.sfx('heal'); W.ui.toast('Medicine applied.'); W.ui.discipleModal(charId);
|
||||
}
|
||||
};
|
||||
APP.raid = function (rivalId) {
|
||||
const st = W.state;
|
||||
APP.startCombatFlow({ enemies: [], rivalId, context: 'raid' }, `You march on the ${W.sim.rivals(st).find(r => r.id === rivalId).n} under a grey dawn.`);
|
||||
};
|
||||
|
||||
/* ---------------- war & late game ---------------- */
|
||||
APP.warDeclaration = function () {
|
||||
const st = W.state;
|
||||
W.addChronicle('War sweeps the jianghu: Orthodox Alliance against the Demon Cult. Every sect must choose.', 'major');
|
||||
const node = W.ui.el('div', 'result-box');
|
||||
node.appendChild(W.ui.el('h2', 'result-title bad', 'THE JIANGHU ERUPTS'));
|
||||
node.appendChild(W.ui.el('div', 'cn result-cn-big', '大战'));
|
||||
node.appendChild(W.ui.el('div', 'result-txt', 'Orthodox banners and black lotus flags march to war. Messengers wait outside your gate for an answer.'));
|
||||
const row = W.ui.el('div', 'confirm-row');
|
||||
const mk = (label, cn, side) => W.ui.btn(label, cn, () => {
|
||||
st.war.side = side;
|
||||
if (side === 'orthodox') { W.sim.factionShift(st, { orthodox: 12, demon: -10 }); W.sim.addRep(st, { honor: 5 }); }
|
||||
else if (side === 'demon') { W.sim.factionShift(st, { demon: 12, orthodox: -12 }); W.sim.addRep(st, { fear: 8, honor: -5 }); }
|
||||
else W.sim.addRep(st, { deception: 3 });
|
||||
W.addChronicle(side === 'orthodox' ? 'Your sect joined the Orthodox Alliance.' : side === 'demon' ? 'Your sect pledged itself to the Demon Cult.' : 'Your sect declared neutrality in the war.', 'major');
|
||||
W.ui.clearModal(); W.ui.refresh();
|
||||
}, 'primary');
|
||||
row.appendChild(mk('Ride with the Orthodox Alliance', '助正道', 'orthodox'));
|
||||
row.appendChild(mk('Join the Demon Cult', '投魔教', 'demon'));
|
||||
row.appendChild(mk('Stay neutral — sharpen blades', '中立', null));
|
||||
node.appendChild(row);
|
||||
W.ui.modal(node, 'slim');
|
||||
W.audio.sfx('gong');
|
||||
};
|
||||
APP.defenseWarning = function (rivalId) {
|
||||
const st = W.state;
|
||||
const r = W.sim.rivals(st).find(x => x.id === rivalId); if (!r) return;
|
||||
W.ui.confirm(`${r.n} marches on your mountain!`, 'Meet them at the walls?', ok => {
|
||||
if (ok) {
|
||||
const def = W.sim.sectEffects(st).defense;
|
||||
const spec = W.sim.defenseBattle(st, rivalId);
|
||||
spec.powerMul = Math.max(0.7, 1 - def / 100);
|
||||
APP.startCombatFlow(spec);
|
||||
} else {
|
||||
const lost = Math.min(st.res.gold, 120);
|
||||
st.res.gold -= lost; st.res.food = Math.max(0, st.res.food - 15);
|
||||
W.sim.addMorale(st, -10); W.sim.addRep(st, { fear: -4 });
|
||||
W.addChronicle(`You sheltered while ${r.n} raided the granaries. They will be back.`, 'dark');
|
||||
W.ui.toast('They loot and burn, laughing. (-' + lost + ' gold, morale falls)');
|
||||
W.ui.refresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
APP.finalWarning = function () {
|
||||
W.ui.toast('⚠ Scouts report massing armies beyond the valley. The final battle comes within days.', 'warn');
|
||||
W.audio.sfx('gong');
|
||||
};
|
||||
APP.triggerFinalBattle = function () {
|
||||
const st = W.state;
|
||||
const fin = W.sim.finalInvasion(st);
|
||||
W.addChronicle(fin.enemySide === 'demon' ? 'The Demon Cult descends on your mountain in force.' : 'Imperial legions surround your mountain. The purge has come.', 'major');
|
||||
APP.startCombatFlow(fin.spec, fin.spec.intro);
|
||||
};
|
||||
APP.checkFinalBattle = function () {
|
||||
const st = W.state;
|
||||
if (!st.war.finalDone && st.day >= 92 && st.locId === 'home') APP.triggerFinalBattle();
|
||||
};
|
||||
|
||||
APP.checkCrossroads = function () {
|
||||
const st = W.state;
|
||||
if (st.day !== 95 || st.flags.crossroads_done) return;
|
||||
st.flags.crossroads_done = true;
|
||||
const node = W.ui.el('div', 'result-box');
|
||||
node.appendChild(W.ui.el('h2', null, 'THE ROAD AHEAD · 百日将尽'));
|
||||
node.appendChild(W.ui.el('div', 'result-txt', 'Five days remain. The war grinds on around your mountain. Whatever happens now will be what the jianghu remembers.'));
|
||||
const row = W.ui.el('div', 'confirm-row');
|
||||
if (st.war.side === 'orthodox') {
|
||||
row.appendChild(W.ui.btn('Betray the Alliance', '背盟', () => { st.flags.betrayed_alliance = true; st.stats.betrayals++; W.addChronicle('In the war\'s shadow, you sold the Alliance\'s battle-plans to the other side.', 'dark'); W.sim.addRep(st, { fear: 10, honor: -12 }); W.ui.clearModal(); }, 'warn'));
|
||||
}
|
||||
row.appendChild(W.ui.btn('Abandon the jianghu when it ends', '归隐', () => { st.flags.abandoned_jianghu = true; W.ui.clearModal(); }));
|
||||
row.appendChild(W.ui.btn('See it through', '坚持', () => W.ui.clearModal(), 'primary'));
|
||||
node.appendChild(row);
|
||||
W.ui.modal(node, 'slim');
|
||||
};
|
||||
|
||||
APP.checkPlayerDeath = function () {
|
||||
const st = W.state;
|
||||
const p = W.sim.player(st);
|
||||
if (p && !p.alive && !st.ended) { st.ended = true; st.endingId = W.sim.computeEnding(st); APP.finishRun(true); }
|
||||
};
|
||||
APP.checkTrivialEnd = function () { APP.checkFinalBattle(); };
|
||||
|
||||
/* ---------------- run end ---------------- */
|
||||
APP.finishRun = function (immediate) {
|
||||
const st = W.state;
|
||||
st.ended = true;
|
||||
if (!st.endingId) st.endingId = W.sim.computeEnding(st);
|
||||
W.ach.unlock('hundred_days');
|
||||
if (!st.stats.deaths) W.ach.unlock('perfect_run');
|
||||
if (['legend', 'newera'].includes(st.endingId)) W.ach.unlock('legend_end');
|
||||
W.addChronicle('The hundred days ended. The story passed into teahouse song.', 'major');
|
||||
APP.autosave();
|
||||
setTimeout(() => W.ui.showEnding(st.endingId), immediate ? 200 : 600);
|
||||
};
|
||||
|
||||
/* expose chronicle helper */
|
||||
W.addChronicle = (txt, kind) => { if (W.state) W.simHelpers.addChron(W.state, txt, kind); };
|
||||
})();
|
||||
|
||||
/* bridge: expose newState for app wrapper */
|
||||
(function () {
|
||||
// 20_state.js defines newState internally; re-create access via W.newGameState
|
||||
// implemented there by assignment below (see file).
|
||||
})();
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
/* =========================================================================
|
||||
WUXIA: 100 DAYS AFTER — stylesheet (ink painting / dark lacquer UI)
|
||||
========================================================================= */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--paper: #ece5d4;
|
||||
--paper2: #e2d8bf;
|
||||
--ink: #2c2822;
|
||||
--ink-soft: #5a5142;
|
||||
--lacquer: rgba(26, 22, 17, 0.94);
|
||||
--lacquer2: rgba(38, 32, 25, 0.96);
|
||||
--gold: #c9a44a;
|
||||
--seal: #a33327;
|
||||
--jade: #5d8a6f;
|
||||
--line: rgba(200, 185, 150, 0.22);
|
||||
}
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #17140f; }
|
||||
body {
|
||||
font-family: Georgia, "Times New Roman", "Songti SC", "SimSun", serif;
|
||||
color: var(--paper);
|
||||
-webkit-user-select: none; user-select: none;
|
||||
}
|
||||
.cn { font-family: "Kaiti SC", "STKaiti", "KaiTi", "Noto Serif CJK SC", "Noto Serif SC", serif; margin-left: 6px; opacity: 0.85; font-size: 0.92em; }
|
||||
.dim { opacity: 0.66; }
|
||||
.pad { padding: 8px 0; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
#app { position: fixed; inset: 0; }
|
||||
#cv { position: absolute; inset: 0; cursor: grab; }
|
||||
#cv:active { cursor: grabbing; }
|
||||
#ui { position: absolute; inset: 0; pointer-events: none; }
|
||||
#ui > * { pointer-events: none; }
|
||||
/* Only real, visible surfaces accept input. Full-screen wrapper layers
|
||||
(.hud / .combat-hud / .modal-layer / .toasts) stay transparent to clicks;
|
||||
their interactive children re-enable pointer-events individually. */
|
||||
#ui .screen, #ui .panel-layer { pointer-events: auto; }
|
||||
#ui .hud-top, #ui .hud-party, #ui .action-bar { pointer-events: auto; }
|
||||
|
||||
/* ================= buttons ================= */
|
||||
.btn {
|
||||
pointer-events: auto;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
background: linear-gradient(180deg, rgba(58,50,40,0.95), rgba(34,29,23,0.95));
|
||||
border: 1px solid var(--line);
|
||||
color: var(--paper);
|
||||
font-family: inherit; font-size: 14px;
|
||||
padding: 9px 18px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: transform 0.08s ease, box-shadow 0.15s ease, border-color 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.btn::before {
|
||||
content: ""; position: absolute; inset: 3px;
|
||||
border: 1px solid rgba(200,185,150,0.12); pointer-events: none;
|
||||
}
|
||||
.btn:hover { border-color: rgba(201,164,74,0.65); box-shadow: 0 0 0 1px rgba(201,164,74,0.25), 0 6px 18px rgba(0,0,0,0.45); transform: translateY(-1px); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary { background: linear-gradient(180deg, #7d2f24, #571e17); border-color: rgba(255,190,160,0.35); }
|
||||
.btn.warn { background: linear-gradient(180deg, #54401e, #3a2d14); border-color: rgba(220,180,90,0.35); }
|
||||
.btn.big { font-size: 17px; padding: 13px 30px; letter-spacing: 0.06em; }
|
||||
.btn.small { font-size: 12px; padding: 5px 10px; }
|
||||
.btn.disabled { opacity: 0.42; pointer-events: auto; cursor: not-allowed; filter: saturate(0.4); }
|
||||
.btn.navbtn { padding: 5px 11px; font-size: 12.5px; background: rgba(28,24,18,0.82); }
|
||||
.btn.act { min-width: 104px; flex-direction: column; gap: 1px; padding: 8px 10px; }
|
||||
.btn.act .cn { margin: 0; font-size: 13px; opacity: 0.75; }
|
||||
.btn.cbt.on { border-color: var(--gold); box-shadow: 0 0 10px rgba(201,164,74,0.4); }
|
||||
.btn.subopt { display: block; width: 100%; text-align: left; margin: 3px 0; padding: 7px 12px; }
|
||||
.closebtn { padding: 4px 12px; }
|
||||
|
||||
/* ================= title ================= */
|
||||
.screen { position: absolute; inset: 0; overflow-y: auto; z-index: 30; }
|
||||
.screen-title {
|
||||
background:
|
||||
radial-gradient(1200px 500px at 70% -10%, rgba(236,229,212,0.12), transparent),
|
||||
linear-gradient(180deg, #14110c 0%, #201b14 55%, #171310 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.title-box { text-align: center; position: relative; padding: 40px; max-width: 720px; }
|
||||
.title-cn {
|
||||
font-family: "Kaiti SC","STKaiti","KaiTi",serif;
|
||||
font-size: clamp(46px, 8vw, 84px);
|
||||
color: var(--paper);
|
||||
letter-spacing: 0.22em; text-indent: 0.22em;
|
||||
text-shadow: 0 0 40px rgba(236,229,212,0.25);
|
||||
animation: inkfade 2.2s ease both;
|
||||
}
|
||||
.title-en {
|
||||
font-size: clamp(19px, 3vw, 27px); letter-spacing: 0.34em; text-indent: 0.34em;
|
||||
color: var(--gold); font-weight: 400; margin-top: 6px;
|
||||
animation: inkfade 2.2s 0.3s ease both;
|
||||
}
|
||||
.title-en span { color: var(--seal); }
|
||||
.title-sub { margin-top: 14px; color: rgba(236,229,212,0.55); font-style: italic; letter-spacing: 0.08em; }
|
||||
.title-seal {
|
||||
width: 74px; height: 74px; margin: 26px auto 8px;
|
||||
background: var(--seal); color: #efe6d4;
|
||||
font-family: "Kaiti SC","STKaiti","KaiTi",serif; font-size: 44px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 9px; transform: rotate(-4deg);
|
||||
box-shadow: 0 4px 24px rgba(163,51,39,0.45);
|
||||
cursor: pointer; user-select: none;
|
||||
animation: stamp 0.5s 0.9s cubic-bezier(.2,2,.4,1) both;
|
||||
}
|
||||
@keyframes stamp { from { transform: scale(2.4) rotate(-14deg); opacity: 0; } to { transform: scale(1) rotate(-4deg); opacity: 1; } }
|
||||
@keyframes inkfade { from { opacity: 0; filter: blur(6px); } to { opacity: 1; filter: blur(0); } }
|
||||
.title-menu { margin-top: 26px; display: flex; flex-direction: column; gap: 12px; align-items: center; }
|
||||
.title-menu .btn { min-width: 300px; }
|
||||
.title-foot { margin-top: 30px; color: rgba(236,229,212,0.4); font-size: 13px; font-style: italic; }
|
||||
|
||||
/* ================= creation ================= */
|
||||
.screen-create {
|
||||
background: linear-gradient(180deg, #171310, #221c14);
|
||||
padding: 30px; overflow-y: auto;
|
||||
}
|
||||
.create-box { max-width: 1180px; margin: 0 auto; }
|
||||
.create-head { font-weight: 400; letter-spacing: 0.12em; color: var(--gold); margin-bottom: 18px; }
|
||||
.create-cols { display: grid; grid-template-columns: 1fr 380px; gap: 26px; }
|
||||
@media (max-width: 900px) { .create-cols { grid-template-columns: 1fr; } }
|
||||
.create-left h3, .create-right h3 { font-weight: 400; color: rgba(236,229,212,0.8); letter-spacing: 0.08em; margin: 6px 0 10px; font-size: 15px; border-bottom: 1px solid var(--line); padding-bottom: 6px; }
|
||||
.bg-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; max-height: 62vh; overflow-y: auto; padding-right: 6px; }
|
||||
.bg-card, .diff-card {
|
||||
background: var(--lacquer); border: 1px solid var(--line);
|
||||
padding: 12px 14px; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.bg-card:hover, .diff-card:hover { border-color: rgba(201,164,74,0.5); }
|
||||
.bg-card.sel, .diff-card.sel { border-color: var(--seal); box-shadow: inset 0 0 0 1px var(--seal), 0 0 18px rgba(163,51,39,0.25); background: linear-gradient(180deg, rgba(80,32,26,0.5), rgba(38,30,24,0.96)); }
|
||||
.bg-name { color: var(--paper); font-size: 15px; }
|
||||
.bg-desc { color: rgba(236,229,212,0.6); font-size: 12.5px; line-height: 1.45; margin-top: 6px; font-style: italic; }
|
||||
.bg-bonus { color: var(--gold); font-size: 11.5px; margin-top: 7px; }
|
||||
.diff-list { display: grid; gap: 8px; }
|
||||
label { color: rgba(236,229,212,0.75); font-size: 13px; display: block; margin: 10px 0 4px; }
|
||||
.inp {
|
||||
width: 100%; background: rgba(20,17,13,0.8); border: 1px solid var(--line);
|
||||
color: var(--paper); padding: 9px 12px; font-family: inherit; font-size: 14px;
|
||||
}
|
||||
.inp:focus { outline: none; border-color: var(--gold); }
|
||||
select.inp option { background: #241f18; }
|
||||
.create-actions { margin-top: 26px; display: flex; gap: 12px; }
|
||||
|
||||
/* ================= HUD ================= */
|
||||
.hud { position: absolute; inset: 0; z-index: 10; }
|
||||
.hud-top {
|
||||
position: absolute; top: 0; left: 0; right: 0;
|
||||
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
|
||||
padding: 10px 16px;
|
||||
background: linear-gradient(180deg, rgba(18,15,11,0.92), rgba(18,15,11,0.72) 75%, transparent);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.tb-day { display: flex; align-items: baseline; gap: 8px; white-space: nowrap; }
|
||||
.tb-day .big { font-size: 21px; color: var(--gold); letter-spacing: 0.04em; }
|
||||
.wx { font-size: 15px; } .ph { opacity: 0.7; font-size: 13px; } .season { font-family: "Kaiti SC","STKaiti",serif; color: rgba(201,164,74,0.8); }
|
||||
.tb-res { display: flex; gap: 14px; font-size: 13.5px; }
|
||||
.res i { font-style: normal; color: var(--seal); margin-right: 5px; font-family: "Kaiti SC","STKaiti",serif; }
|
||||
.res.r-gold i { color: var(--gold); }
|
||||
.tb-rep { display: flex; gap: 14px; font-size: 13px; align-items: baseline; }
|
||||
.rep-fame { color: #d8b56a; }
|
||||
.morale { color: rgba(236,229,212,0.75); }
|
||||
.ap { color: #8fb08f; font-weight: bold; }
|
||||
.tb-nav { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.hud-party { position: absolute; left: 14px; top: 86px; display: flex; flex-direction: column; gap: 8px; width: 172px; }
|
||||
.pb-card {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
background: var(--lacquer); border: 1px solid var(--line);
|
||||
padding: 6px; cursor: pointer; transition: border-color 0.15s;
|
||||
}
|
||||
.pb-card:hover { border-color: rgba(201,164,74,0.55); }
|
||||
.pb-card.hurt { border-color: rgba(178,52,38,0.7); }
|
||||
.portrait-img { display: block; border: 1px solid var(--line); image-rendering: auto; background: #ddd3ba; }
|
||||
.pb-info { flex: 1; min-width: 0; }
|
||||
.pb-name { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pb-name .cn { font-size: 11px; opacity: 0.6; }
|
||||
.mini { font-size: 11px; color: rgba(236,229,212,0.6); margin: 1px 0; }
|
||||
.inj { font-size: 10.5px; color: #cf7a5a; }
|
||||
|
||||
.bar { height: 5px; background: rgba(10,8,6,0.7); border: 1px solid rgba(255,255,255,0.06); margin-top: 3px; }
|
||||
.bar-fill { height: 100%; transition: width 0.3s ease; }
|
||||
.bar.hp .bar-fill { background: linear-gradient(90deg, #6a9a6c, #8fbf83); }
|
||||
.bar.qi .bar-fill { background: linear-gradient(90deg, #4a6a9a, #6a93c9); }
|
||||
.bar.loy .bar-fill { background: linear-gradient(90deg, #9a7a3a, #cfa84a); }
|
||||
.bar.rel .bar-fill { background: linear-gradient(90deg, #9a4a5a, #c97a8a); }
|
||||
|
||||
.action-bar {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
display: flex; gap: 8px; justify-content: center; align-items: stretch; flex-wrap: wrap;
|
||||
padding: 14px 16px 16px;
|
||||
background: linear-gradient(0deg, rgba(18,15,11,0.94), rgba(18,15,11,0.7) 70%, transparent);
|
||||
}
|
||||
.no-ap, .ended-note { color: rgba(236,229,212,0.7); font-style: italic; align-self: center; padding: 12px; }
|
||||
|
||||
/* ================= side panel ================= */
|
||||
.panel-layer { position: absolute; top: 0; right: 0; bottom: 0; width: min(680px, 94vw); z-index: 20; }
|
||||
.panel {
|
||||
height: 100%; display: flex; flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(24,20,15,0.97), rgba(30,25,19,0.97));
|
||||
border-left: 1px solid var(--line);
|
||||
box-shadow: -20px 0 60px rgba(0,0,0,0.5);
|
||||
animation: slidein 0.22s ease;
|
||||
}
|
||||
@keyframes slidein { from { transform: translateX(40px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
.panel-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.panel-title { font-size: 17px; letter-spacing: 0.1em; color: var(--gold); }
|
||||
.panel-body { flex: 1; overflow-y: auto; padding: 16px 18px 40px; font-size: 13.5px; }
|
||||
.panel-body h3 { font-weight: 400; color: rgba(236,229,212,0.85); letter-spacing: 0.08em; margin: 16px 0 8px; font-size: 14.5px; border-bottom: 1px dashed var(--line); padding-bottom: 5px; }
|
||||
.panel-body::-webkit-scrollbar, .bg-list::-webkit-scrollbar { width: 8px; }
|
||||
.panel-body::-webkit-scrollbar-thumb, .bg-list::-webkit-scrollbar-thumb { background: rgba(201,164,74,0.3); }
|
||||
|
||||
/* map */
|
||||
.jianghu-map { width: 100%; border: 1px solid var(--line); cursor: pointer; background: #e7dcc2; }
|
||||
.map-note { color: rgba(236,229,212,0.6); font-style: italic; margin-bottom: 10px; }
|
||||
.map-info { margin-top: 10px; padding: 10px; background: rgba(20,17,13,0.6); border: 1px solid var(--line); min-height: 64px; font-size: 13px; line-height: 1.5; }
|
||||
.rumors { margin-top: 14px; }
|
||||
.rumor { color: rgba(236,229,212,0.68); padding: 3px 0; font-style: italic; }
|
||||
|
||||
/* sect */
|
||||
.sect-name { font-size: 15px; margin-bottom: 4px; }
|
||||
.queue-note { color: var(--gold); padding: 6px 0; }
|
||||
.build-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; margin-top: 10px; }
|
||||
.build-card { background: var(--lacquer); border: 1px solid var(--line); padding: 12px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.build-card.built { border-color: rgba(93,138,111,0.5); }
|
||||
.bc-head { font-size: 14.5px; } .bc-head .cn { opacity: 0.6; }
|
||||
.lv { color: var(--gold); float: right; font-size: 12px; }
|
||||
.bc-desc { color: rgba(236,229,212,0.55); font-size: 12px; font-style: italic; line-height: 1.4; }
|
||||
.bc-eff { color: #9ab08a; font-size: 11.5px; }
|
||||
.bc-cost { color: rgba(201,164,74,0.8); font-size: 11.5px; }
|
||||
.req-warn { color: #c07a5a; font-size: 11px; }
|
||||
.assign-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.assign-row { display: flex; align-items: center; gap: 10px; background: rgba(20,17,13,0.5); border: 1px solid var(--line); padding: 6px 10px; }
|
||||
.assign-name { flex: 1; }
|
||||
.assign-row select { max-width: 210px; }
|
||||
|
||||
/* disciples */
|
||||
.disc-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.disc-row { display: flex; gap: 10px; align-items: center; background: var(--lacquer); border: 1px solid var(--line); padding: 8px 10px; cursor: pointer; transition: border-color 0.15s; }
|
||||
.disc-row:hover { border-color: rgba(201,164,74,0.5); }
|
||||
.disc-mid { flex: 1; min-width: 0; }
|
||||
.disc-name { font-size: 14px; }
|
||||
.disc-tags { display: flex; flex-direction: column; gap: 3px; max-width: 110px; }
|
||||
.tag { background: rgba(201,164,74,0.12); border: 1px solid rgba(201,164,74,0.3); color: #d8c48a; font-size: 10.5px; padding: 2px 7px; white-space: nowrap; width: fit-content; }
|
||||
.disc-side { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; }
|
||||
.rel-mini { font-size: 11px; color: #c98a8a; }
|
||||
|
||||
/* arts */
|
||||
.eq-row { display: flex; gap: 8px; }
|
||||
.eq-slot { flex: 1; height: 64px; border: 1px dashed var(--line); display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer; font-size: 12px; text-align: center; padding: 4px; }
|
||||
.eq-slot.filled { border-style: solid; background: rgba(60,50,36,0.5); }
|
||||
.eq-n { font-size: 12px; line-height: 1.2; }
|
||||
.eq-cn { font-size: 13px; opacity: 0.7; }
|
||||
.combo-card { border: 1px solid var(--line); border-left: 3px solid var(--seal); background: var(--lacquer); padding: 10px 14px; margin: 8px 0; }
|
||||
.combo-card.tier4, .combo-card.tier5 { border-left-color: var(--gold); box-shadow: 0 0 20px rgba(201,164,74,0.12) inset; }
|
||||
.combo-head { font-size: 15px; }
|
||||
.seal { display: inline-flex; width: 20px; height: 20px; background: var(--seal); color: #efe6d4; align-items: center; justify-content: center; font-family: "Kaiti SC","STKaiti",serif; font-size: 13px; border-radius: 3px; margin-right: 4px; vertical-align: middle; }
|
||||
.combo-desc { color: rgba(236,229,212,0.65); font-style: italic; font-size: 12.5px; margin: 5px 0; }
|
||||
.combo-bonus { color: #9ab08a; font-size: 12px; }
|
||||
.unstable { color: #c07a5a; display: block; margin-top: 3px; font-size: 11.5px; }
|
||||
.ult { color: var(--gold); margin-top: 3px; font-size: 12px; }
|
||||
.cat-label { color: rgba(201,164,74,0.85); letter-spacing: 0.1em; font-size: 12.5px; margin: 12px 0 6px; }
|
||||
.art-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 8px; }
|
||||
.art-card { background: var(--lacquer); border: 1px solid var(--line); padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.art-card.equipped { border-color: rgba(93,138,111,0.7); box-shadow: inset 0 0 0 1px rgba(93,138,111,0.4); }
|
||||
.art-card.tier5 { border-color: rgba(163,51,39,0.6); }
|
||||
.art-n { font-size: 13.5px; }
|
||||
.art-cn { font-size: 13px; opacity: 0.65; }
|
||||
.art-d { color: rgba(236,229,212,0.55); font-size: 11.5px; font-style: italic; line-height: 1.35; }
|
||||
.art-cmb { color: #8aa0b8; font-size: 11px; }
|
||||
.art-pas { color: #9ab08a; font-size: 11px; }
|
||||
.curse { color: #c05a4a; font-size: 11px; }
|
||||
.art-card .btn { align-self: flex-start; margin-top: 3px; }
|
||||
|
||||
/* factions */
|
||||
.war-note { color: #d88a5a; border: 1px solid rgba(216,138,90,0.4); padding: 8px 12px; margin-bottom: 10px; background: rgba(120,60,30,0.15); }
|
||||
.fac-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.fac-card { background: var(--lacquer); border: 1px solid var(--line); padding: 10px 14px; }
|
||||
.fac-card.hostile { border-left: 3px solid #b03a2a; }
|
||||
.fac-card.friendly { border-left: 3px solid var(--jade); }
|
||||
.fac-card.cold { border-left: 3px solid #8a6a5a; }
|
||||
.fac-head { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
.fac-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
.fac-mood { margin-left: auto; font-size: 12px; color: rgba(236,229,212,0.65); }
|
||||
.fac-ideo { color: rgba(201,164,74,0.75); font-size: 12px; font-style: italic; margin-top: 4px; }
|
||||
.fac-d { margin-top: 3px; font-size: 12px; line-height: 1.4; }
|
||||
.rival-row { display: flex; align-items: center; gap: 10px; background: var(--lacquer); border: 1px solid var(--line); padding: 8px 12px; margin: 6px 0; flex-wrap: wrap; }
|
||||
|
||||
/* journal */
|
||||
.chron-list { display: flex; flex-direction: column; gap: 4px; max-height: 46vh; overflow-y: auto; }
|
||||
.chron { padding: 4px 0; border-bottom: 1px dotted rgba(200,185,150,0.12); font-size: 13px; line-height: 1.45; }
|
||||
.chron-day { color: var(--gold); font-size: 11px; margin-right: 8px; }
|
||||
.kind-major { color: #e8dfc8; }
|
||||
.kind-dark { color: #c78a7a; }
|
||||
.kind-bright { color: #a8c898; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 6px; }
|
||||
.stat { display: flex; justify-content: space-between; background: rgba(20,17,13,0.5); border: 1px solid var(--line); padding: 6px 10px; font-size: 12.5px; }
|
||||
.stat b { color: var(--gold); }
|
||||
|
||||
/* system */
|
||||
.save-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.save-row { display: flex; align-items: center; gap: 8px; background: rgba(20,17,13,0.5); border: 1px solid var(--line); padding: 7px 10px; font-size: 13px; flex-wrap: wrap; }
|
||||
.save-row span { flex: 1; min-width: 130px; }
|
||||
.ei-box { border: 1px solid var(--line); padding: 10px; margin-top: 10px; }
|
||||
.ei-box summary { cursor: pointer; color: rgba(236,229,212,0.7); }
|
||||
.ei-ta { width: 100%; height: 70px; margin-top: 8px; font-size: 10px; word-break: break-all; resize: vertical; background: rgba(10,8,6,0.8); color: #cabf9f; border: 1px solid var(--line); padding: 6px; }
|
||||
.ei-row { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.set-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; font-size: 13.5px; }
|
||||
.set-row span { width: 170px; }
|
||||
.set-row input[type=range] { flex: 1; accent-color: var(--seal); }
|
||||
.ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
|
||||
.ach { border: 1px solid var(--line); padding: 8px 10px; opacity: 0.4; font-size: 12px; background: rgba(20,17,13,0.4); }
|
||||
.ach.got { opacity: 1; border-color: rgba(201,164,74,0.5); box-shadow: inset 0 0 14px rgba(201,164,74,0.08); }
|
||||
.ach b { color: var(--gold); }
|
||||
|
||||
/* ================= modals ================= */
|
||||
.modal-layer { position: absolute; inset: 0; z-index: 40; pointer-events: none; }
|
||||
.modal-back {
|
||||
position: absolute; inset: 0; background: rgba(10,8,6,0.55);
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px;
|
||||
pointer-events: auto; backdrop-filter: blur(2px); overflow-y: auto;
|
||||
}
|
||||
.modal {
|
||||
background: linear-gradient(180deg, #26201a, #1c1812);
|
||||
border: 1px solid rgba(201,164,74,0.3);
|
||||
box-shadow: 0 30px 90px rgba(0,0,0,0.7), inset 0 0 0 1px rgba(0,0,0,0.6), inset 0 0 60px rgba(0,0,0,0.35);
|
||||
max-width: 780px; width: 100%;
|
||||
position: relative;
|
||||
animation: modalin 0.25s cubic-bezier(.2,1.4,.4,1);
|
||||
max-height: calc(100vh - 40px); overflow-y: auto;
|
||||
}
|
||||
@keyframes modalin { from { transform: scale(0.94); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
.modal::before { content: ""; position: absolute; inset: 6px; border: 1px solid rgba(200,185,150,0.14); pointer-events: none; }
|
||||
.modal.slim { max-width: 520px; }
|
||||
.confirm-box, .result-box { padding: 26px 30px; }
|
||||
.result-box { text-align: center; }
|
||||
.confirm-box h3 { font-weight: 400; letter-spacing: 0.08em; margin-bottom: 8px; }
|
||||
.confirm-row { display: flex; gap: 10px; margin-top: 18px; flex-wrap: wrap; }
|
||||
.confirm-row.center { justify-content: center; }
|
||||
.result-title { font-weight: 400; letter-spacing: 0.28em; text-indent: 0.28em; font-size: 26px; color: var(--paper); }
|
||||
.result-title.good { color: #a8c898; }
|
||||
.result-title.bad { color: #c86a52; }
|
||||
.result-cn-big { font-family: "Kaiti SC","STKaiti",serif; font-size: 54px; color: rgba(163,51,39,0.85); margin: 6px 0 2px; }
|
||||
.result-txt { font-size: 15px; line-height: 1.75; color: rgba(236,229,212,0.9); margin: 10px 0; text-align: left; }
|
||||
.result-brush { font-family: "Kaiti SC","STKaiti",serif; color: rgba(200,185,150,0.3); font-size: 20px; letter-spacing: 0.4em; }
|
||||
.result-extra { margin: 8px 0; color: var(--gold); }
|
||||
.warn-text { color: #c8825a; margin-top: 8px; font-size: 13px; }
|
||||
|
||||
/* event modal */
|
||||
.event-box { display: flex; flex-direction: column; }
|
||||
.event-scene { height: 280px; overflow: hidden; position: relative; }
|
||||
.event-art { width: 100%; height: 100%; object-fit: cover; display: block; filter: sepia(0.12); }
|
||||
.event-scene::after { content: ""; position: absolute; inset: 0; box-shadow: inset 0 -60px 60px -20px #1c1812, inset 0 0 40px rgba(0,0,0,0.4); }
|
||||
.event-box.slim2 .event-scene { display: none; }
|
||||
.event-title {
|
||||
padding: 14px 26px 4px; font-size: 21px; letter-spacing: 0.1em; color: var(--paper);
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
}
|
||||
.event-title .cn { font-size: 17px; color: rgba(201,164,74,0.8); }
|
||||
.event-content { padding: 8px 26px 4px; display: flex; gap: 16px; }
|
||||
.event-speaker { flex-shrink: 0; text-align: center; }
|
||||
.speaker-name { font-size: 12px; color: rgba(201,164,74,0.9); margin-top: 5px; }
|
||||
.event-text { font-size: 14.5px; line-height: 1.8; color: rgba(236,229,212,0.92); }
|
||||
.event-text p { margin-bottom: 10px; }
|
||||
.choices { padding: 12px 26px 24px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.choice {
|
||||
text-align: left; background: rgba(40,33,26,0.85); border: 1px solid var(--line);
|
||||
color: var(--paper); font-family: inherit; font-size: 14px;
|
||||
padding: 11px 16px; cursor: pointer; transition: all 0.13s; display: block; width: 100%;
|
||||
}
|
||||
.choice:hover { border-color: var(--gold); background: rgba(70,56,38,0.85); transform: translateX(4px); }
|
||||
.choice.disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.choice.disabled:hover { transform: none; border-color: var(--line); background: rgba(40,33,26,0.85); }
|
||||
.choice-t { display: inline; }
|
||||
.choice-why { float: right; color: #c07a5a; font-size: 11.5px; font-style: italic; }
|
||||
|
||||
/* ================= combat HUD ================= */
|
||||
.combat-hud { position: absolute; inset: 0; z-index: 15; pointer-events: none; }
|
||||
.turn-strip { position: absolute; top: 76px; right: 16px; display: flex; gap: 5px; flex-wrap: wrap; max-width: 340px; justify-content: flex-end; }
|
||||
.tu { background: rgba(24,20,15,0.9); border: 1px solid var(--line); font-size: 11px; padding: 4px 9px; opacity: 0.55; border-radius: 2px; }
|
||||
.tu.active { opacity: 1; border-color: var(--gold); box-shadow: 0 0 12px rgba(201,164,74,0.35); }
|
||||
.tu.side-enemy { border-left: 3px solid #b03a2a; }
|
||||
.tu.side-ally { border-left: 3px solid #5d8a6f; }
|
||||
.round-tag { position: absolute; top: 106px; right: 16px; font-size: 11.5px; color: rgba(236,229,212,0.6); letter-spacing: 0.14em; }
|
||||
.cbt-log {
|
||||
position: absolute; left: 16px; bottom: 108px; width: min(430px, 44vw);
|
||||
background: linear-gradient(0deg, rgba(18,15,11,0.85), transparent);
|
||||
font-size: 12.5px; line-height: 1.55; color: rgba(236,229,212,0.85);
|
||||
padding: 12px; max-height: 160px; overflow: hidden;
|
||||
}
|
||||
.cbt-actions {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
display: flex; gap: 8px; justify-content: center; padding: 14px;
|
||||
background: linear-gradient(0deg, rgba(18,15,11,0.95), transparent);
|
||||
pointer-events: auto; flex-wrap: wrap;
|
||||
}
|
||||
.enemy-turn { font-style: italic; color: #d8a87a; padding: 12px; animation: pulse 1.2s infinite; }
|
||||
@keyframes pulse { 50% { opacity: 0.5; } }
|
||||
.cbt-sub {
|
||||
position: absolute; bottom: 76px; left: 50%; transform: translateX(-50%);
|
||||
width: min(480px, 92vw); background: rgba(24,20,15,0.97); border: 1px solid var(--line);
|
||||
padding: 10px; max-height: 260px; overflow-y: auto; pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ================= trade ================= */
|
||||
.trade-box { padding: 22px 26px; }
|
||||
.trade-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 10px; }
|
||||
@media (max-width: 700px) { .trade-cols { grid-template-columns: 1fr; } }
|
||||
.trade-col { max-height: 46vh; overflow-y: auto; }
|
||||
.trade-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px dotted rgba(200,185,150,0.14); padding: 7px 0; font-size: 13px; }
|
||||
.trade-row div:first-child { flex: 1; }
|
||||
|
||||
/* ================= disciple detail ================= */
|
||||
.disc-detail { padding: 24px 28px; }
|
||||
.dd-head { display: flex; gap: 18px; }
|
||||
.dd-headtxt { flex: 1; }
|
||||
.dd-headtxt h2 { font-weight: 400; letter-spacing: 0.04em; }
|
||||
.dd-bars { margin-top: 10px; font-size: 12px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.dd-bars > div { display: grid; grid-template-columns: 130px 1fr; align-items: center; gap: 10px; }
|
||||
.dd-stats { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 14px; }
|
||||
.stat-pill { background: rgba(201,164,74,0.1); border: 1px solid rgba(201,164,74,0.3); padding: 4px 10px; font-size: 12px; }
|
||||
.stat-pill b { color: var(--gold); margin-left: 4px; }
|
||||
.dd-skills { margin-top: 10px; }
|
||||
.skillpill { display: inline-block; background: rgba(93,138,111,0.14); border: 1px solid rgba(93,138,111,0.35); color: #9ac0a2; font-size: 11px; padding: 2px 8px; margin: 2px; }
|
||||
.dd-eq { margin-top: 10px; }
|
||||
.dd-gearrow { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
|
||||
.mem-list { display: flex; flex-direction: column; gap: 3px; }
|
||||
.mem { font-size: 12.5px; color: rgba(236,229,212,0.75); }
|
||||
|
||||
/* ================= toasts ================= */
|
||||
.toasts { position: absolute; top: 84px; left: 50%; transform: translateX(-50%); z-index: 50; display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; width: min(620px, 90vw); }
|
||||
.toast {
|
||||
background: rgba(24,20,15,0.95); border: 1px solid var(--line); border-left: 3px solid var(--gold);
|
||||
color: rgba(236,229,212,0.92); font-size: 13.5px; padding: 10px 18px; max-width: 100%;
|
||||
opacity: 0; transform: translateY(-8px); transition: all 0.35s ease; line-height: 1.5;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: none; }
|
||||
.toast.warn { border-left-color: #c07a5a; color: #e0b49a; }
|
||||
.toast.ach { border-left-color: var(--seal); color: #e8dfc8; }
|
||||
|
||||
/* ================= ending ================= */
|
||||
.ending-screen {
|
||||
position: fixed; inset: 0; z-index: 60; overflow-y: auto;
|
||||
background:
|
||||
radial-gradient(1000px 500px at 50% -10%, rgba(236,229,212,0.09), transparent),
|
||||
linear-gradient(180deg, #14100b, #241d13 60%, #171310);
|
||||
display: flex; justify-content: center;
|
||||
}
|
||||
.ending-inner { max-width: 760px; padding: 60px 30px 80px; text-align: center; }
|
||||
.ending-kicker { letter-spacing: 0.34em; color: rgba(201,164,74,0.7); font-size: 12px; }
|
||||
.ending-title { font-weight: 400; font-size: clamp(26px, 5vw, 40px); letter-spacing: 0.12em; color: var(--paper); margin: 18px 0 4px; }
|
||||
.ending-cn { font-family: "Kaiti SC","STKaiti",serif; font-size: 24px; color: var(--seal); margin-bottom: 24px; }
|
||||
.ending-scroll {
|
||||
text-align: left; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
|
||||
padding: 16px 8px; margin: 10px 0 20px; max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
.ending-desc { font-size: 15.5px; line-height: 1.85; color: rgba(236,229,212,0.85); font-style: italic; margin-bottom: 22px; }
|
||||
.ending-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 6px; margin-bottom: 22px; }
|
||||
.ending-final { font-size: 15px; letter-spacing: 0.14em; color: rgba(201,164,74,0.9); margin-bottom: 26px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.hud-party { display: none; }
|
||||
.tb-res { gap: 8px; font-size: 12px; }
|
||||
.event-content { flex-direction: column; }
|
||||
.panel-layer { width: 100vw; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
/* Full real-browser flow test: title -> create -> play days via REAL clicks
|
||||
(true hit-testing), capturing console/404s/pageerrors throughout. */
|
||||
const { chromium } = require('/app/node_modules/.pnpm/playwright@1.61.1/node_modules/playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome', args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const logs = [];
|
||||
page.on('console', m => { if (m.type() === 'error' || m.type() === 'warning') logs.push(`[${m.type()}] ${m.text()}`); });
|
||||
page.on('pageerror', e => logs.push(`[PAGEERROR] ${e.message}`));
|
||||
page.on('requestfailed', r => logs.push(`[REQFAIL] ${r.url()}`));
|
||||
page.on('response', r => { if (r.status() >= 400) logs.push(`[HTTP ${r.status()}] ${r.url()}`); });
|
||||
|
||||
await page.goto(process.argv[2] || 'http://127.0.0.1:8914/wuxia.html', { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const step = async (name, fn) => {
|
||||
try { await fn(); console.log('✓', name); }
|
||||
catch (e) { console.log('✗', name, '—', e.message.split('\n')[0]); }
|
||||
await page.waitForTimeout(150);
|
||||
};
|
||||
|
||||
// title -> create
|
||||
await step('click New Journey', () => page.click('.title-menu .btn:has-text("New Journey")'));
|
||||
await step('pick background', () => page.click('.bg-card:has-text("Old Soldier")'));
|
||||
await step('fill names', async () => {
|
||||
await page.fill('.inp-name', 'Cloud Tester');
|
||||
await page.fill('.inp-sect', 'Sky Ridge Sect');
|
||||
});
|
||||
await step('pick difficulty', () => page.click('.diff-list .diff-card:has-text("Wanderer")'));
|
||||
await step('Set Out', () => page.click('.create-actions .btn:has-text("Set Out")'));
|
||||
|
||||
// dismiss intro notice
|
||||
await step('dismiss intro', async () => {
|
||||
const b = page.locator('.modal .btn', { hasText: 'Continue' }).first();
|
||||
if (await b.count()) await b.click(); else throw new Error('no intro modal');
|
||||
});
|
||||
|
||||
// canvas should be clickable during play (drag pan)
|
||||
await step('canvas drag-pan works', async () => {
|
||||
const cv = page.locator('#cv');
|
||||
const box = await cv.boundingBox();
|
||||
await page.mouse.move(box.x + 600, box.y + 400);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 500, box.y + 330, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
// do actions via the action bar buttons
|
||||
for (const act of ['Train', 'Meditate']) {
|
||||
await step(`action bar: ${act}`, () => page.click(`.action-bar .btn:has-text("${act}")`));
|
||||
}
|
||||
|
||||
// open each panel by nav button and close
|
||||
for (const p of ["Map", "Sect", "Disciples", "Arts", "Factions", "Journal", "☰"]) {
|
||||
await step(`panel ${p}`, async () => {
|
||||
await page.click(`#navbtns .btn:has-text("${p}")`);
|
||||
await page.waitForSelector('.panel-layer:not(.hidden)', { timeout: 2000 });
|
||||
await page.click('.panel-head .closebtn');
|
||||
});
|
||||
}
|
||||
|
||||
// end several days through the real button
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await step(`end day ${i + 1}`, async () => {
|
||||
// resolve any modal (buttons OR event choices) first
|
||||
for (let k = 0; k < 5; k++) {
|
||||
const anyBtn = page.locator('.modal .btn:visible, .modal .choice:visible:not(.disabled)').first();
|
||||
if (await anyBtn.count()) { await anyBtn.click().catch(() => {}); await page.waitForTimeout(150); } else break;
|
||||
}
|
||||
await page.click('.action-bar .btn:has-text("End Day")');
|
||||
await page.waitForTimeout(350);
|
||||
for (let k = 0; k < 5; k++) {
|
||||
const cont = page.locator('.modal .btn:visible, .modal .choice:visible:not(.disabled)').first();
|
||||
if (await cont.count()) { await cont.click().catch(() => {}); await page.waitForTimeout(150); } else break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const state = await page.evaluate(() => window.W && window.W.state ? { day: window.W.state.day, ap: window.W.state.ap } : null);
|
||||
console.log('state after clicks:', JSON.stringify(state));
|
||||
await page.screenshot({ path: '/tmp/gameplay_after_fix.png' });
|
||||
|
||||
console.log('\nErrors seen:');
|
||||
const interesting = logs.filter(l => !l.includes('favicon.ico'));
|
||||
interesting.length ? interesting.forEach(l => console.log(' ', l)) : console.log(' (none besides favicon)');
|
||||
await browser.close();
|
||||
})().catch(e => { console.error('FLOW ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/* Real-browser repro: loads the game in headless Chromium, captures console
|
||||
errors, and performs TRUE hit-tested clicks on the title menu buttons. */
|
||||
const { chromium } = require('/app/node_modules/.pnpm/playwright@1.61.1/node_modules/playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome', args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const logs = [];
|
||||
page.on('console', m => logs.push(`[console.${m.type()}] ${m.text()}`));
|
||||
page.on('pageerror', e => logs.push(`[PAGEERROR] ${e.message}`));
|
||||
|
||||
await page.goto(process.argv[2] || 'http://127.0.0.1:8914/wuxia.html', { waitUntil: 'load' });
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// What's at the center of the New Journey button?
|
||||
const probe = await page.evaluate(() => {
|
||||
const btns = [...document.querySelectorAll('.title-menu .btn')];
|
||||
return btns.map(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2, cy = r.y + r.height / 2;
|
||||
const top = document.elementFromPoint(cx, cy);
|
||||
return {
|
||||
label: b.textContent.trim().slice(0, 24),
|
||||
rect: `${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.width)}x${Math.round(r.height)}`,
|
||||
topElement: top ? (top.tagName + '.' + String(top.className).split(' ').join('.')) : 'NULL (offscreen?)',
|
||||
topIsButtonOrChild: top ? b.contains(top) || top === b : false,
|
||||
};
|
||||
});
|
||||
});
|
||||
console.log('Title buttons hit-test:');
|
||||
for (const p of probe) console.log(' •', JSON.stringify(p));
|
||||
|
||||
// True click attempt on New Journey
|
||||
const nj = page.locator('.title-menu .btn', { hasText: 'New Journey' }).first();
|
||||
try {
|
||||
await nj.click({ timeout: 3000 });
|
||||
console.log('CLICK: dispatched');
|
||||
} catch (e) {
|
||||
console.log('CLICK FAILED:', e.message.split('\n')[0]);
|
||||
}
|
||||
await page.waitForTimeout(400);
|
||||
const createVisible = await page.evaluate(() => !document.querySelector('.screen-create').classList.contains('hidden'));
|
||||
console.log('creation screen visible after click:', createVisible);
|
||||
|
||||
// Screenshot for visual confirmation
|
||||
await page.screenshot({ path: '/tmp/title_after_click.png' });
|
||||
|
||||
console.log('\nConsole/page errors:');
|
||||
logs.length ? logs.forEach(l => console.log(' ', l)) : console.log(' (none)');
|
||||
await browser.close();
|
||||
})().catch(e => { console.error('REPRO ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
/* Headless simulation test: loads logic modules, runs full 100-day games
|
||||
with randomized decisions to catch crashes and dead ends. */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = '/root/Wuxia/src';
|
||||
const LOGIC = ['00_boot.js', '05_audio.js', '10_data_arts.js', '12_data_world.js', '14_data_events.js', '20_state.js', '22_sim.js', '24_combat.js'];
|
||||
|
||||
global.window = global;
|
||||
for (const f of LOGIC) {
|
||||
eval(fs.readFileSync(path.join(SRC, f), 'utf8'));
|
||||
}
|
||||
|
||||
function pickChoice(st) {
|
||||
const pe = st.pendingEvent;
|
||||
const okIdx = pe.choices.map((c, i) => c.ok ? i : -1).filter(i => i >= 0);
|
||||
return okIdx.length ? W.pick(okIdx) : null;
|
||||
}
|
||||
|
||||
function autoCombat(st) {
|
||||
let guard = 0;
|
||||
while (st.combat && !st.combat.over && guard++ < 200) {
|
||||
const cbt = st.combat;
|
||||
const cur = W.combat.current(cbt);
|
||||
if (!cur || cur.dead) { W.combat.advance(st, cbt); continue; }
|
||||
if (cur.side === 'ally') {
|
||||
const foes = cbt.units.filter(u => u.side === 'enemy' && !u.dead);
|
||||
if (!foes.length) break;
|
||||
let tgt = foes.sort((a, b) => W.U.dist(cur.x, cur.y, a.x, a.y) - W.U.dist(cur.x, cur.y, b.x, b.y))[0];
|
||||
const techs = cur.arts.map(id => W.artById(id)).filter(a => a && a.cmb && !(cur.cds[a.id] > 0) && cur.qi >= a.cmb.qi);
|
||||
const inRange = techs.filter(a => W.combat.targetsFor(cbt, cur, a).length);
|
||||
if (inRange.length && W.chance(0.5)) {
|
||||
W.combat.useTechnique(st, cbt, cur, inRange[0].id);
|
||||
} else if (W.U.dist(cur.x, cur.y, tgt.x, tgt.y) <= 1) {
|
||||
W.combat.attack(st, cbt, cur, tgt);
|
||||
} else {
|
||||
const reach = W.combat.reachable(cbt, cur);
|
||||
let best = null, bd = 1e9;
|
||||
for (const c of reach) { const d = W.U.dist(c.x, c.y, tgt.x, tgt.y); if (d < bd) { bd = d; best = c; } }
|
||||
if (best) W.combat.moveUnit(st, cbt, cur, best.x, best.y);
|
||||
else if (W.U.dist(cur.x, cur.y, tgt.x, tgt.y) <= 2) { /* approach more next round */ }
|
||||
else W.combat.guard(st, cbt, cur);
|
||||
}
|
||||
W.combat.advance(st, cbt);
|
||||
} else {
|
||||
W.combat.aiAct(st, cbt, cur);
|
||||
W.combat.advance(st, cbt);
|
||||
}
|
||||
}
|
||||
if (st.combat && st.combat.result) {
|
||||
W.combat.finish(st, st.combat);
|
||||
if (!W.sim.player(st).alive) return false;
|
||||
} else if (st.combat) { st.combat = null; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function runCombatSpec(st, spec) {
|
||||
spec._foes = W.sim.buildCombatEnemies(st, spec);
|
||||
// simulate competent play: party enters fights in good shape
|
||||
for (const c of W.sim.party(st)) { if (c.alive) c.hp = Math.max(c.hp, Math.round(c.maxHp * 0.95)); }
|
||||
st._lastCtx = spec.context;
|
||||
W.combat.create(st, spec);
|
||||
// estimate: retreat if badly outmatched (like a careful player would)
|
||||
let foePow = 0, allyPow = 0;
|
||||
for (const u of st.combat.units) {
|
||||
if (u.side === 'enemy') foePow += (u.hp / 40) * (u.atk / 8);
|
||||
else allyPow += (u.hp / 40) * (u.atk / 8) * (u.side === 'ally' ? 1 : 0);
|
||||
}
|
||||
const outmatched = allyPow < foePow * 0.62;
|
||||
let guard = 0;
|
||||
while (st.combat && !st.combat.over && guard++ < 260) {
|
||||
const cbt = st.combat;
|
||||
const cur = W.combat.current(cbt);
|
||||
if (!cur || cur.dead) { W.combat.advance(st, cbt); continue; }
|
||||
if (outmatched && cur.side === 'ally' && guard % 3 === 1) { if (W.combat.flee(st, cbt, cur)) break; W.combat.advance(st, cbt); continue; }
|
||||
if (cur.side === 'ally') {
|
||||
const foes = cbt.units.filter(u => u.side === 'enemy' && !u.dead);
|
||||
if (!foes.length) break;
|
||||
// heal self/allies via techniques if available and hurt
|
||||
const hurtFriend = cbt.units.filter(x => x.side === 'ally' && !x.dead && x.hp < x.maxHp * 0.55)[0];
|
||||
let acted = false;
|
||||
for (const aid of cur.arts) {
|
||||
const a = W.artById(aid);
|
||||
if (a && a.cmb && !(cur.cds[aid] > 0) && cur.qi >= a.cmb.qi && a.cmb.kind === 'heal' && hurtFriend) {
|
||||
W.combat.useTechnique(st, cbt, cur, aid, hurtFriend); acted = true; break;
|
||||
}
|
||||
}
|
||||
if (!acted) {
|
||||
let tgt = foes.sort((a, b) => (a.hp / a.maxHp + W.U.dist(cur.x, cur.y, a.x, a.y) * 0.05) - (b.hp / b.maxHp + W.U.dist(cur.x, cur.y, b.x, b.y) * 0.05))[0];
|
||||
const techs = cur.arts.map(id => W.artById(id)).filter(a => a && a.cmb && !(cur.cds[a.id] > 0) && cur.qi >= a.cmb.qi && a.cmb.kind !== 'heal');
|
||||
const usable = techs.filter(a => W.combat.targetsFor(cbt, cur, a).length);
|
||||
if (usable.length && W.chance(0.6)) {
|
||||
W.combat.useTechnique(st, cbt, cur, usable[0].id);
|
||||
} else if (W.U.dist(cur.x, cur.y, tgt.x, tgt.y) <= 1) {
|
||||
W.combat.attack(st, cbt, cur, tgt);
|
||||
} else {
|
||||
const reach = W.combat.reachable(cbt, cur);
|
||||
let best = null, bd = 1e9;
|
||||
for (const c of reach) { const d = W.U.dist(c.x, c.y, tgt.x, tgt.y); if (d < bd) { bd = d; best = c; } }
|
||||
if (best) W.combat.moveUnit(st, cbt, cur, best.x, best.y);
|
||||
else W.combat.guard(st, cbt, cur);
|
||||
}
|
||||
}
|
||||
W.combat.advance(st, cbt);
|
||||
} else {
|
||||
W.combat.aiAct(st, cbt, cur);
|
||||
W.combat.advance(st, cbt);
|
||||
}
|
||||
}
|
||||
if (st.combat && st.combat.result) {
|
||||
W.combat.finish(st, st.combat);
|
||||
if (!W.sim.player(st).alive) return false;
|
||||
} else if (st.combat) { st.combat = null; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolvePendingEvents(st) {
|
||||
let g = 0;
|
||||
while (st.pendingEvent && g++ < 6) {
|
||||
const ci = pickChoice(st);
|
||||
if (ci == null) { st.pendingEvent = null; st._pendingDef = null; break; }
|
||||
W.sim.chooseEvent(st, ci);
|
||||
if (st.pendingCombat) {
|
||||
const spec = st.pendingCombat; st.pendingCombat = null;
|
||||
spec.winReward = spec.winReward || null;
|
||||
if (!runCombatSpec(st, spec)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function runGame(seed, opts, verbose) {
|
||||
W.seedRng(seed);
|
||||
const st = W.newGameState(Object.assign({ background: W.pick(W.BACKGROUNDS).id, playerName: 'Test', sectName: 'Test Sect', difficulty: 'jianghu' }, opts));
|
||||
st.assign = {};
|
||||
let daysPlayed = 0;
|
||||
while (!st.ended && daysPlayed < 130) {
|
||||
// competent play: backfill travel party from healthy disciples at home
|
||||
const liveParty = st.party.filter(id => st.chars[id] && st.chars[id].alive);
|
||||
if (liveParty.length < 4) {
|
||||
for (const c of W.sim.disciples(st)) {
|
||||
if (liveParty.length >= 4) break;
|
||||
if (!liveParty.includes(c.id)) { liveParty.push(c.id); }
|
||||
}
|
||||
st.party = liveParty.slice(0, 4);
|
||||
}
|
||||
let acts = 0;
|
||||
while (st.ap > 0 && acts++ < 8) {
|
||||
const options = [];
|
||||
if (st.locId !== 'home') {
|
||||
options.push('explore', 'explore', 'explore', 'gather', 'rest', 'rest');
|
||||
if (['town', 'city', 'temple', 'camp'].includes(W.locById(st.locId).type)) options.push('recruit', 'trade', 'spy');
|
||||
if (['wild', 'village'].includes(W.locById(st.locId).type)) options.push('hunt');
|
||||
} else {
|
||||
options.push('train', 'meditate', 'rest');
|
||||
}
|
||||
const r = W.sim.doAction(st, W.pick(options));
|
||||
if (r.event && st.pendingEvent) { if (!resolvePendingEvents(st)) return { seed, day: st.day, end: 'death-event-combat' }; }
|
||||
if (r.meeting && st.meeting) {
|
||||
const choice = W.pick(['gift', 'persuade', 'spar', 'leave', 'duel', 'request']);
|
||||
const rr = W.sim.resolveMeeting(st, choice);
|
||||
if (rr.combat) {
|
||||
rr.combat.sparChar = rr.combat.sparVs ? st.chars[rr.combat.sparVs] : undefined;
|
||||
delete rr.combat.sparVs;
|
||||
if (!runCombatSpec(st, rr.combat)) return { seed, day: st.day, end: 'death-meeting' };
|
||||
}
|
||||
}
|
||||
if (r.combat) {
|
||||
if (!runCombatSpec(st, r.combat)) return { seed, day: st.day, end: 'player-death', ctx: r.combat.context };
|
||||
}
|
||||
}
|
||||
// travel sometimes
|
||||
if (!st.travel && W.chance(0.4)) {
|
||||
const dests = Object.keys(st.world.locs).filter(id => st.world.locs[id].discovered && id !== st.locId);
|
||||
if (dests.length) W.sim.startTravel(st, W.pick(dests));
|
||||
}
|
||||
const logs = W.sim.endDay(st);
|
||||
daysPlayed++;
|
||||
for (const lg of logs) {
|
||||
if (lg.kind === 'encounter') {
|
||||
const ev = W.sim.rollEvent(st, 'travel');
|
||||
if (ev) { if (!resolvePendingEvents(st)) return { seed, end: 'death-travel-event' }; }
|
||||
else {
|
||||
const dest = W.locById(st.travel ? st.travel.to : st.locId);
|
||||
const spec = { enemies: W.sim.encounterFor(st, Math.max(1, dest.danger)), context: 'road' };
|
||||
if (!runCombatSpec(st, spec)) return { seed, end: 'death-road' };
|
||||
}
|
||||
} else if (lg.kind === 'event' || lg.kind === 'war') {
|
||||
if (!resolvePendingEvents(st)) return { seed, end: 'death-day-event' };
|
||||
} else if (lg.kind === 'raid_incoming') {
|
||||
const spec = W.sim.defenseBattle(st, lg.rival);
|
||||
if (!runCombatSpec(st, spec)) return { seed, end: 'defense-loss' };
|
||||
} else if (lg.kind === 'end') break;
|
||||
}
|
||||
if (st.day >= 92 && !st.war.finalDone) {
|
||||
const fin = W.sim.finalInvasion(st);
|
||||
if (!runCombatSpec(st, fin.spec)) return { seed, day: st.day, end: 'final-loss' };
|
||||
}
|
||||
if (!W.sim.player(st).alive) return { seed, day: st.day, end: 'martyr' };
|
||||
}
|
||||
const ending = st.endingId || W.sim.computeEnding(st);
|
||||
if (verbose) console.log(`seed ${seed}: ended day ${Math.min(st.day, 100)} — ${ending} | fame ${Math.round(st.rep.fame)} | arts ${W.sim.knownArtCount(st)} | disciples ${W.sim.roster(st).length} | kills ${st.stats.kills} | combos seen`);
|
||||
return { seed, day: Math.min(st.day, 100), ending };
|
||||
}
|
||||
|
||||
// ---- run batch ----
|
||||
const N = parseInt(process.argv[2] || '30', 10);
|
||||
let fails = 0, endings = {};
|
||||
for (let i = 0; i < N; i++) {
|
||||
try {
|
||||
const r = runGame(1000 + i * 7919, {}, i < 8);
|
||||
endings[r.end || r.ending] = (endings[r.end || r.ending] || 0) + 1; if(r.end) console.log(' died:', JSON.stringify(r));
|
||||
} catch (e) {
|
||||
fails++;
|
||||
console.error('\n=== CRASH seed', 1000 + i * 7919, '===');
|
||||
console.error(e.stack.split('\n').slice(0, 8).join('\n'));
|
||||
if (fails > 4) break;
|
||||
}
|
||||
}
|
||||
console.log(`\nSimulated ${N} runs. Crashes: ${fails}. Outcomes:`, endings);
|
||||
process.exit(fails ? 1 : 0);
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env node
|
||||
/* UI smoke test: boots wuxia.html in jsdom with a canvas 2D stub,
|
||||
then drives the real UI through creation -> days -> panels -> combat. */
|
||||
const { JSDOM } = require('/app/node_modules/.pnpm/jsdom@29.1.1/node_modules/jsdom');
|
||||
const fs = require('fs');
|
||||
|
||||
const html = fs.readFileSync('/root/Wuxia/wuxia.html', 'utf8');
|
||||
|
||||
function makeCtxStub() {
|
||||
const gradient = { addColorStop() { } };
|
||||
const handler = {
|
||||
get(target, prop) {
|
||||
if (prop === 'createLinearGradient' || prop === 'createRadialGradient' || prop === 'createPattern') return () => gradient;
|
||||
if (prop === 'measureText') return () => ({ width: 10 });
|
||||
if (prop === 'getImageData') return (x, y, w, h) => ({ data: new Uint8ClampedArray(w * h * 4) });
|
||||
if (typeof prop === 'string') {
|
||||
if (!(prop in target)) target[prop] = (...args) => undefined;
|
||||
return target[prop];
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
set(target, prop, v) { target[prop] = v; return true; },
|
||||
};
|
||||
return new Proxy({}, handler);
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const origErr = console.error;
|
||||
console.error = (...a) => { errors.push(a.map(x => (x && x.stack) ? x.stack.split('\n')[0] : String(x)).join(' ')); };
|
||||
const dom = new JSDOM(html.replace('<script>', '<script>window.__JSDOM__=1;'), {
|
||||
runScripts: 'dangerously',
|
||||
resources: 'usable',
|
||||
url: 'http://localhost/',
|
||||
pretendToBeVisual: true,
|
||||
beforeParse(window) {
|
||||
window.HTMLCanvasElement.prototype.getContext = function () { return makeCtxStub(); };
|
||||
window.HTMLCanvasElement.prototype.toDataURL = function () { return 'data:image/png;base64,x'; };
|
||||
window.addEventListener('error', e => errors.push('window error: ' + e.message));
|
||||
},
|
||||
});
|
||||
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
|
||||
function fail(msg) { console.error('✗ FAIL:', msg); process.exitCode = 1; }
|
||||
function ok(msg) { console.log('✓', msg); }
|
||||
function clickByText(sel, text) {
|
||||
const els = [...doc.querySelectorAll(sel)];
|
||||
const t = els.find(e => e.textContent.includes(text));
|
||||
if (!t) { fail(`no ${sel} containing "${text}"`); return null; }
|
||||
t.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
|
||||
return t;
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
(async () => {
|
||||
await sleep(300);
|
||||
try {
|
||||
if (!window.W) { fail('W namespace missing'); process.exit(1); }
|
||||
ok('game booted, version ' + window.W.VERSION);
|
||||
if (!doc.querySelector('.title-box')) fail('title screen missing');
|
||||
else ok('title screen rendered');
|
||||
|
||||
// new journey -> creation
|
||||
clickByText('.btn', 'New Journey');
|
||||
await sleep(50);
|
||||
if (!doc.querySelector('.bg-card')) fail('creation backgrounds missing'); else ok('creation screen rendered (' + doc.querySelectorAll('.bg-card').length + ' backgrounds)');
|
||||
// pick a background & difficulty
|
||||
clickByText('.bg-card', 'Old Soldier');
|
||||
doc.querySelector('.inp-name').value = 'Chen Feng';
|
||||
doc.querySelector('.inp-sect').value = 'Iron Lotus Reborn';
|
||||
clickByText('.diff-list .diff-card', 'Jianghu');
|
||||
|
||||
// set out
|
||||
clickByText('.create-actions .btn', 'Set Out');
|
||||
await sleep(100);
|
||||
if (!doc.querySelector('.hud-top')) fail('HUD missing after start'); else ok('game HUD rendered');
|
||||
if (!window.W.state) fail('state missing');
|
||||
else ok('new game: day ' + window.W.state.day + ', party of ' + window.W.state.party.length);
|
||||
|
||||
// dismiss intro notice
|
||||
const contBtn = [...doc.querySelectorAll('.modal .btn')].find(b => b.textContent.includes('Continue'));
|
||||
if (contBtn) { contBtn.click(); await sleep(30); }
|
||||
|
||||
// panels
|
||||
for (const name of ['Map', 'Sect', 'Disciples', 'Arts', 'Factions', 'Journal']) {
|
||||
clickByText('#navbtns .btn', name);
|
||||
await sleep(20);
|
||||
if (doc.querySelector('.panel-layer.hidden')) fail('panel ' + name + ' did not open');
|
||||
}
|
||||
ok('all side panels open/close');
|
||||
// equip arts panel specifics
|
||||
clickByText('#navbtns .btn', 'Arts');
|
||||
await sleep(20);
|
||||
const eqSlots = doc.querySelectorAll('.eq-slot').length;
|
||||
if (eqSlots !== 4) fail('expected 4 equip slots, got ' + eqSlots); else ok('arts panel shows 4 equip slots');
|
||||
window.W.ui.closePanel();
|
||||
|
||||
// perform some actions
|
||||
const app = window.W.app;
|
||||
for (let i = 0; i < 3; i++) { app.doAction('train'); }
|
||||
ok('training actions executed, AP left: ' + window.W.state.ap);
|
||||
app.doAction('meditate');
|
||||
|
||||
// end day xN, resolving events/combats automatically
|
||||
let resolvedCombats = 0;
|
||||
for (let day = 0; day < 12; day++) {
|
||||
// resolve any pending event modal
|
||||
for (let k = 0; k < 6 && doc.querySelector('.event-box'); k++) {
|
||||
const choices = [...doc.querySelectorAll('.choice')].filter(c => !c.classList.contains('disabled'));
|
||||
const pick = choices.length ? choices[Math.floor(Math.random() * choices.length)] : null;
|
||||
if (pick) pick.click();
|
||||
await sleep(30);
|
||||
const cont = [...doc.querySelectorAll('.modal .btn')].find(b => b.textContent.includes('Continue') || b.textContent.includes('Yes'));
|
||||
if (cont && !cont.closest('.confirm-box')) { cont.click(); await sleep(20); }
|
||||
}
|
||||
// resolve confirm boxes (travel prompts etc.)
|
||||
const yes = [...doc.querySelectorAll('.confirm-box .btn')].find(b => b.textContent.includes('Yes'));
|
||||
if (yes) { yes.click(); await sleep(20); }
|
||||
// resolve meetings
|
||||
if (doc.querySelector('.choices') && window.W.state.meeting) {
|
||||
const btns = [...doc.querySelectorAll('.choices .btn:not(.disabled)')];
|
||||
if (btns.length) { btns[btns.length - 1].click(); await sleep(20); }
|
||||
}
|
||||
// resolve combats quickly via engine
|
||||
if (window.W.state.combat) {
|
||||
const st = window.W.state;
|
||||
let g = 0;
|
||||
while (st.combat && !st.combat.over && g++ < 120) {
|
||||
const cur = window.W.combat.current(st.combat);
|
||||
if (!cur || cur.dead) { window.W.combat.advance(st, st.combat); continue; }
|
||||
if (cur.side === 'ally') {
|
||||
const foes = st.combat.units.filter(u => u.side === 'enemy' && !u.dead);
|
||||
if (!foes.length) break;
|
||||
const tgt = foes.sort((a, b) => window.W.U.dist(cur.x, cur.y, a.x, a.y) - window.W.U.dist(cur.x, cur.y, b.x, b.y))[0];
|
||||
if (window.W.U.dist(cur.x, cur.y, tgt.x, tgt.y) <= 1) window.W.combat.attack(st, st.combat, cur, tgt);
|
||||
else {
|
||||
const reach = window.W.combat.reachable(st.combat, cur);
|
||||
let best = null, bd = 1e9;
|
||||
for (const c of reach) { const d = window.W.U.dist(c.x, c.y, tgt.x, tgt.y); if (d < bd) { bd = d; best = c; } }
|
||||
if (best) window.W.combat.moveUnit(st, st.combat, cur, best.x, best.y);
|
||||
else window.W.combat.guard(st, st.combat, cur);
|
||||
}
|
||||
window.W.combat.advance(st, st.combat);
|
||||
} else { window.W.combat.aiAct(st, st.combat, cur); window.W.combat.advance(st, st.combat); }
|
||||
}
|
||||
if (st.combat && st.combat.over) {
|
||||
resolvedCombats++;
|
||||
// wait for finishCombat timer
|
||||
await sleep(900);
|
||||
const res = [...doc.querySelectorAll('.modal .btn')].find(b => b.textContent.includes('Continue'));
|
||||
if (res) { res.click(); await sleep(40); }
|
||||
const res2 = [...doc.querySelectorAll('.modal .btn')].find(b => b.textContent.includes('Continue'));
|
||||
if (res2) { res2.click(); await sleep(40); }
|
||||
}
|
||||
}
|
||||
if (window.W.state.ended) break;
|
||||
app.endDay();
|
||||
await sleep(60);
|
||||
// close event results
|
||||
const conts = [...doc.querySelectorAll('.modal .btn')].filter(b => b.textContent.includes('Continue'));
|
||||
if (conts.length) { conts[0].click(); await sleep(20); }
|
||||
}
|
||||
ok('survived 12 days in UI, day now ' + window.W.state.day + ', combats fought: ' + resolvedCombats + ', gold: ' + Math.round(window.W.state.res.gold));
|
||||
|
||||
// travel via map panel
|
||||
window.W.ui.openPanel('map');
|
||||
await sleep(30);
|
||||
const mapCv = doc.querySelector('.jianghu-map');
|
||||
if (!mapCv) fail('jianghu map canvas missing'); else ok('jianghu scroll map rendered');
|
||||
window.W.ui.closePanel();
|
||||
|
||||
// save / reload roundtrip through UI storage
|
||||
app.saveGame('1');
|
||||
const before = window.W.state.day;
|
||||
app.loadGame('1');
|
||||
await sleep(50);
|
||||
if (window.W.state.day !== before) fail('save/load day mismatch'); else ok('UI save/load roundtrip OK (day ' + before + ')');
|
||||
|
||||
// portraits & scene art generation (canvas stubs exercised)
|
||||
const p = window.W.sim.player(window.W.state);
|
||||
const pcv = window.W.portrait(p, 'angry');
|
||||
if (!pcv) fail('portrait generation failed'); else ok('procedural portrait generated');
|
||||
const scv = window.W.sceneArt('bridge_rain', 400, 200);
|
||||
if (!scv) fail('scene art failed'); else ok('event scene art generated');
|
||||
|
||||
// ---- force an event modal through the real pipeline ----
|
||||
const st2 = window.W.state;
|
||||
window.W.sim.rollEvent(st2, 'sect');
|
||||
if (st2.pendingEvent) {
|
||||
window.W.ui.showEvent(st2.pendingEvent);
|
||||
await sleep(30);
|
||||
if (!doc.querySelector('.event-box')) fail('event modal did not render');
|
||||
else {
|
||||
const ch = [...doc.querySelectorAll('.choice')].filter(c => !c.classList.contains('disabled'));
|
||||
if (ch.length) { ch[0].click(); await sleep(40); }
|
||||
const conts = [...doc.querySelectorAll('.modal .btn')];
|
||||
if (conts.length) { conts[conts.length - 1].click(); await sleep(20); }
|
||||
ok('event modal shown & choice resolved');
|
||||
}
|
||||
} else ok('no sect event rolled this day (ok)');
|
||||
|
||||
// ---- force a combat through the app flow and click through it ----
|
||||
const spec = { enemies: window.W.sim.encounterFor(st2, 1), context: 'road' };
|
||||
st2.pendingCombat = null;
|
||||
// beginCombat directly (intro modal skipped)
|
||||
st2.meeting = null;
|
||||
spec._foes = window.W.sim.buildCombatEnemies(st2, spec);
|
||||
for (const c of window.W.sim.party(st2)) if (c.alive) c.hp = c.maxHp;
|
||||
window.W.combat.create(st2, spec);
|
||||
window.W.ui.showCombat();
|
||||
await sleep(100);
|
||||
if (!doc.querySelector('.combat-hud')) fail('combat HUD missing'); else ok('combat HUD rendered');
|
||||
let guard = 0;
|
||||
while (st2.combat && !st2.combat.over && guard++ < 60) {
|
||||
const cur = window.W.combat.current(st2.combat);
|
||||
if (!cur || cur.dead) { window.W.combat.advance(st2, st2.combat); continue; }
|
||||
if (cur.side === 'ally') {
|
||||
// use the real UI command functions
|
||||
window.W.app.cbtMode('attack');
|
||||
const foes = st2.combat.units.filter(u => u.side === 'enemy' && !u.dead && window.W.U.dist(cur.x, cur.y, u.x, u.y) <= 1);
|
||||
if (foes.length) window.W.combat.attack(st2, st2.combat, cur, foes[0]);
|
||||
else {
|
||||
const reach = window.W.combat.reachable(st2.combat, cur);
|
||||
const tgt = st2.combat.units.filter(u => u.side === 'enemy' && !u.dead)[0];
|
||||
let best = null, bd = 1e9;
|
||||
for (const c of reach) { const d = window.W.U.dist(c.x, c.y, tgt.x, tgt.y); if (d < bd) { bd = d; best = c; } }
|
||||
if (best) window.W.combat.moveUnit(st2, st2.combat, cur, best.x, best.y);
|
||||
else window.W.app.cbtGuard();
|
||||
}
|
||||
window.W.combat.advance(st2, st2.combat);
|
||||
} else { window.W.combat.aiAct(st2, st2.combat, cur); window.W.combat.advance(st2, st2.combat); }
|
||||
}
|
||||
await sleep(900);
|
||||
if (st2.combat && st2.combat.over) {
|
||||
// drive the app's own finish path
|
||||
window.W.app.finishCombat();
|
||||
await sleep(60);
|
||||
}
|
||||
if (doc.querySelector('.result-box')) {
|
||||
ok('combat finished & result screen rendered (' + (st2.combat ? st2.combat.result.outcome : '?') + ')');
|
||||
const contBtns = [...doc.querySelectorAll('.modal .btn')].filter(b => b.textContent.includes('Continue'));
|
||||
if (contBtns.length) contBtns[0].click();
|
||||
await sleep(40);
|
||||
} else fail('combat result screen missing');
|
||||
|
||||
// ---- ending screen ----
|
||||
st2.day = 100;
|
||||
window.W.app.finishRun(true);
|
||||
await sleep(400);
|
||||
if (!doc.querySelector('.ending-screen')) fail('ending screen missing');
|
||||
else {
|
||||
ok('ending screen rendered: ' + (doc.querySelector('.ending-title') || {}).textContent);
|
||||
const again = [...doc.querySelectorAll('.ending-screen .btn')].find(b => b.textContent.includes('Title'));
|
||||
if (again) { again.click(); await sleep(60); }
|
||||
if (!doc.querySelector('.title-box')) fail('return to title failed'); else ok('return to title works');
|
||||
}
|
||||
|
||||
if (errors.length) { fail('page/console errors: ' + errors.slice(0, 5).join(' | ')); }
|
||||
else ok('no page errors');
|
||||
console.log('\nUI SMOKE TEST COMPLETE');
|
||||
process.exit(process.exitCode || 0);
|
||||
} catch (e) {
|
||||
console.error('✗ EXCEPTION:', e.stack.split('\n').slice(0, 6).join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
/* Build: bundle src/*.js + style.css into a single self-contained wuxia.html */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SRC = path.join(__dirname, '..', 'src');
|
||||
const OUT = path.join(__dirname, '..', 'wuxia.html');
|
||||
|
||||
const files = fs.readdirSync(SRC).filter(f => f.endsWith('.js')).sort();
|
||||
let js = [];
|
||||
for (const f of files) {
|
||||
const code = fs.readFileSync(path.join(SRC, f), 'utf8');
|
||||
js.push(`/* ==== ${f} ==== */\n` + code);
|
||||
}
|
||||
const css = fs.readFileSync(path.join(SRC, 'style.css'), 'utf8');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Wuxia: 100 Days After — 武林百日后</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='10' fill='%23a33327'/%3E%3Ctext x='32' y='46' font-size='38' font-family='Kaiti SC,KaiTi,serif' text-anchor='middle' fill='%23efe6d4'%3E百%3C/text%3E%3C/svg%3E">
|
||||
<style>
|
||||
${css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<canvas id="cv"></canvas>
|
||||
<div id="ui"></div>
|
||||
</div>
|
||||
<script>
|
||||
${js.join('\n\n')}
|
||||
</script>
|
||||
<script>window.addEventListener('load', function(){ try { W.app.boot(); } catch(e){ console.error(e); document.body.insertAdjacentHTML('beforeend', '<pre style="color:#c86a52;padding:20px;white-space:pre-wrap">'+e.stack+'</pre>'); } });</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync(OUT, html);
|
||||
console.log('Built', OUT, (html.length / 1024).toFixed(0) + ' KB', '| modules:', files.join(', '));
|
||||
+6808
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user