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
314 lines
12 KiB
JavaScript
314 lines
12 KiB
JavaScript
'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; }
|
|
}
|
|
};
|