/* ========================================================================= world2d.js — graphical overworld layer Top-down tile world: walk with WASD/arrows, talk to NPCs (E), gather, fight roamers, use portals to travel. Reuses all game data & systems. ========================================================================= */ 'use strict'; const W2D = { T: 48, // tile size (px) VW: 960, VH: 540, // logical viewport cv: null, ctx: null, cur: null, // current loc id grid: null, // {w,h,g,exits,spots,npcSpots,roamSpots,style} ground: null, // prerendered offscreen canvas px: 0, py: 0, dir: 2, // player pixel pos (feet), dir:0N 1E 2S 3W moving: false, anim: 0, trail: [], // follower trail cam: { x: 0, y: 0 }, keys: Object.create(null), running: false, paused: false, ents: [], // live npcs/roamers/spots for current map engaged: null, // roamer that triggered current battle posCache: {}, // locId -> {x,y} persist position per visit _bound: false, _raf: 0, _last: 0, banner: 0, }; /* ---------------- deterministic randomness ---------------- */ W2D.hashSeed = function (s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }; W2D.rngFrom = function (seed) { let s = seed >>> 0; return function () { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; }; /* ---------------- tile palette ---------------- */ // 0 grass 1 path 2 water 3 tree 4 wall 5 door 6 flower 7 rock 8 wood // 9 sand 10 stone 11 crop 12 lantern 13 grave 14 stair 15 banner // 16 stall 17 torch 18 shrine 19 boat 20 well 21 bridge 22 petal const BLOCKED = new Set([2, 3, 4, 7, 12, 13, 15, 16, 17, 18, 19, 20]); const SOLID_BORDER = { village: 3, town: 3, city: 4, dock: 2, temple: 3, sect: 7, market: 4, tomb: 4, bamboo: 3, mountain: 7, valley: 3 }; W2D.styleFor = function (locId) { if (locId === 'l_dock') return 'dock'; if (locId === 'l_temple') return 'temple'; if (locId === 'l_sect') return 'sect'; if (locId === 'l_market') return 'market'; if (locId === 'l_tomb') return 'tomb'; if (locId === 'l_bamboo') return 'bamboo'; if (locId === 'l_mountain') return 'mountain'; if (locId === 'l_valley') return 'valley'; if (locId === 'l_city' || locId === 'l_town') return 'city'; return 'village'; }; /* ---------------- map generation (pure, testable) ---------------- */ W2D.genGrid = function (locId) { const L = LOCS[locId]; const style = W2D.styleFor(locId); const rnd = W2D.rngFrom(W2D.hashSeed(locId + '|' + style)); const w = 44, h = 32; const g = []; for (let y = 0; y < h; y++) { g.push(new Array(w).fill(0)); } const set = (x, y, t) => { if (x >= 0 && y >= 0 && x < w && y < h) g[y][x] = t; }; const get = (x, y) => (x >= 0 && y >= 0 && x < w && y < h) ? g[y][x] : 7; /* border ring */ const bb = SOLID_BORDER[style]; for (let x = 0; x < w; x++) { set(x, 0, bb); set(x, h - 1, bb); } for (let y = 0; y < h; y++) { set(0, y, bb); set(w - 1, y, bb); } /* style bases */ if (style === 'dock') { for (let y = 2; y < h - 2; y++) for (let x = w - 14; x < w - 1; x++) set(x, y, 2); for (let y = 0; y < h; y++) for (let x = w - 14; x < w; x++) if (g[y][x] === 2 && rnd() < 0.06) set(x, y, 19); } else if (style === 'temple') { for (let y = 4; y < h - 4; y++) for (let x = 6; x < w - 6; x++) if (rnd() < 0.85) set(x, y, 10); } else if (style === 'sect') { for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y][x] === 0 && rnd() < 0.5) set(x, y, 9); } else if (style === 'market' || style === 'tomb') { for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y][x] === 0) set(x, y, 10); } else if (style === 'valley') { for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (g[y][x] === 0 && rnd() < 0.22) set(x, y, 22); } /* exits: one per travel edge, distributed N,E,S,W */ const edges = TRAVEL.filter(e => e.includes(locId)); const dirs = []; const order = [[0, 'N'], [1, 'E'], [2, 'S'], [3, 'W']]; edges.forEach((e, i) => { const [dx, tag] = order[i % 4]; const other = e[0] === locId ? e[1] : e[0]; let ex, ey; if (tag === 'N') { ex = 6 + Math.floor(rnd() * (w - 12)); ey = 0; } else if (tag === 'S') { ex = 6 + Math.floor(rnd() * (w - 12)); ey = h - 1; } else if (tag === 'E') { ex = w - 1; ey = 4 + Math.floor(rnd() * (h - 8)); } else { ex = 0; ey = 4 + Math.floor(rnd() * (h - 8)); } // carve 3-tile gap for (let k = -1; k <= 1; k++) { if (tag === 'N') set(ex + k, 0, 1), set(ex + k, 1, 1); if (tag === 'S') set(ex + k, h - 1, 1), set(ex + k, h - 2, 1); if (tag === 'E') set(w - 1, ey + k, 1), set(w - 2, ey + k, 1); if (tag === 'W') set(0, ey + k, 1), set(1, ey + k, 1); } dirs.push({ to: other, x: ex, y: ey, tag, hours: e[2] }); }); /* paths: connect each exit to center without running along the border */ const cx = w >> 1, cy = h >> 1; dirs.forEach(d => { let x = d.x, y = d.y; if (d.tag === 'N' || d.tag === 'S') { const midY = d.tag === 'N' ? 3 : h - 4; while (y !== midY) { set(x, y, 1); y += Math.sign(midY - y); } while (x !== cx) { set(x, y, 1); x += Math.sign(cx - x); } while (y !== cy) { set(x, y, 1); y += Math.sign(cy - y); } } else { const midX = d.tag === 'W' ? 3 : w - 4; while (x !== midX) { set(x, y, 1); x += Math.sign(midX - x); } while (y !== cy) { set(x, y, 1); y += Math.sign(cy - y); } while (x !== cx) { set(x, y, 1); x += Math.sign(cx - x); } } set(x, y, 1); }); /* decorations per style */ const freeTile = () => { for (let tries = 0; tries < 200; tries++) { const x = 2 + Math.floor(rnd() * (w - 4)), y = 2 + Math.floor(rnd() * (h - 4)); if (g[y][x] === 0 || g[y][x] === 9 || g[y][x] === 10) return { x, y }; } return null; }; const scatter = (t, n, r = 0.0) => { for (let i = 0; i < n; i++) { const p = freeTile(); if (p && rnd() > r) set(p.x, p.y, t); } }; if (style === 'village') { scatter(3, 26); scatter(6, 14); // farm plots + houses for (let i = 0; i < 5; i++) { const p = freeTile(); if (p) { for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 3; dx++) set(p.x + dx, p.y + dy, 11); } } for (let i = 0; i < 6; i++) { const p = freeTile(); if (p) { set(p.x, p.y, 4); set(p.x, p.y + 1, 4); set(p.x + 1, p.y, 4); set(p.x + 1, p.y + 1, 5); } } const wp = freeTile(); if (wp) set(wp.x, wp.y, 20); } else if (style === 'city' && locId === 'l_city') { for (let i = 0; i < 10; i++) { const p = freeTile(); if (p) { set(p.x, p.y, 4); set(p.x + 1, p.y, 4); set(p.x, p.y + 1, 4); set(p.x + 1, p.y + 1, 4); set(p.x, p.y + 2, 5); } } scatter(12, 10); scatter(3, 8); } else if (style === 'city') { // town for (let i = 0; i < 7; i++) { const p = freeTile(); if (p) { set(p.x, p.y, 4); set(p.x + 1, p.y, 4); set(p.x, p.y + 1, 4); set(p.x + 1, p.y + 1, 5); } } scatter(16, 6); scatter(3, 10); } else if (style === 'dock') { for (let i = 0; i < 2; i++) { const p = { x: w - 12 + i * 5, y: 8 + Math.floor(rnd() * 10) }; for (let dx = 0; dx < 6; dx++) set(p.x + dx, p.y, 8); } for (let i = 0; i < 3; i++) { const p = freeTile(); if (p) set(p.x, p.y, 17); } scatter(6, 8); } else if (style === 'temple') { for (let i = 0; i < 4; i++) { const p = freeTile(); if (p) set(p.x, p.y, 18); } scatter(3, 10, 0.5); scatter(12, 8); for (let i = 0; i < 6; i++) { const p = freeTile(); if (p) set(p.x, p.y, 17); } } else if (style === 'sect') { for (let y = 6; y < h - 6; y += 3) for (let x = 4; x < w - 4; x++) if (rnd() < 0.5 && g[y][x] !== 1) set(x, y, 14); scatter(7, 16); scatter(15, 6); scatter(17, 4); } else if (style === 'market') { scatter(16, 12); scatter(12, 16); } else if (style === 'tomb') { scatter(13, 18); scatter(17, 10); scatter(7, 8); } else if (style === 'bamboo') { for (let y = 2; y < h - 2; y++) for (let x = 2; x < w - 2; x++) if (rnd() < 0.16 && g[y][x] === 0) set(x, y, 3); scatter(6, 10); } else if (style === 'mountain') { scatter(7, 34); scatter(3, 12, 0.4); scatter(18, 4); } else if (style === 'valley') { scatter(6, 30); scatter(3, 18); scatter(2, 0); } /* keep spawn plaza clear */ const clearR = 3; for (let y = cy - clearR; y <= cy + clearR; y++) for (let x = cx - clearR; x <= cx + clearR; x++) { if (x > 0 && y > 0 && x < w - 1 && y < h - 1) { if (BLOCKED.has(g[y][x])) g[y][x] = 1; } } /* collect free tiles for entity placement */ const free = []; for (let y = 2; y < h - 2; y++) for (let x = 2; x < w - 2; x++) { if (!BLOCKED.has(g[y][x]) && g[y][x] !== 2) free.push({ x, y }); } const pickSpaced = (n, minD) => { const out = []; let guard = 0; while (out.length < n && guard++ < 800) { const p = free[Math.floor(rnd() * free.length)]; if (Math.abs(p.x - cx) + Math.abs(p.y - cy) < 4) continue; if (out.every(q => Math.abs(q.x - p.x) + Math.abs(q.y - p.y) >= minD)) out.push({ x: p.x, y: p.y }); } return out; }; const npcSpots = pickSpaced((L.npcs || []).length, 6); const spotDefs = (L.actions || []).filter(a => ['herb', 'mine', 'meditate', 'dummy', 'secttasks', 'board', 'gamble', 'pickpocket', 'camp', 'fish'].includes(a.id)); const spotPts = pickSpaced(spotDefs.length, 7); const spots = spotDefs.map((a, i) => ({ act: a, x: spotPts[i] ? spotPts[i].x : cx, y: spotPts[i] ? spotPts[i].y : cy })); const pools = L.pools || {}; const poolIds = pools.mid || pools.low || pools.high || []; const danger = L.danger || 0; const nRoam = Math.min(4, danger + 1); const roamSpots = (danger > 0 && poolIds.length) ? pickSpaced(nRoam, 8).map(p => ({ id: poolIds[Math.floor(rnd() * poolIds.length)], x: p.x, y: p.y })) : []; return { w, h, g, exits: dirs, spots, npcSpots, roamSpots, style, cx, cy }; }; /* ---------------- prerender ground ---------------- */ W2D.prerender = function (grid, locId) { const cv = document.createElement('canvas'); cv.width = grid.w * W2D.T; cv.height = grid.h * W2D.T; const c = cv.getContext('2d'); const rnd = W2D.rngFrom(W2D.hashSeed('gfx|' + locId)); const T = W2D.T; for (let y = 0; y < grid.h; y++) for (let x = 0; x < grid.w; x++) { const t = grid.g[y][x], X = x * T, Y = y * T; // base if (t === 2 || t === 19) c.fillStyle = '#1c3a52'; else if (t === 10) c.fillStyle = '#3c4048'; else if (t === 9) c.fillStyle = '#5c5648'; else if (t === 8 || t === 21) c.fillStyle = '#6e5236'; else c.fillStyle = (x + y) % 2 ? '#31472f' : '#354c33'; c.fillRect(X, Y, T, T); // texture noise if (t !== 2 && t !== 19 && rnd() < 0.3) { c.fillStyle = 'rgba(255,255,255,.03)'; c.fillRect(X + rnd() * T, Y + rnd() * T, 3, 3); } // per-type details if (t === 1) { c.fillStyle = '#5d5142'; c.fillRect(X, Y, T, T); c.fillStyle = 'rgba(0,0,0,.12)'; c.fillRect(X, Y + T - 4, T, 4); } else if (t === 2 || t === 19) { c.strokeStyle = 'rgba(160,200,230,.18)'; c.beginPath(); c.moveTo(X + 4, Y + 10 + rnd() * 12); c.lineTo(X + T - 4, Y + 10 + rnd() * 12); c.stroke(); if (t === 19) { c.fillStyle = '#4a3524'; c.fillRect(X + 6, Y + 6, T - 12, T - 12); c.fillStyle = '#7a6248'; c.fillRect(X + 6, Y + 2, T - 12, 8); } } else if (t === 3) { c.fillStyle = '#274a33'; c.beginPath(); c.arc(X + T / 2, Y + T / 2 - 3, T / 2 - 3, 0, 7); c.fill(); c.fillStyle = 'rgba(255,255,255,.08)'; c.beginPath(); c.arc(X + T / 2 - 4, Y + T / 2 - 7, 5, 0, 7); c.fill(); c.fillStyle = '#4a3a28'; c.fillRect(X + T / 2 - 2, Y + T / 2 + 4, 4, 9); } else if (t === 4) { c.fillStyle = '#6e6152'; c.fillRect(X, Y, T, T); c.strokeStyle = 'rgba(0,0,0,.25)'; c.strokeRect(X + .5, Y + .5, T - 1, T - 1); c.fillStyle = 'rgba(255,255,255,.06)'; c.fillRect(X, Y, T, 5); } else if (t === 5) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#7a3b30'; c.fillRect(X + 4, Y + 4, T - 8, T - 8); c.fillStyle = '#d8b36a'; c.fillRect(X + T / 2 - 2, Y + T / 2, 4, 4); } else if (t === 6 || t === 22) { const col = t === 6 ? (rnd() < .5 ? '#c76f8a' : '#d8b36a') : '#d98aa8'; c.fillStyle = col; for (let k = 0; k < 3; k++) { c.beginPath(); c.arc(X + 6 + rnd() * (T - 12), Y + 6 + rnd() * (T - 12), 2.2, 0, 7); c.fill(); } } else if (t === 7) { c.fillStyle = '#4d565f'; c.beginPath(); c.moveTo(X + 4, Y + T - 6); c.lineTo(X + T / 2, Y + 5); c.lineTo(X + T - 4, Y + T - 6); c.closePath(); c.fill(); c.fillStyle = 'rgba(255,255,255,.1)'; c.beginPath(); c.moveTo(X + T / 2, Y + 5); c.lineTo(X + T - 4, Y + T - 6); c.lineTo(X + T / 2, Y + T - 6); c.closePath(); c.fill(); } else if (t === 8 || t === 21) { c.strokeStyle = 'rgba(0,0,0,.3)'; c.strokeRect(X + .5, Y + .5, T - 1, T - 1); c.fillStyle = 'rgba(0,0,0,.15)'; c.fillRect(X, Y + T / 2, T, 2); } else if (t === 10) { c.strokeStyle = 'rgba(0,0,0,.18)'; c.strokeRect(X + .5, Y + .5, T - 1, T - 1); } else if (t === 11) { c.fillStyle = '#2e3b22'; c.fillRect(X + 2, Y + 6, T - 4, T - 10); c.strokeStyle = '#7c8a3e'; for (let k = 0; k < 4; k++) { c.beginPath(); c.moveTo(X + 5 + k * 6, Y + T - 6); c.lineTo(X + 5 + k * 6, Y + 8); c.stroke(); } } else if (t === 12) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#5a4632'; c.fillRect(X + T / 2 - 2, Y + 8, 4, T - 10); c.fillStyle = '#e8b54d'; c.fillRect(X + T / 2 - 5, Y + 3, 10, 8); } else if (t === 13) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#565c64'; c.fillRect(X + 7, Y + 10, T - 14, T - 16); c.fillRect(X + T / 2 - 2, Y + 5, 4, 5); } else if (t === 14) { c.fillStyle = '#4a505a'; c.fillRect(X, Y, T, T); c.fillStyle = 'rgba(255,255,255,.08)'; c.fillRect(X, Y, T, 3); } else if (t === 15) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#5a4632'; c.fillRect(X + T / 2 - 2, Y + 6, 4, T - 8); c.fillStyle = '#a03a30'; c.fillRect(X + T / 2 + 1, Y + 6, 9, 14); } else if (t === 16) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#6e5236'; c.fillRect(X + 3, Y + 8, T - 6, T - 12); c.fillStyle = '#a03a30'; c.fillRect(X + 2, Y + 4, T - 4, 6); } else if (t === 17) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#5a4632'; c.fillRect(X + T / 2 - 2, Y + 10, 4, T - 12); c.fillStyle = '#e8843d'; c.beginPath(); c.arc(X + T / 2, Y + 8, 4, 0, 7); c.fill(); } else if (t === 18) { c.fillStyle = '#3c4048'; c.fillRect(X, Y, T, T); c.fillStyle = '#6a7078'; c.fillRect(X + 6, Y + 8, T - 12, T - 12); c.fillStyle = '#2a2e34'; c.fillRect(X + T / 2 - 3, Y + 12, 6, 8); } else if (t === 20) { c.fillStyle = '#31472f'; c.fillRect(X, Y, T, T); c.fillStyle = '#565c64'; c.beginPath(); c.arc(X + T / 2, Y + T / 2, T / 2 - 4, 0, 7); c.fill(); c.fillStyle = '#1c3a52'; c.beginPath(); c.arc(X + T / 2, Y + T / 2, T / 2 - 8, 0, 7); c.fill(); } } return cv; }; /* ---------------- rich ground renderer (v2, overrides above) ---------------- */ W2D.prerender = function (grid, locId) { const T = W2D.T; const cv = document.createElement('canvas'); cv.width = grid.w * T; cv.height = grid.h * T; const c = cv.getContext('2d'); const rnd = W2D.rngFrom(W2D.hashSeed('gfx2|' + locId)); const water = []; const at = (x, y) => (x >= 0 && y >= 0 && x < grid.w && y < grid.h) ? grid.g[y][x] : 7; for (let ty = 0; ty < grid.h; ty++) for (let tx = 0; tx < grid.w; tx++) { const t = at(tx, ty), X = tx * T, Y = ty * T, R = rnd; /* base fill per family */ if (t === 2 || t === 19 || t === 21) { // water family const deep = (at(tx, ty - 1) === 2 && at(tx - 1, ty) === 2); const grd = c.createLinearGradient(X, Y, X, Y + T); grd.addColorStop(0, deep ? '#16334a' : '#1d4258'); grd.addColorStop(1, '#122a3e'); c.fillStyle = grd; c.fillRect(X, Y, T, T); water.push([X, Y]); // shore foam where land touches const foam = (dx, dy) => !BLOCKED.has(at(tx + dx, ty + dy)) && at(tx + dx, ty + dy) !== 2; c.fillStyle = 'rgba(190,220,235,.35)'; if (foam(0, -1)) c.fillRect(X, Y, T, 4); if (foam(-1, 0)) c.fillRect(X, Y, 4, T); if (foam(0, 1)) c.fillRect(X, Y + T - 4, T, 4); if (foam(1, 0)) c.fillRect(X + T - 4, Y, 4, T); } else if (t === 10 || t === 13 || t === 14 || t === 17 || t === 18) { // stone floor family c.fillStyle = '#454b54'; c.fillRect(X, Y, T, T); c.strokeStyle = 'rgba(0,0,0,.22)'; c.strokeRect(X + .5 + (tx % 2) * 6, Y + .5, T - 1, T - 1); c.fillStyle = 'rgba(255,255,255,.05)'; c.fillRect(X, Y, T, 3); if (R() < .18) { c.strokeStyle = 'rgba(0,0,0,.18)'; c.beginPath(); c.moveTo(X + 6, Y + 8); c.lineTo(X + T - 10, Y + T - 12); c.stroke(); } // crack if (R() < .12) { c.fillStyle = 'rgba(110,150,90,.25)'; c.beginPath(); c.arc(X + T * .7, Y + T * .75, 5, 0, 7); c.fill(); } // moss } else if (t === 8) { // pier planks c.fillStyle = '#7a5c3c'; c.fillRect(X, Y, T, T); for (let i = 0; i < 4; i++) { c.fillStyle = i % 2 ? '#6f5236' : '#81624a'; c.fillRect(X, Y + i * (T / 4), T, T / 4 - 2); } c.fillStyle = 'rgba(0,0,0,.35)'; [[6, 6], [T - 8, 6], [6, T - 8], [T - 8, T - 8]].forEach(([px2, py2]) => c.fillRect(X + px2, Y + py2, 3, 3)); } else if (t === 11) { // crop rows c.fillStyle = '#4a4030'; c.fillRect(X, Y, T, T); for (let r2 = 0; r2 < 3; r2++) { c.fillStyle = '#3a3325'; c.fillRect(X, Y + 8 + r2 * 13, T, 4); } c.strokeStyle = '#86a03e'; c.lineWidth = 2; for (let k2 = 0; k2 < 5; k2++) { const sx = X + 6 + k2 * 9 + R() * 3; c.beginPath(); c.moveTo(sx, Y + T - 6); c.quadraticCurveTo(sx + 3, Y + T - 20, sx + (R() * 8 - 4), Y + 12); c.stroke(); } c.lineWidth = 1; } else { // land default: grass/sand/dirt base const sand = (t === 9) || grid.style === 'sect'; const g1 = sand ? '#6a6350' : '#39543a', g2 = sand ? '#5f5947' : '#31492f'; c.fillStyle = ((tx + ty) % 2) ? g1 : g2; c.fillRect(X, Y, T, T); if (!sand && R() < .55) { // grass blade tufts c.strokeStyle = 'rgba(140,180,100,.30)'; for (let k2 = 0; k2 < 4; k2++) { const bx = X + 4 + R() * (T - 8), by = Y + 6 + R() * (T - 10); c.beginPath(); c.moveTo(bx, by); c.lineTo(bx + (R() * 4 - 2), by - 5); c.stroke(); } } if (!sand && t === 0 && R() < .06) { c.fillStyle = R() < .5 ? '#c76f8a' : '#d8b36a'; c.beginPath(); c.arc(X + 8 + R() * (T - 16), Y + 8 + R() * (T - 16), 2.6, 0, 7); c.fill(); } if (t === 1) { // dirt path overlay with soft edge c.fillStyle = '#63553f'; c.fillRect(X, Y, T, T); c.fillStyle = 'rgba(0,0,0,.10)'; c.fillRect(X, Y + T - 5, T, 5); c.fillStyle = 'rgba(255,255,255,.05)'; c.fillRect(X, Y, T, 3); if (R() < .5) { c.fillStyle = '#57493a'; const px2 = X + 6 + R() * (T - 12), py2 = Y + 6 + R() * (T - 12); c.beginPath(); c.arc(px2, py2, 2.4, 0, 7); c.fill(); } if (R() < .3) { c.fillStyle = '#6d5f49'; c.fillRect(X + 4 + R() * (T - 20), Y + 10 + R() * (T - 20), 8, 3); } } if (t === 22) { // valley petals ground c.fillStyle = 'rgba(217,138,168,.5)'; for (let k2 = 0; k2 < 4; k2++) { c.beginPath(); c.ellipse(X + 6 + R() * (T - 12), Y + 6 + R() * (T - 12), 3.4, 2, R() * 3, 0, 7); c.fill(); } } } /* props */ if (t === 3) { // tree c.fillStyle = 'rgba(0,0,0,.28)'; c.beginPath(); c.ellipse(X + T / 2, Y + T - 6, T * .38, T * .14, 0, 0, 7); c.fill(); c.fillStyle = '#5a4632'; c.fillRect(X + T / 2 - 5, Y + T * .45, 10, T * .5); c.fillStyle = 'rgba(0,0,0,.2)'; c.fillRect(X + T / 2 + 1, Y + T * .45, 4, T * .5); const cy2 = Y + T * .34, cx2 = X + T / 2; const lobes = [[-14, -4, 15], [13, -2, 14], [0, -14, 16], [-6, 8, 12], [8, 8, 11]]; lobes.forEach(([ox, oy, rr]) => { c.fillStyle = '#2c5236'; c.beginPath(); c.arc(cx2 + ox, cy2 + oy + 3, rr, 0, 7); c.fill(); }); lobes.forEach(([ox, oy, rr]) => { c.fillStyle = '#3b6a44'; c.beginPath(); c.arc(cx2 + ox - 2, cy2 + oy - 1, rr * .82, 0, 7); c.fill(); }); c.fillStyle = 'rgba(200,230,160,.22)'; c.beginPath(); c.arc(cx2 - 8, cy2 - 10, 6, 0, 7); c.fill(); } else if (t === 4) { // plaster+timber wall c.fillStyle = '#8b7d68'; c.fillRect(X, Y, T, T); c.strokeStyle = '#4a3c2c'; c.lineWidth = 3; c.strokeRect(X + 1.5, Y + 1.5, T - 3, T - 3); c.beginPath(); c.moveTo(X + 3, Y + 3); c.lineTo(X + T - 3, Y + T - 3); c.moveTo(X + T - 3, Y + 3); c.lineTo(X + 3, Y + T - 3); c.stroke(); c.lineWidth = 1; c.fillStyle = 'rgba(0,0,0,.18)'; c.fillRect(X, Y + T - 5, T, 5); } else if (t === 5) { // door w/ paper windows c.fillStyle = '#4a3c2c'; c.fillRect(X, Y, T, T); c.fillStyle = '#7a3b30'; c.fillRect(X + 5, Y + 5, T - 10, T - 10); c.fillStyle = '#e8d9a0'; c.fillRect(X + 9, Y + 9, T - 18, T * .35); // paper window c.strokeStyle = '#5a2c24'; c.strokeRect(X + 9.5, Y + 9.5, T - 19, T * .35); c.fillStyle = '#d8b36a'; c.fillRect(X + T / 2 - 2, Y + T * .62, 4, 4); } else if (t === 6) { // flowers cluster ['#c76f8a', '#d8b36a', '#e8e3cf'].forEach((col, i) => { const fx = X + 10 + i * 12 + R() * 4, fy = Y + 12 + (i % 2) * 16; c.strokeStyle = '#4c7040'; c.beginPath(); c.moveTo(fx, fy + 7); c.lineTo(fx, fy); c.stroke(); c.fillStyle = col; for (let p2 = 0; p2 < 4; p2++) { c.beginPath(); c.arc(fx + Math.cos(p2 * 1.57) * 3, fy + Math.sin(p2 * 1.57) * 3, 2.4, 0, 7); c.fill(); } c.fillStyle = '#f0e6a0'; c.beginPath(); c.arc(fx, fy, 1.6, 0, 7); c.fill(); }); } else if (t === 7) { // boulder c.fillStyle = 'rgba(0,0,0,.28)'; c.beginPath(); c.ellipse(X + T / 2, Y + T - 7, T * .36, T * .13, 0, 0, 7); c.fill(); const grd = c.createLinearGradient(X, Y + 6, X, Y + T); grd.addColorStop(0, '#6a747e'); grd.addColorStop(1, '#464f58'); c.fillStyle = grd; c.beginPath(); c.moveTo(X + 6, Y + T - 8); c.lineTo(X + T * .32, Y + 8); c.lineTo(X + T * .62, Y + 12); c.lineTo(X + T - 6, Y + T - 8); c.closePath(); c.fill(); c.fillStyle = 'rgba(255,255,255,.16)'; c.beginPath(); c.moveTo(X + T * .32, Y + 8); c.lineTo(X + T * .62, Y + 12); c.lineTo(X + T * .5, Y + T - 10); c.closePath(); c.fill(); } else if (t === 12) { // lantern post c.fillStyle = 'rgba(0,0,0,.25)'; c.beginPath(); c.ellipse(X + T / 2, Y + T - 5, 8, 3, 0, 0, 7); c.fill(); c.fillStyle = '#4a3c2c'; c.fillRect(X + T / 2 - 3, Y + 10, 6, T - 14); c.fillStyle = '#8b2f26'; c.fillRect(X + T / 2 - 9, Y + 2, 18, 16); c.fillStyle = '#f0c05a'; c.fillRect(X + T / 2 - 6, Y + 5, 12, 10); c.strokeStyle = '#4a3c2c'; c.strokeRect(X + T / 2 - 9.5, Y + 1.5, 19, 17); } else if (t === 13) { // grave mound c.fillStyle = '#565c64'; c.beginPath(); c.arc(X + T / 2, Y + T * .58, T * .3, Math.PI, 0); c.fill(); c.fillStyle = '#6a7078'; c.fillRect(X + T / 2 - 5, Y + 8, 10, 14); c.fillStyle = '#2a2e34'; c.fillRect(X + T / 2 - 3, Y + 11, 6, 8); } else if (t === 14) { // stairs c.fillStyle = '#4d545e'; c.fillRect(X, Y, T, T); for (let s2 = 0; s2 < 4; s2++) { c.fillStyle = s2 % 2 ? '#565e69' : '#454c56'; c.fillRect(X, Y + s2 * (T / 4), T, T / 4 - 2); c.fillStyle = 'rgba(255,255,255,.07)'; c.fillRect(X, Y + s2 * (T / 4), T, 2); } } else if (t === 15) { // banner c.fillStyle = '#4a3c2c'; c.fillRect(X + T / 2 - 3, Y + 4, 6, T - 8); const wave = Math.sin((tx + ty)) * 3; c.fillStyle = '#a03a30'; c.beginPath(); c.moveTo(X + T / 2 + 3, Y + 6); c.quadraticCurveTo(X + T / 2 + 16, Y + 10 + wave, X + T / 2 + 15, Y + 26 + wave); c.lineTo(X + T / 2 + 3, Y + 24); c.closePath(); c.fill(); c.fillStyle = '#e8d9a0'; c.font = '9px serif'; c.textAlign = 'center'; c.fillText('武', X + T / 2 + 9, Y + 20 + wave); } else if (t === 16) { // market stall c.fillStyle = '#6e5236'; c.fillRect(X + 3, Y + T * .4, T - 6, T * .52); c.fillStyle = '#a03a30'; c.fillRect(X, Y + 6, T, 10); c.fillStyle = '#c94f42'; c.fillRect(X, Y + 6, T, 4); c.fillStyle = '#e8dcc2'; c.fillRect(X + 8, Y + T * .55, T - 16, 6); } else if (t === 17) { // torch c.fillStyle = '#4a3c2c'; c.fillRect(X + T / 2 - 3, Y + 14, 6, T - 18); c.fillStyle = '#e8843d'; c.beginPath(); c.arc(X + T / 2, Y + 12, 6, 0, 7); c.fill(); c.fillStyle = '#f5c96a'; c.beginPath(); c.arc(X + T / 2, Y + 11, 3, 0, 7); c.fill(); } else if (t === 18) { // shrine c.fillStyle = '#6a7078'; c.fillRect(X + 8, Y + 10, T - 16, T - 14); c.fillStyle = '#7d848d'; c.fillRect(X + 5, Y + 6, T - 10, 7); c.fillStyle = '#22262c'; c.fillRect(X + T / 2 - 4, Y + 18, 8, 10); c.fillStyle = 'rgba(240,200,120,.5)'; c.fillRect(X + T / 2 - 3, Y + 20, 2, 2); c.fillRect(X + T / 2 + 1, Y + 20, 2, 2); } else if (t === 19) { // boat c.fillStyle = '#5a4632'; c.beginPath(); c.ellipse(X + T / 2, Y + T / 2, T * .42, T * .26, 0, 0, 7); c.fill(); c.fillStyle = '#7a6248'; c.fillRect(X + T * .2, Y + T * .38, T * .6, 5); c.fillStyle = '#8b6a4a'; c.fillRect(X + T / 2 - 2, Y + 4, 4, 14); } else if (t === 20) { // well c.fillStyle = '#565c64'; c.beginPath(); c.arc(X + T / 2, Y + T / 2, T * .36, 0, 7); c.fill(); c.fillStyle = '#141f2c'; c.beginPath(); c.arc(X + T / 2, Y + T / 2, T * .24, 0, 7); c.fill(); c.fillStyle = '#4a3c2c'; c.fillRect(X + 6, Y + 4, 5, 12); c.fillRect(X + T - 11, Y + 4, 5, 12); c.fillStyle = '#7a5236'; c.fillRect(X + 4, Y + 2, T - 8, 5); } } /* ambient occlusion under blocking tiles (depth cue) */ for (let ty = 1; ty < grid.h; ty++) for (let tx = 0; tx < grid.w; tx++) { if (BLOCKED.has(at(tx, ty - 1)) && !BLOCKED.has(at(tx, ty))) { const grd = c.createLinearGradient(0, ty * T, 0, ty * T + 10); grd.addColorStop(0, 'rgba(0,0,0,.30)'); grd.addColorStop(1, 'rgba(0,0,0,0)'); c.fillStyle = grd; c.fillRect(tx * T, ty * T, T, 10); } } cv._water = water; return cv; }; /* ---------------- enter / lifecycle ---------------- */ W2D.enter = function (locId, canvas) { if (typeof document === 'undefined') return; W2D.stop(); W2D.cur = locId; W2D.grid = W2D.genGrid(locId); try { W2D.ground = W2D.prerender(W2D.grid, locId); W2D.gfxFallback = false; } catch (e) { console.warn('[world] rich renderer failed, using flat fallback:', e); W2D.gfxFallback = true; 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; // entities (cached per location so dead roamers stay dead between UI refreshes) if (!W2D.entsCache) W2D.entsCache = {}; if (W2D.entsCache[locId]) { W2D.ents = W2D.entsCache[locId]; } else { W2D.ents = []; gr.npcSpots.forEach((p, i) => { const id = LOCS[locId].npcs[i]; if (id) W2D.ents.push({ kind: 'npc', id, x: p.x * W2D.T + 16, y: p.y * W2D.T + 16 }); }); gr.spots.forEach(s => W2D.ents.push({ kind: 'spot', act: s.act, x: s.x * W2D.T + 16, y: s.y * W2D.T + 16 })); gr.roamSpots.forEach((p, i) => W2D.ents.push({ kind: 'roam', id: p.id, gx: p.x, gy: p.y, x: p.x * W2D.T + 16, y: p.y * W2D.T + 16, cool: 400 + i * 130, dead: false, immune: 0 })); W2D.entsCache[locId] = W2D.ents; } // spawn: cached pos or center const cache = W2D.posCache[locId]; if (cache && !W2D.blockedAt(cache.x, cache.y)) { W2D.px = cache.x; W2D.py = cache.y; } else { W2D.px = gr.cx * W2D.T + 16; W2D.py = gr.cy * W2D.T + 16; } W2D.cam.x = W2D.px - W2D.VW / 2; W2D.cam.y = W2D.py - W2D.VH / 2; W2D.target = null; W2D.pending = null; W2D.vk = { up: false, down: false, left: false, right: false }; W2D.banner = 2600; W2D.bindKeys(); canvas.setAttribute('tabindex', '0'); W2D.bindPointer(canvas); try { canvas.focus({ preventScroll: true }); } catch (_) {} const host = canvas.parentElement; if (host) W2D.buildDpad(host); W2D.paused = false; W2D.running = true; W2D._last = performance.now(); cancelAnimationFrame(W2D._raf); const tick = (t) => { if (!W2D.running) return; W2D._raf = requestAnimationFrame(tick); const dt = Math.min(50, t - W2D._last); W2D._last = t; if (!W2D.paused && !W2D.uiBlocked()) { try { W2D.update(dt, t); } catch (e) { W2D._uwarned = W2D._uwarned || {}; if (!W2D._uwarned[e.message]) { W2D._uwarned[e.message] = 1; console.warn('[world update]', e); } } } W2D.draw(t); }; W2D._raf = requestAnimationFrame(tick); }; W2D.stop = function () { W2D.running = false; cancelAnimationFrame(W2D._raf); }; W2D.pause = function (v) { W2D.paused = v; }; W2D.blockedAt = function (px, py) { const gr = W2D.grid; if (!gr) return true; const x = Math.floor(px / W2D.T), y = Math.floor(py / W2D.T); if (x < 0 || y < 0 || x >= gr.w || y >= gr.h) return true; return BLOCKED.has(gr.g[y][x]); }; W2D.bindKeys = function () { if (W2D._bound) return; W2D._bound = true; const KEY_MAP = { w: 'w', z: 'w', arrowup: 'w', s: 's', arrowdown: 's', a: 'a', q: 'a', arrowleft: 'a', d: 'd', arrowright: 'd', e: 'e', enter: 'e', ' ': 'e' }; const CODE_MAP = { KeyW: 'w', ArrowUp: 'w', KeyS: 's', ArrowDown: 's', KeyA: 'a', ArrowLeft: 'a', KeyD: 'd', ArrowRight: 'd', KeyE: 'e', Enter: 'e', Space: 'e' }; // trust e.key first; fall back to physical e.code only when e.key is unusable const norm = (e) => { const k = (e.key || '').toLowerCase(); return KEY_MAP[k] || (e.code ? CODE_MAP[e.code] : null) || k; }; document.addEventListener('keydown', (e) => { const k = norm(e); W2D.keys[k] = true; if (!W2D.running || W2D.paused || G.inBattle) return; if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(k)) e.preventDefault(); if (k === 'e') W2D.interact(); }, true); document.addEventListener('keyup', (e) => { W2D.keys[norm(e)] = false; }, true); // release everything when the tab loses focus window.addEventListener('blur', () => { W2D.keys = Object.create(null); W2D.vk = { up: false, down: false, left: false, right: false }; }); }; /* ---------------- pointer / touch controls ---------------- */ W2D.bindPointer = function (cv) { const toWorld = (ev) => { const r = cv.getBoundingClientRect(); return { x: (ev.clientX - r.left) * (W2D.VW / r.width) + W2D.cam.x, y: (ev.clientY - r.top) * (W2D.VH / r.height) + W2D.cam.y }; }; cv.style.cursor = 'crosshair'; cv.addEventListener('pointerdown', (ev) => { ev.preventDefault(); try { cv.focus({ preventScroll: true }); } catch (_) {} const p = toWorld(ev); // clicked on/near an interactable? walk there then trigger it let hit = null, hd = 1e9; const scan = (ent, kind, label) => { const d = Math.hypot(ent.x - p.x, ent.y - p.y); if (d < 34 && d < hd) { hd = d; hit = { ent, kind }; } }; for (const e of W2D.ents) { if (e.kind === 'roam' && e.dead) continue; if (e.kind === 'npc') scan(e, 'npc'); else if (e.kind === 'spot') scan(e, 'spot'); } for (const ex of W2D.grid.exits) { const L2 = LOCS[ex.to]; if (L2.hidden && !G.flags[L2.hidden]) continue; if (L2.nightOnly && !TimeSys.isNight()) continue; scan({ kind: 'exit', to: ex.to, x: ex.x * W2D.T + 16, y: ex.y * W2D.T + 16 }, 'exit'); } if (!hit) for (const e of W2D.ents) { if (e.kind === 'roam' && !e.dead) { const d = Math.hypot(e.x - p.x, e.y - p.y); if (d < 34) hit = { ent: e, kind: 'roam' }; } } W2D.target = { x: p.x, y: p.y }; W2D.pending = hit; // {kind, ent} }); }; W2D.buildDpad = function (host) { if (host.querySelector('.dpad')) return; const mk = (cls, label, act) => ``; const el = h(`
${Util.esc(n.role || '')}
`, btns); }; /* ---------------- drawing ---------------- */ W2D.draw = function (now) { const c = W2D.ctx; if (!c) return; const gr = W2D.grid, T = W2D.T; c.imageSmoothingEnabled = false; c.clearRect(0, 0, W2D.VW, W2D.VH); c.save(); c.translate(-Math.round(W2D.cam.x), -Math.round(W2D.cam.y)); const safe = (f) => { try { f(); } catch (e) { W2D._warned = W2D._warned || {}; const m = e && e.message; if (!W2D._warned[m]) { W2D._warned[m] = 1; console.warn('[world draw]', m); } } }; try { c.drawImage(W2D.ground, 0, 0); } catch (_) {} // animated water shimmer over water tiles safe(() => { { const wl = W2D.ground._water || []; c.strokeStyle = 'rgba(190,225,245,.22)'; c.lineWidth = 2; for (let i = 0; i < wl.length; i++) { const wx = wl[i][0], wy = wl[i][1]; if (wx < W2D.cam.x - T || wx > W2D.cam.x + W2D.VW || wy < W2D.cam.y - T || wy > W2D.cam.y + W2D.VH) continue; const ph = ((now / 900) + i * .37) % 1; const yy = wy + 8 + ph * (T - 16); c.beginPath(); c.moveTo(wx + 8 + Math.sin(i + now / 600) * 4, yy); c.lineTo(wx + 20 + Math.sin(i + now / 600) * 4, yy); c.stroke(); } c.lineWidth = 1; } }); // move-target marker safe(() => { if (W2D.target) { c.strokeStyle = 'rgba(216,179,106,.85)'; c.lineWidth = 2; c.beginPath(); c.arc(W2D.target.x, W2D.target.y, 6 + Math.sin(now / 150) * 1.5, 0, 7); c.stroke(); c.lineWidth = 1; } }); // exit arches safe(() => { for (const ex of gr.exits) { const L2 = LOCS[ex.to]; if (L2.hidden && !G.flags[L2.hidden]) continue; const X = ex.x * T, Y = ex.y * T; c.fillStyle = 'rgba(216,179,106,.9)'; c.fillRect(X + 4, Y + (ex.tag === 'N' ? 2 : T - 10), T - 8, 8); c.fillStyle = '#1a1408'; c.font = 'bold 10px serif'; c.textAlign = 'center'; c.fillText('➜', X + T / 2, Y + (ex.tag === 'N' ? 10 : T - 3)); } }); // sort drawables by y safe(() => { const draws = []; for (const e of W2D.ents) { if (e.kind === 'roam' && e.dead) continue; draws.push(e); } draws.push({ kind: 'player', x: W2D.px, y: W2D.py }); draws.sort((a, b) => a.y - b.y); for (const d of draws) { if (d.kind === 'npc') W2D.drawNpc(c, d, now); else if (d.kind === 'spot') W2D.drawSpot(c, d, now); else if (d.kind === 'roam') W2D.drawRoam(c, d, now); else W2D.drawPlayer(c, now); } }); // follower safe(() => { if (G.party && G.party.length && W2D.trail.length > 12) { const p = W2D.trail[Math.max(0, W2D.trail.length - 14)]; W2D.figure(c, p.x, p.y + 4, 0.8, '#5d8a3f', now, false); } }); // prompt safe(() => { const near = W2D.nearest(); if (near) { const label = near.ent.kind === 'npc' ? `E · Talk — ${NPCS[near.ent.id].name}` : near.ent.kind === 'spot' ? `E · ${near.label}` : `E · ${near.label}`; W2D.bubble(c, near.ent.x, near.ent.y - 34, label); } }); // night tint + lantern glow safe(() => { const hr = G.time.hour; let dark = 0; if (hr >= 20 || hr < 5) dark = 0.5; else if (hr >= 18) dark = (hr - 18) * 0.16; else if (hr < 7) dark = (7 - hr) * 0.12; if (dark > 0) { c.fillStyle = `rgba(8,12,32,${dark})`; c.fillRect(W2D.cam.x, W2D.cam.y, W2D.VW, W2D.VH); c.globalCompositeOperation = 'lighter'; for (let y = 0; y < gr.h; y++) for (let x = 0; x < gr.w; x++) { const t = gr.g[y][x]; if (t === 12 || t === 17) { const g2 = c.createRadialGradient(x * T + T / 2, y * T + T / 2, 4, x * T + T / 2, y * T + T / 2, 60); g2.addColorStop(0, 'rgba(232,160,60,.5)'); g2.addColorStop(1, 'rgba(232,160,60,0)'); c.fillStyle = g2; c.fillRect(x * T - 60, y * T - 60, 152, 152); } } c.globalCompositeOperation = 'source-over'; } }); safe(() => W2D.drawParts(c, now)); safe(() => W2D.drawVignette(c)); c.restore(); // banner if (W2D.banner > 0) { W2D.banner -= 16; const a = Math.min(1, W2D.banner / 600); c.fillStyle = `rgba(10,14,20,${0.55 * a})`; c.fillRect(W2D.VW / 2 - 170, 14, 340, 44); c.fillStyle = `rgba(216,179,106,${a})`; 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) { const bob = walking ? Math.sin(now / 90) * 1.6 : 0; c.fillStyle = 'rgba(0,0,0,.3)'; c.beginPath(); c.ellipse(x, y + 10 * s, 9 * s, 3.5 * s, 0, 0, 7); c.fill(); // legs c.fillStyle = '#2c2c34'; const lg = walking ? Math.sin(now / 90) * 3 : 0; c.fillRect(x - 5 * s, y + 2 * s + lg * s * 0.4, 4 * s, 8 * s); c.fillRect(x + 1 * s, y + 2 * s - lg * s * 0.4, 4 * s, 8 * s); // robe c.fillStyle = robe; c.fillRect(x - 7 * s, y - 8 * s + bob, 14 * s, 12 * s); c.fillStyle = 'rgba(216,179,106,.85)'; c.fillRect(x - 7 * s, y + 1 * s + bob, 14 * s, 2 * s); // head c.fillStyle = '#e8c39a'; c.beginPath(); c.arc(x, y - 12 * s + bob, 5 * s, 0, 7); c.fill(); c.fillStyle = '#1d1d22'; c.beginPath(); c.arc(x, y - 14 * s + bob, 5 * s, Math.PI, 0); c.fill(); // hair }; W2D.drawPlayer = function (c, now) { const robe = G.player.gender === 'f' ? '#8a3f5d' : '#3f5d8a'; W2D.figure(c, W2D.px, W2D.py, 1, robe, now, W2D.moving); // sword on back c.save(); c.translate(W2D.px + 8, W2D.py - 4); c.rotate(-0.6); c.fillStyle = '#9aa4ad'; c.fillRect(0, -2, 14, 3); c.fillStyle = '#5a4632'; c.fillRect(-4, -3, 4, 5); c.restore(); }; W2D.drawNpc = function (c, e, now) { const n = NPCS[e.id]; W2D.figure(c, e.x, e.y, 1, n.rob || '#5d6e8a', now, false); c.font = '11px serif'; c.textAlign = 'center'; c.fillStyle = 'rgba(0,0,0,.5)'; const w = c.measureText(n.name).width + 10; c.fillRect(e.x - w / 2, e.y - 34, w, 15); c.fillStyle = '#e8dcc2'; c.fillText(n.name, e.x, e.y - 23); }; W2D.drawSpot = function (c, e, now) { const pulse = 0.5 + Math.sin(now / 300) * 0.2; c.fillStyle = `rgba(216,179,106,${pulse * 0.35})`; c.beginPath(); c.arc(e.x, e.y, 14, 0, 7); c.fill(); c.font = '16px serif'; c.textAlign = 'center'; const glyph = { herb: '🌿', mine: '⛏️', meditate: '🧘', dummy: '🥋', secttasks: '📋', board: '📌', gamble: '🎲', pickpocket: '🖐️', camp: '🏕️', fish: '🎣' }[e.act.id] || '✨'; c.fillText(glyph, e.x, e.y + 6); }; W2D.drawRoam = function (c, e, now) { const en = ENEMIES[e.id]; const bob = Math.sin(now / 200 + e.gx) * 2; c.fillStyle = 'rgba(0,0,0,.3)'; c.beginPath(); c.ellipse(e.x, e.y + 10, 9, 3.5, 0, 0, 7); c.fill(); c.fillStyle = en.color || '#7a3b30'; c.fillRect(e.x - 8, e.y - 10 + bob, 16, 16); c.fillStyle = 'rgba(255,255,255,.15)'; c.fillRect(e.x - 8, e.y - 10 + bob, 16, 4); c.fillStyle = '#e8dcc2'; c.font = '12px serif'; c.textAlign = 'center'; c.fillText(en.glyph, e.x, e.y + 3 + bob); c.fillStyle = '#e07a6a'; c.fillRect(e.x - 9, e.y - 18 + bob, 18, 3); c.fillStyle = '#7dc25c'; c.fillRect(e.x - 9, e.y - 18 + bob, 18 * Math.min(1, (e.hp || 30) / 30), 3); }; W2D.bubble = function (c, x, y, text) { c.font = '12px serif'; c.textAlign = 'center'; const w = c.measureText(text).width + 16; c.fillStyle = 'rgba(12,16,24,.88)'; c.strokeStyle = 'rgba(216,179,106,.7)'; c.beginPath(); c.roundRect ? c.roundRect(x - w / 2, y - 14, w, 22, 6) : c.rect(x - w / 2, y - 14, w, 22); c.fill(); c.stroke(); c.fillStyle = '#f0e6cc'; c.fillText(text, x, y + 1); }; /* ================================================================ SPRITES v2 — larger, layered, animated (overrides earlier draws) ================================================================ */ W2D.figure = function (c, x, y, s, robe, now, walking, hair, dir) { s = s * 1.35; // bigger characters hair = hair || '#241d18'; dir = dir === undefined ? 2 : dir; const phase = walking ? Math.floor(now / 130) % 2 : 0; const bob = walking ? Math.sin(now / 110) * 1.8 : Math.sin(now / 500) * .6; // shadow c.fillStyle = 'rgba(0,0,0,.32)'; c.beginPath(); c.ellipse(x, y + 13 * s * .8, 10 * s, 4 * s, 0, 0, 7); c.fill(); // legs c.fillStyle = '#33333c'; const lo = walking ? (phase ? 3.5 : -3.5) * s * .5 : 0; c.fillRect(x - 6 * s, y + 4 * s + lo * .4, 5 * s, 12 * s); c.fillRect(x + 1 * s, y + 4 * s - lo * .4, 5 * s, 12 * s); // robe: trapezoid with side shade const grd = c.createLinearGradient(x - 9 * s, 0, x + 9 * s, 0); grd.addColorStop(0, robe); grd.addColorStop(.55, robe); grd.addColorStop(1, 'rgba(0,0,0,.28)'); c.fillStyle = robe; c.beginPath(); c.moveTo(x - 7 * s, y - 10 * s + bob); c.lineTo(x + 7 * s, y - 10 * s + bob); c.lineTo(x + 9 * s, y + 6 * s + bob * .4); c.lineTo(x - 9 * s, y + 6 * s + bob * .4); c.closePath(); c.fill(); c.fillStyle = 'rgba(255,255,255,.09)'; c.beginPath(); c.moveTo(x - 7 * s, y - 10 * s + bob); c.lineTo(x - 2 * s, y - 10 * s + bob); c.lineTo(x - 3 * s, y + 6 * s); c.lineTo(x - 9 * s, y + 6 * s); c.closePath(); c.fill(); // sash c.fillStyle = '#d8b36a'; c.fillRect(x - 8 * s, y - 1 * s + bob, 16 * s, 2.6 * s); // arms c.fillStyle = robe; c.fillRect(x - 10 * s, y - 8 * s + bob, 3.4 * s, 11 * s); c.fillRect(x + 6.6 * s, y - 8 * s + bob, 3.4 * s, 11 * s); c.fillStyle = '#e8c39a'; c.fillRect(x - 10 * s, y + 2.4 * s + bob, 3.4 * s, 2.6 * s); c.fillRect(x + 6.6 * s, y + 2.4 * s + bob, 3.4 * s, 2.6 * s); // head c.fillStyle = '#e8c39a'; c.beginPath(); c.arc(x, y - 16 * s + bob, 7 * s, 0, 7); c.fill(); // hair cap + bun c.fillStyle = hair; c.beginPath(); c.arc(x, y - 17.5 * s + bob, 7.2 * s, Math.PI * 1.02, Math.PI * 1.98); c.fill(); c.beginPath(); c.arc(x, y - 25 * s + bob, 3.4 * s, 0, 7); c.fill(); // topknot // face by direction if (dir !== 0) { c.fillStyle = '#20242a'; const ex = dir === 1 ? 2.4 * s : dir === 3 ? -2.4 * s : 0; if (dir === 2) { c.beginPath(); c.arc(x - 2.4 * s, y - 15 * s + bob, 1.15 * s, 0, 7); c.fill(); c.beginPath(); c.arc(x + 2.4 * s, y - 15 * s + bob, 1.15 * s, 0, 7); c.fill(); } else { c.beginPath(); c.arc(x + ex, y - 15 * s + bob, 1.15 * s, 0, 7); c.fill(); } c.strokeStyle = 'rgba(160,90,70,.7)'; c.beginPath(); c.arc(x + ex * .4, y - 12.6 * s + bob, 1.6 * s, .15 * Math.PI, .85 * Math.PI); c.stroke(); } }; W2D.drawPlayer = function (c, now) { const robe = G.player.gender === 'f' ? '#8a3f5d' : '#3f5d8a'; W2D.figure(c, W2D.px, W2D.py, 1, robe, now, W2D.moving, G.player.gender === 'f' ? '#3a2a22' : '#171310', W2D.dir); // flowing headband ribbon c.save(); c.strokeStyle = 'rgba(216,179,106,.9)'; c.lineWidth = 2; const fx = W2D.dir === 1 ? -1 : 1; c.beginPath(); c.moveTo(W2D.px + fx * 5, W2D.py - 26 + Math.sin(now / 200) * 1.5); c.quadraticCurveTo(W2D.px + fx * 16, W2D.py - 30 + Math.sin(now / 170) * 3, W2D.px + fx * 24, W2D.py - 24 + Math.sin(now / 140) * 4); c.stroke(); c.lineWidth = 1; // sword on back c.save(); c.translate(W2D.px + 9, W2D.py - 6); c.rotate(-0.65); c.fillStyle = '#453729'; c.fillRect(-2, -3, 20, 4); // scabbard c.fillStyle = '#aab4bd'; c.fillRect(14, -2.4, 9, 2.8); // exposed blade c.fillStyle = '#d8b36a'; c.fillRect(-4, -4, 3, 6); // guard c.restore(); }; W2D.drawNpc = function (c, e, now) { const n = NPCS[e.id]; W2D.figure(c, e.x, e.y, 1, n.hue || '#5d6e8a', now, false, n.gender === 'f' ? '#33241e' : '#181310', 2); c.font = '12px serif'; c.textAlign = 'center'; const label = n.name; const w = c.measureText(label).width + 12; c.fillStyle = 'rgba(10,13,18,.72)'; c.beginPath(); if (c.roundRect) c.roundRect(e.x - w / 2, e.y - 42, w, 17, 5); else c.rect(e.x - w / 2, e.y - 42, w, 17); c.fill(); c.strokeStyle = 'rgba(216,179,106,.45)'; c.stroke(); c.fillStyle = '#efe6cf'; c.fillText(label, e.x, e.y - 30); }; W2D.drawRoam = function (c, e, now) { const en = ENEMIES[e.id]; const near = Math.hypot(W2D.px - e.x, W2D.py - e.y) < 100; const bob = Math.sin(now / 220 + e.gx) * 2.5; c.fillStyle = 'rgba(0,0,0,.3)'; c.beginPath(); c.ellipse(e.x, e.y + 12, 11, 4, 0, 0, 7); c.fill(); if (near) { // aggro telegraph c.strokeStyle = `rgba(220,80,60,${.5 + Math.sin(now / 120) * .3})`; c.lineWidth = 2; c.beginPath(); c.arc(e.x, e.y - 4, 17, 0, 7); c.stroke(); c.lineWidth = 1; } // body w/ outline c.fillStyle = en.color || '#7a3b30'; c.strokeStyle = 'rgba(0,0,0,.55)'; c.beginPath(); c.arc(e.x, e.y - 4 + bob, 13, 0, 7); c.fill(); c.stroke(); c.fillStyle = 'rgba(255,255,255,.14)'; c.beginPath(); c.arc(e.x - 4, e.y - 9 + bob, 6, 0, 7); c.fill(); c.fillStyle = '#f2e9d4'; c.font = 'bold 14px serif'; c.textAlign = 'center'; c.fillText(en.glyph, e.x, e.y + bob + 1); // hp bar const hpPct = Math.min(1, (e.hp || 30) / 30); c.fillStyle = 'rgba(0,0,0,.6)'; c.fillRect(e.x - 13, e.y - 23 + bob, 26, 4); c.fillStyle = hpPct > .5 ? '#7dc25c' : hpPct > .25 ? '#d8b36a' : '#c94f42'; c.fillRect(e.x - 12, e.y - 22 + bob, 24 * hpPct, 2); }; /* ---------------- ambient particles ---------------- */ W2D.initParts = function () { const st = W2D.grid.style; let mode = null; if (['village', 'temple', 'bamboo', 'valley'].includes(st)) mode = 'petal'; if (st === 'market' || st === 'tomb') mode = 'ember'; if (st === 'sect' || st === 'mountain') mode = 'leaf'; W2D.partMode = mode; W2D.parts = []; W2D._partT = 0; }; W2D.updateParts = function (dt, now) { if (!W2D.partMode) return; W2D._partT -= dt; if (W2D._partT <= 0 && W2D.parts.length < 46) { W2D._partT = 130 + Math.random() * 260; const m = W2D.partMode; const p = { x: W2D.cam.x + Math.random() * W2D.VW, y: m === 'ember' ? W2D.cam.y + W2D.VH + 10 : W2D.cam.y - 10, vx: m === 'petal' ? -14 - Math.random() * 16 : m === 'leaf' ? -20 - Math.random() * 14 : (Math.random() * 10 - 5), vy: m === 'ember' ? -26 - Math.random() * 22 : 22 + Math.random() * 20, rot: Math.random() * 6.28, vr: (Math.random() - .5) * 4, life: 1, decay: .12 + Math.random() * .1, ph: Math.random() * 6.28 }; W2D.parts.push(p); } for (const p of W2D.parts) { p.life -= p.decay * dt / 1000; p.x += p.vx * dt / 100; p.y += p.vy * dt / 100; p.rot += p.vr * dt / 1000; if (W2D.partMode !== 'ember') p.x += Math.sin(now / 700 + p.ph) * .5; } W2D.parts = W2D.parts.filter(p => p.life > 0); }; W2D.drawParts = function (c, now) { for (const p of W2D.parts) { c.save(); c.translate(p.x, p.y); c.globalAlpha = Math.max(0, Math.min(1, p.life)); if (W2D.partMode === 'petal') { c.rotate(p.rot); c.fillStyle = '#d98aa8'; c.beginPath(); c.ellipse(0, 0, 4, 2.4, 0, 0, 7); c.fill(); c.fillStyle = 'rgba(255,255,255,.35)'; c.beginPath(); c.ellipse(-1, -.6, 1.8, 1, 0, 0, 7); c.fill(); } else if (W2D.partMode === 'leaf') { c.rotate(p.rot); c.fillStyle = '#7fa050'; c.beginPath(); c.ellipse(0, 0, 4.6, 2, 0, 0, 7); c.fill(); } else { // ember const g2 = c.createRadialGradient(0, 0, 0, 0, 0, 5); g2.addColorStop(0, 'rgba(245,180,90,.95)'); g2.addColorStop(1, 'rgba(245,120,40,0)'); c.fillStyle = g2; c.beginPath(); c.arc(0, 0, 5, 0, 7); c.fill(); } c.restore(); } c.globalAlpha = 1; }; /* vignette overlay (call inside translated context, before banner) */ W2D.drawVignette = function (c) { const vg = c.createRadialGradient(W2D.VW / 2, W2D.VH / 2, W2D.VH * .38, W2D.VW / 2, W2D.VH / 2, W2D.VH * .82); vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(4,6,12,.42)'); c.fillStyle = vg; 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; const cv = document.createElement('canvas'); cv.width = grid.w * T; cv.height = grid.h * T; const c = cv.getContext('2d'); const COLS = { 0: '#354c33', 1: '#5d5142', 2: '#1c3a52', 3: '#274a33', 4: '#6e6152', 5: '#7a3b30', 6: '#354c33', 7: '#4d565f', 8: '#6e5236', 9: '#5c5648', 10: '#3c4048', 11: '#4a4030', 12: '#3c4048', 13: '#3c4048', 14: '#4a505a', 15: '#3c4048', 16: '#3c4048', 17: '#3c4048', 18: '#3c4048', 19: '#1c3a52', 20: '#31472f' }; for (let y = 0; y < grid.h; y++) for (let x = 0; x < grid.w; x++) { c.fillStyle = COLS[grid.g[y][x]] || '#354c33'; c.fillRect(x * T, y * T, T, T); } return cv; }; /* expose for smoke tests */ if (typeof module !== 'undefined' && module.exports) module.exports = W2D;