1154 lines
55 KiB
JavaScript
1154 lines
55 KiB
JavaScript
/* =========================================================================
|
|
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) => `<button class="dpad-btn ${cls}" data-dk="${label}">${act}</button>`;
|
|
const el = h(`<div class="dpad">
|
|
${mk('up', 'up', '▲')}${mk('left', 'left', '◀')}${mk('e', 'E', 'E')}${mk('right', 'right', '▶')}${mk('down', 'down', '▼')}
|
|
</div>`);
|
|
host.appendChild(el);
|
|
el.querySelectorAll('[data-dk]').forEach(b => {
|
|
const k = b.dataset.dk;
|
|
const set = (v) => (ev) => { ev.preventDefault(); if (k === 'E') { if (v) W2D.interact(); } else W2D.vk[k] = v; b.classList.toggle('on', v); };
|
|
b.addEventListener('pointerdown', set(true));
|
|
b.addEventListener('pointerup', set(false));
|
|
b.addEventListener('pointerleave', set(false));
|
|
b.addEventListener('pointercancel', set(false));
|
|
});
|
|
};
|
|
|
|
W2D.uiBlocked = function () {
|
|
if (G.inBattle) return true;
|
|
if (typeof document === 'undefined') return true;
|
|
const els = document.querySelectorAll('.modal-backdrop,.dlg-backdrop');
|
|
for (const el of els) { if (el.offsetParent !== null) return true; }
|
|
return false;
|
|
};
|
|
|
|
/* ---------------- update ---------------- */
|
|
W2D.update = function (dt, now) {
|
|
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;
|
|
if (K['s'] || V.down) dy += 1;
|
|
if (K['a'] || V.left) dx -= 1;
|
|
if (K['d'] || V.right) dx += 1;
|
|
|
|
// click-to-move steering when no keys held
|
|
if (!dx && !dy && W2D.target) {
|
|
const tdx = W2D.target.x - W2D.px, tdy = W2D.target.y - W2D.py;
|
|
const td = Math.hypot(tdx, tdy);
|
|
if (td < 8) { W2D.target = null; }
|
|
else { dx = tdx / td; dy = tdy / td; }
|
|
}
|
|
if ((K['w'] || K['a'] || K['s'] || K['d'] || V.up || V.down || V.left || V.right)) { W2D.target = null; }
|
|
|
|
W2D.moving = !!(dx || dy);
|
|
if (W2D.moving) {
|
|
const len = Math.hypot(dx, dy) || 1;
|
|
const nx = W2D.px + dx / len * sp, ny = W2D.py + dy / len * sp;
|
|
const r = 8; // feet box radius
|
|
if (!W2D.blockedAt(nx + Math.sign(dx) * r, W2D.py)) W2D.px = nx;
|
|
if (!W2D.blockedAt(W2D.px, ny + Math.sign(dy) * r)) W2D.py = ny;
|
|
W2D.dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 1 : 3) : (dy > 0 ? 2 : 0);
|
|
W2D.anim += dt;
|
|
W2D.trail.push({ x: W2D.px, y: W2D.py });
|
|
if (W2D.trail.length > 40) W2D.trail.shift();
|
|
W2D.posCache[W2D.cur] = { x: W2D.px, y: W2D.py };
|
|
}
|
|
|
|
// arrived near a clicked interactable? trigger it
|
|
if (W2D.pending && W2D.pending.ent) {
|
|
const pe = W2D.pending.ent;
|
|
if (Math.hypot(pe.x - W2D.px, pe.y - W2D.py) < 46) {
|
|
const p = W2D.pending; W2D.pending = null; W2D.target = null;
|
|
if (p.kind === 'npc') W2D.openNpcMenu(pe.id);
|
|
else if (p.kind === 'spot') UI.runAction(pe.act);
|
|
else if (p.kind === 'exit') Game.travelTo(pe.to);
|
|
else if (p.kind === 'roam') { /* walking into beasts triggers battle via proximity */ }
|
|
}
|
|
}
|
|
|
|
// camera
|
|
const tx = W2D.px - W2D.VW / 2, ty = W2D.py - W2D.VH / 2;
|
|
W2D.cam.x += (tx - W2D.cam.x) * 0.12; W2D.cam.y += (ty - W2D.cam.y) * 0.12;
|
|
W2D.cam.x = Math.max(0, Math.min(W2D.grid.w * W2D.T - W2D.VW, W2D.cam.x));
|
|
W2D.cam.y = Math.max(0, Math.min(W2D.grid.h * W2D.T - W2D.VH, W2D.grid.h * W2D.T > W2D.VH ? W2D.cam.y : -(W2D.VH - W2D.grid.h * W2D.T) / 2));
|
|
|
|
// roamer AI
|
|
for (const e of W2D.ents) {
|
|
if (e.kind !== 'roam' || e.dead) continue;
|
|
if (e.immune && now < e.immune) continue;
|
|
e.cool -= dt;
|
|
if (e.cool <= 0) {
|
|
e.cool = 420 + Math.random() * 500;
|
|
const pdx = W2D.px - e.x, pdy = W2D.py - e.y;
|
|
const dist = Math.hypot(pdx, pdy);
|
|
let mx = 0, my = 0;
|
|
if (dist < 6 * W2D.T) { mx = Math.sign(pdx); my = Math.sign(pdy); }
|
|
else { const r = Math.random(); mx = r < .33 ? 1 : r < .66 ? -1 : 0; my = Math.random() < .5 ? (Math.random() < .5 ? 1 : -1) : 0; }
|
|
const nxp = (e.gx + mx) * W2D.T + 16, nyp = (e.gy + my) * W2D.T + 16;
|
|
if (!W2D.blockedAt(nxp, nyp)) { e.gx += mx; e.gy += my; e.x = nxp; e.y = nyp; }
|
|
// engage
|
|
if (dist < 30 && !G.inBattle) {
|
|
W2D.engaged = e;
|
|
e.immune = now + 4000;
|
|
const danger = LOCS[W2D.cur].danger || 1;
|
|
const ids = Math.random() < 0.3 ? [e.id, e.id] : [e.id];
|
|
Game.startBattle({ ids, scale: 0.8 + danger * 0.12, meta: { kind: 'wild' } });
|
|
}
|
|
}
|
|
}
|
|
|
|
// portal auto-travel
|
|
const near = W2D.nearest();
|
|
if (near && near.ent.kind === 'exit' && near.dist < 22) {
|
|
const d = near.ent; d.cool = d.cool || 0;
|
|
if (now - (d.last || 0) > 1500) { d.last = now; Game.travelTo(d.to); }
|
|
}
|
|
|
|
W2D.updateParts(dt, now);
|
|
};
|
|
|
|
/* ---------------- interaction ---------------- */
|
|
W2D.nearest = function () {
|
|
let best = null;
|
|
const consider = (ent, label) => {
|
|
const dist = Math.hypot(ent.x - W2D.px, ent.y - W2D.py);
|
|
if (dist < 46 && (!best || dist < best.dist)) best = { ent, dist, label };
|
|
};
|
|
for (const e of W2D.ents) {
|
|
if (e.kind === 'npc') consider(e, 'Talk');
|
|
if (e.kind === 'spot') consider(e, e.act.label);
|
|
}
|
|
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;
|
|
consider({ kind: 'exit', to: ex.to, x: ex.x * W2D.T + 16, y: ex.y * W2D.T + 16 }, 'To ' + L2.name);
|
|
}
|
|
return best;
|
|
};
|
|
|
|
W2D.interact = function () {
|
|
const near = W2D.nearest();
|
|
if (!near) return;
|
|
const e = near.ent;
|
|
if (e.kind === 'npc') { W2D.openNpcMenu(e.id); }
|
|
else if (e.kind === 'spot') { UI.runAction(e.act); }
|
|
else if (e.kind === 'exit') { Game.travelTo(e.to); }
|
|
};
|
|
|
|
W2D.openNpcMenu = function (id) {
|
|
const n = NPCS[id];
|
|
const btns = [];
|
|
if (n.dlg) btns.push({ label: '💬 Talk', primary: true, fn: () => { UI.closeModal(); UI.talk(id); } });
|
|
if (n.shop) btns.push({ label: '🛒 Trade', fn: () => { UI.closeModal(); UI.openShop(id); } });
|
|
if (n.spar !== false) btns.push({ label: '⚔️ Spar', fn: () => { UI.closeModal(); Game.spar(id); } });
|
|
btns.push({ label: '🎁 Give gift', fn: () => { UI.closeModal(); UI.openGiftPicker(id); } });
|
|
btns.push({ label: 'Leave', fn: () => UI.closeModal() });
|
|
UI.modal(`${Util.esc(n.name)} <span class="small muted">— ${Util.esc(n.title || '')}</span>`,
|
|
`<p class="small muted">${Util.esc(n.role || '')}</p>`, 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;
|