Initial commit — Jianghu Chronicles: Road of the Wandering Blade

Original open-world wuxia browser RPG:
- Graphical 48px tile world (12 locations) with walkable character,
  NPC interaction, action spots, roaming enemies, travel portals
- Tactical 10x7 grid battles: shapes, statuses, internals, 30+ techniques
  incl. tier-V ultimates & support arts across 6 weapon types
- Full equipment: weapon/head/body/feet/2 accessories, 5 tiers, smithing
- 6-chapter main story + side jobs board + bounty hunts + tournament
- Crafting (smith/alchemy), fishing minigame, gambling, pickpocketing
- Companions with chemistry passives, affection, romance, sects
- Achievements, monster codex, day/night cycle, endings
- 25-test headless smoke suite; no-cache static server included
This commit is contained in:
2026-08-23 07:01:08 +00:00
commit 8680f79102
14 changed files with 6244 additions and 0 deletions
+313
View File
@@ -0,0 +1,313 @@
'use strict';
/* ============================================================
ENGINE — utilities, state, derived stats, time, log, sfx, saves
============================================================ */
const Util = {
_uid: 0,
ri(a, b) { return Math.floor(Math.random() * (b - a + 1)) + a; },
rf() { return Math.random(); },
chance(p) { return Math.random() < p; },
pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; },
shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; },
clamp(v, a, b) { return Math.max(a, Math.min(b, v)); },
uid() { return 'u' + (++Util._uid); },
esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); },
money(n) { return n + ' silver'; },
dist(ax, ay, bx, by) { return Math.max(Math.abs(ax - bx), Math.abs(ay - by)); }, // Chebyshev
manhattan(ax, ay, bx, by) { return Math.abs(ax - bx) + Math.abs(ay - by); },
cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; },
plural(n, s) { return n + ' ' + s + (n === 1 ? '' : 's'); }
};
const ATTRS = [
['str', 'Strength 臂力', 'Melee damage'],
['con', 'Constitution 体质', 'Health & defense'],
['agi', 'Agility 身法', 'Speed & evasion'],
['wit', 'Wit 悟性', 'Learning & inner energy'],
['luk', 'Fortune 福缘', 'Critical hits & lucky finds'],
['cha', 'Charm 魅力', 'Persuasion & prices']
];
/* ---------------- global mutable state ---------------- */
const G = {
player: null,
loc: null,
discovered: {},
flags: {}, // story flags & counters
aff: {}, // npcId -> affinity 0..100
rep: { fame: 0, infamy: 0, azure: 0, temple: 0, serpent: 0, fist: 0, garrison: 0, underground: 0 },
quests: {}, // qid -> {stage:int, done:bool}
party: [], // companion npcIds (max 2 fight alongside)
allies: [], // recruited npcIds (roster beyond party)
time: { day: 1, hour: 8 },
log: [],
daily: null,
bounty: { list: [], taken: null },
tourney: { streak: 0, lastDay: -99 },
settings: { sfx: true, aiSpeed: 380 },
battleMeta: null,
partner: null,
inBattle: false
};
function defaultDaily() {
return {
dummyPts: 0, sparred: {}, giftsGiven: {}, gathered: {}, mined: false,
pickpocket: false, meditated: 0, bountyDone: false, gambled: 0,
fished: 0
};
}
/* ---------------- player factory ---------------- */
function makePlayer(opts) {
const origin = DATA.ORIGINS[opts.origin] || DATA.ORIGINS.farm;
const attrs = { str: 4, con: 4, agi: 4, wit: 4, luk: 4, cha: 4 };
for (const k of Object.keys(attrs)) attrs[k] += (origin.attrs[k] || 0);
for (const k of Object.keys(opts.attrs || {})) {
if (!(k in attrs)) continue;
attrs[k] += opts.attrs[k];
attrs[k] = Math.min(attrs[k], 9); // creation cap
}
const p = {
name: opts.name || 'Nameless',
gender: opts.gender || 'm',
origin: opts.origin || 'farm',
attrs, lvl: 1, exp: 0, silver: origin.silver,
hp: 1, mp: 1, // set by recalc below
equip: { weapon: null, head: null, body: null, feet: null, acc1: null, acc2: null },
inv: [],
techs: {}, // techId -> proficiency 0..100
internal: origin.internal || 'i_water',
internalLv: 1, internalExp: 0,
knownInternals: ['i_water'],
light: 'l_basic',
knownLights: ['l_basic'],
permBonuses: { hp: 0, mp: 0 } // permanent elixir gains
};
for (const id of (origin.items || [])) Game_addItemRaw(p, id);
for (const t of (origin.techs || [])) p.techs[t] = 10;
if (!Object.keys(p.techs).length) p.techs['t_univ_1'] = 10;
Stats.recalc(p);
p.hp = p.d.maxHp; p.mp = p.d.maxMp;
return p;
}
// raw inventory add used before Game exists
function Game_addItemRaw(p, id, q) {
q = q || 1;
const stackable = !['weapon', 'body', 'head', 'feet'].includes((DATA.ITEMS[id] || {}).type);
const ex = p.inv.find(s => s.id === id && stackable);
if (ex) ex.q += q; else p.inv.push({ id, q });
}
/* ---------------- derived stats ---------------- */
const Stats = {
expNext(lvl) { return Math.round(60 * Math.pow(lvl, 1.35)); },
recalc(p) {
// effective attributes = base + accessory bonuses
const eff = { str: p.attrs.str, con: p.attrs.con, agi: p.attrs.agi, wit: p.attrs.wit, luk: p.attrs.luk, cha: p.attrs.cha };
let atk = 5 + eff.str * 3 + (p.lvl - 1) * 2;
let def = eff.con * 2;
let hpFlat = 80 + eff.con * 12 + (p.lvl - 1) * 10;
let mpFlat = 30 + eff.wit * 6 + (p.lvl - 1) * 5;
let spd = 8 + eff.agi * 2;
let crit = 3 + eff.luk * 1;
let dodge = Math.round(eff.agi * 1.5);
// equipment
let rng = 1, wt = 'fist';
for (const slot of Object.keys(p.equip)) {
const id = p.equip[slot]; if (!id) continue;
const it = DATA.ITEMS[id]; if (!it) continue;
atk += it.atk || 0; def += it.def || 0; spd += it.spd || 0;
crit += it.crit || 0; dodge += it.dodge || 0;
hpFlat += it.hp || 0; mpFlat += it.mp || 0;
if (slot === 'weapon') { rng = it.rng || 1; wt = it.wt || 'sword'; }
if (it.bonus) {
for (const bk of Object.keys(it.bonus)) {
eff[bk] = (eff[bk] || 0) + it.bonus[bk];
if (bk === 'str') atk += 3 * it.bonus[bk];
if (bk === 'con') { def += 2 * it.bonus[bk]; hpFlat += 12 * it.bonus[bk]; }
if (bk === 'agi') { spd += 2 * it.bonus[bk]; dodge += Math.round(1.5 * it.bonus[bk]); }
if (bk === 'wit') mpFlat += 6 * it.bonus[bk];
if (bk === 'luk') crit += it.bonus[bk];
}
}
}
// internal art
const ia = DATA.INTERNALS[p.internal];
if (ia) {
const L = p.internalLv;
hpFlat += (ia.hp0 || 0) + (ia.hpL || 0) * L;
mpFlat += (ia.mp0 || 0) + (ia.mpL || 0) * L;
atk += (ia.atk0 || 0) + (ia.atkL || 0) * L;
def += (ia.def0 || 0) + (ia.defL || 0) * L;
spd += (ia.spd0 || 0) + (ia.spdL || 0) * L;
crit += (ia.crit0 || 0) + (ia.critL || 0) * L;
dodge += (ia.dodge0 || 0) + (ia.dodgeL || 0) * L;
}
// lightness skill
const li = DATA.LIGHTNESS[p.light];
let mvBonus = 0;
if (li) { mvBonus = li.mv || 0; dodge += li.dodge || 0; spd += li.spd || 0; }
hpFlat += p.permBonuses.hp; mpFlat += p.permBonuses.mp;
const d = {
atk, def, spd, crit, dodge,
maxHp: hpFlat, maxMp: mpFlat,
rng, wt,
mv: Util.clamp(3 + Math.floor(spd / 15) + mvBonus, 3, 10),
regenMpPct: 5 + ((ia && ia.mpRegen) || 0)
};
p.d = d;
if (typeof Game !== 'undefined' && Game.uiDirty) {} // no-op hook
return d;
},
// build a plain summary for UI
sheet(p) {
Stats.recalc(p);
return p.d;
}
};
/* ---------------- time ---------------- */
const TimeSys = {
advance(hours) {
hours = Math.max(0, Math.round(hours));
G.time.hour += hours;
let rolled = false;
while (G.time.hour >= 24) { G.time.hour -= 24; G.time.day++; rolled = true; }
if (rolled && typeof Game !== 'undefined' && Game.dailyReset) Game.dailyReset();
if (typeof UI !== 'undefined' && UI.refreshTop) UI.refreshTop();
},
isNight() { return G.time.hour >= 20 || G.time.hour < 5; },
str() {
const h = String(G.time.hour).padStart(2, '0');
const phase = G.time.hour < 5 ? 'Small Hours' : G.time.hour < 8 ? 'Dawn' : G.time.hour < 12 ? 'Morning'
: G.time.hour < 14 ? 'Noon' : G.time.hour < 18 ? 'Afternoon' : G.time.hour < 20 ? 'Dusk' : 'Night';
return `Day ${G.time.day} · ${phase} (${h}:00)`;
}
};
/* ---------------- log ---------------- */
const Log = {
add(msg, cls) {
G.log.push({ m: msg, c: cls || '', t: Date.now() });
if (G.log.length > 250) G.log.shift();
const el = (typeof document !== 'undefined') && document.getElementById('log');
if (el) {
const p = document.createElement('p');
if (cls) p.className = cls;
p.innerHTML = msg;
el.appendChild(p);
while (el.children.length > 120) el.removeChild(el.firstChild);
el.scrollTop = el.scrollHeight;
} else if (typeof console !== 'undefined') {
console.log('[log]', msg.replace(/<[^>]*>/g, ''));
}
},
clearDom() {
const el = (typeof document !== 'undefined') && document.getElementById('log');
if (el) el.innerHTML = '';
for (const l of G.log.slice(-30)) {
if (!el) break;
const p = document.createElement('p');
p.className = l.c; p.innerHTML = l.m;
el.appendChild(p);
}
}
};
/* ---------------- sound (tiny synth) ---------------- */
const Sfx = {
ctx: null,
ensure() {
if (!G.settings.sfx) return null;
try {
if (!Sfx.ctx) Sfx.ctx = new (window.AudioContext || window.webkitAudioContext)();
return Sfx.ctx;
} catch (e) { return null; }
},
tone(freq, dur, type, vol, when) {
const ctx = Sfx.ensure(); if (!ctx) return;
const t0 = ctx.currentTime + (when || 0);
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = type || 'square'; o.frequency.value = freq;
g.gain.setValueAtTime(vol || 0.05, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(ctx.destination);
o.start(t0); o.stop(t0 + dur + 0.02);
},
play(name) {
switch (name) {
case 'click': Sfx.tone(520, .06, 'triangle', .04); break;
case 'hit': Sfx.tone(160, .12, 'square', .06); Sfx.tone(90, .16, 'sawtooth', .05, .02); break;
case 'crit': Sfx.tone(220, .1, 'square', .07); Sfx.tone(330, .12, 'square', .06, .05); Sfx.tone(440, .14, 'triangle', .05, .1); break;
case 'heal': Sfx.tone(440, .12, 'sine', .05); Sfx.tone(660, .16, 'sine', .05, .08); break;
case 'coin': Sfx.tone(880, .07, 'triangle', .05); Sfx.tone(1320, .09, 'triangle', .04, .05); break;
case 'levelup': [523, 659, 784, 1047].forEach((f, i) => Sfx.tone(f, .14, 'triangle', .06, i * .09)); break;
case 'victory': [392, 523, 659, 784].forEach((f, i) => Sfx.tone(f, .18, 'triangle', .06, i * .11)); break;
case 'defeat': [330, 262, 196].forEach((f, i) => Sfx.tone(f, .25, 'sawtooth', .05, i * .18)); break;
case 'open': Sfx.tone(300, .08, 'sine', .04); break;
}
}
};
/* ---------------- saves ---------------- */
const SaveSys = {
KEY: 'jianghu_save_',
snapshot() {
return JSON.stringify({
v: 1, when: Date.now(),
player: G.player, loc: G.loc, discovered: G.discovered, flags: G.flags,
aff: G.aff, rep: G.rep, quests: G.quests, party: G.party, allies: G.allies,
time: G.time, daily: G.daily, bounty: G.bounty, tourney: G.tourney,
settings: G.settings, partner: G.partner
});
},
restore(json) {
const o = JSON.parse(json);
G.player = o.player; G.loc = o.loc; G.discovered = o.discovered || {};
G.flags = o.flags || {}; G.aff = o.aff || {}; G.rep = o.rep || G.rep;
G.quests = o.quests || {}; G.party = o.party || []; G.allies = o.allies || [];
G.time = o.time || { day: 1, hour: 8 }; G.daily = o.daily || defaultDaily();
G.bounty = o.bounty || { list: [], taken: null };
G.tourney = o.tourney || { streak: 0, lastDay: -99 };
G.settings = Object.assign({ sfx: true, aiSpeed: 380 }, o.settings);
G.partner = o.partner || null;
G.log = []; G.inBattle = false; G.battleMeta = null;
Stats.recalc(G.player);
},
save(slot) {
try { localStorage.setItem(SaveSys.KEY + slot, SaveSys.snapshot()); return true; }
catch (e) { Log.add('Save failed: ' + e.message, 'bad'); return false; }
},
load(slot) {
const s = localStorage.getItem(SaveSys.KEY + slot);
if (!s) return false;
SaveSys.restore(s); return true;
},
peek(slot) {
const s = localStorage.getItem(SaveSys.KEY + slot);
if (!s) return null;
try { const o = JSON.parse(s); return { day: o.time.day, name: o.player.name, lvl: o.player.lvl, when: o.when }; }
catch (e) { return null; }
},
del(slot) { localStorage.removeItem(SaveSys.KEY + slot); },
list() {
const out = [];
for (const slot of ['auto', '1', '2', '3']) {
const info = SaveSys.peek(slot);
out.push({ slot, info });
}
return out;
},
autosave() { SaveSys.save('auto'); },
exportStr() { return btoa(unescape(encodeURIComponent(SaveSys.snapshot()))); },
importStr(str) {
try { SaveSys.restore(decodeURIComponent(escape(atob(str.trim())))); return true; }
catch (e) { return false; }
}
};