diff --git a/README.md b/README.md new file mode 100644 index 0000000..517cde6 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Jianghu Chronicles — Road of the Wandering Blade + +An **original open-world wuxia browser RPG**: walk a living jianghu, befriend +(and romance) companions, master six weapon schools down to their tier-V +ultimates, and unravel the Iron Umbrella Society across a six-chapter story. +All names, characters, techniques and lore are original works of fiction. + +## Play + +Any static server pointed at this folder works: + +```bash +python3 -m http.server 8917 # or: node server.js +# open http://127.0.0.1:8917 +``` + +Opening `index.html` directly from disk (`file://`) also works — no modules, +no fetches. + +## Controls + +| Input | Action | +|---|---| +| Click / tap map | Walk there; tap near NPC/portal to auto-interact | +| WASD / Arrows / ZQSD | Move (Shift = run) | +| E / Enter | Talk · gather · use spots · portals | +| D-pad (bottom-right) | Touch controls | +| Glowing ➜ arches | Travel between locations | + +Combat is turn-based tactics on a 10×7 grid: move + act each turn, click a +move then a highlighted tile. Battles pause the world; winning clears the +roamer that ambushed you. + +## Features + +- 12 hand-styled locations, day/night cycle, ambient particles, minimap +- 6 weapon types × 5 tiers of techniques, internals cultivation, lightness arts +- Full gear: weapon / head / body / feet / 2 accessories, smithing upgrades +- 6-chapter main quest, job-board side jobs, bounty hunts, weekly tournament +- Companions with chemistry passives, affection, three romances, four sect paths +- Alchemy, fishing, gambling, pickpocketing (infamy has consequences) +- Achievements, monster codex, four endings, autosave + 3 slots + export + +## Tests + +```bash +node test/smoke.mjs +``` + +25 headless tests load every script into a VM with DOM stubs and exercise +character creation, battles (AI and player-driven), quests, economy, crafting, +save/load, world-map generation invariants, achievements, equipment stats and +side-job lifecycles. diff --git a/index.html b/index.html index 86478b0..ad82838 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ Jianghu Chronicles — Road of the Wandering Blade - +
@@ -35,14 +35,14 @@
- - - - - - - - - + + + + + + + + + diff --git a/js/ui.js b/js/ui.js index c57d8b5..bc82460 100644 --- a/js/ui.js +++ b/js/ui.js @@ -357,7 +357,7 @@ const UI = { v.innerHTML = `
-
Click / tap map to walk there (tap NPC to talk) · WASD / Arrows also move · E interact · D-pad bottom-right for touch · glowing arches travel · touch beasts to fight
+
Click / tap map to walk there (tap NPC to talk) · WASD / Arrows move · Shift run · E interact · D-pad bottom-right for touch · glowing arches travel · touch beasts to fight · minimap top-right

${Util.esc(L.name)} ${L.region}${L.danger ? `danger ${'★'.repeat(L.danger)}` : ''}${L.nightOnly ? 'night only' : ''}

${L.desc}

diff --git a/js/world2d.js b/js/world2d.js index e2e218c..eac6b79 100644 --- a/js/world2d.js +++ b/js/world2d.js @@ -432,6 +432,7 @@ W2D.enter = function (locId, canvas) { W2D.ground = W2D._flatGround(W2D.grid); } try { W2D.initParts(); } catch (_) { W2D.partMode = null; } + try { W2D.mini = W2D.prerenderMini(W2D.grid); } catch (_) { W2D.mini = null; } W2D.cv = canvas; W2D.ctx = canvas.getContext('2d'); const gr = W2D.grid; @@ -579,7 +580,7 @@ W2D.uiBlocked = function () { /* ---------------- update ---------------- */ W2D.update = function (dt, now) { - const sp = 0.14 * dt; // px per ms + const sp = 0.14 * dt * (W2D.keys['shift'] ? 1.75 : 1); // px per ms (Shift = run) const K = W2D.keys, V = W2D.vk || {}; let dx = 0, dy = 0; if (K['w'] || V.up) dy -= 1; @@ -825,6 +826,34 @@ W2D.draw = function (now) { c.font = '20px serif'; c.textAlign = 'center'; c.fillText(LOCS[W2D.cur].name, W2D.VW / 2, 43); } + + // minimap (screen space, top-right) + safe(() => { + if (!W2D.mini || !gr) return; + const mw = W2D.mini.width, mh = W2D.mini.height; + const mx = W2D.VW - mw - 14, my = 14; + const sx = mw / (gr.w * T), sy = mh / (gr.h * T); + c.fillStyle = 'rgba(8,10,16,.78)'; + c.fillRect(mx - 5, my - 5, mw + 10, mh + 10); + c.strokeStyle = 'rgba(216,179,106,.55)'; + c.strokeRect(mx - 4.5, my - 4.5, mw + 9, mh + 9); + c.drawImage(W2D.mini, mx, my); + for (const ex of gr.exits) { + const L2 = LOCS[ex.to]; + if (L2.hidden && !G.flags[L2.hidden]) continue; + c.fillStyle = '#d8b36a'; + c.fillRect(mx + ex.x * T * sx - 2, my + ex.y * T * sy - 2, 4, 4); + } + for (const e of W2D.ents) { + if (e.dead) continue; + if (e.kind === 'npc') { c.fillStyle = '#efe6cf'; c.fillRect(mx + e.x * sx - 1.5, my + e.y * sy - 1.5, 3, 3); } + else if (e.kind === 'roam') { c.fillStyle = '#d05a48'; c.fillRect(mx + e.x * sx - 1.5, my + e.y * sy - 1.5, 3, 3); } + else if (e.kind === 'spot') { c.fillStyle = '#7dc25c'; c.fillRect(mx + e.x * sx - 1, my + e.y * sy - 1, 2, 2); } + } + const pulse = 2.5 + Math.sin(now / 180) * 1; + c.fillStyle = '#ff5a4e'; + c.beginPath(); c.arc(mx + W2D.px * sx, my + W2D.py * sy, pulse, 0, 7); c.fill(); + }); }; W2D.figure = function (c, x, y, s, robe, now, walking) { @@ -1088,6 +1117,24 @@ W2D.drawVignette = function (c) { c.fillRect(W2D.cam.x, W2D.cam.y, W2D.VW, W2D.VH); }; +/* minimap: prerendered tile thumbnail + live dots drawn each frame */ +W2D.prerenderMini = function (grid) { + const S = 3; + const cv = document.createElement('canvas'); + cv.width = grid.w * S; cv.height = grid.h * S; + const c = cv.getContext('2d'); + for (let y = 0; y < grid.h; y++) for (let x = 0; x < grid.w; x++) { + const t = grid.g[y][x]; + c.fillStyle = t === 2 || t === 19 ? '#1c3a52' + : t === 1 ? '#63553f' + : t === 8 ? '#7a5c3c' + : BLOCKED.has(t) ? '#20262c' + : '#3a5239'; + c.fillRect(x * S, y * S, S, S); + } + return cv; +}; + /* flat fallback ground (used only if the rich renderer throws) */ W2D._flatGround = function (grid) { const T = W2D.T;