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:
+313
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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; }
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user