'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; } } };