Spaces:
Sleeping
Sleeping
| // TinyWorld — canvas isometric town: futuristic buildings, park, fountain, | |
| // steering-physics characters that spread to in-character destinations. | |
| // Data in via hidden textareas: #tw-world (board+cast, hot-swappable for map | |
| // switching) and #tw-reactions (per-throw, polled). | |
| (function () { | |
| function $(s) { return document.querySelector(s); } | |
| function txt(s) { var e = $(s + ' textarea') || $(s + ' input'); return e ? e.value : ''; } | |
| function micBanner() { | |
| if (document.getElementById('tw-mic-warn')) return; | |
| if (location.hostname === '0.0.0.0' || (location.protocol !== 'https:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1')) { | |
| var b = document.createElement('div'); b.id = 'tw-mic-warn'; b.className = 'tw-mic-warn'; | |
| var port = location.port ? ':' + location.port : ''; | |
| b.innerHTML = '⚠ For the microphone, open <b>http://localhost' + port + '</b> — voice input is blocked on this address (' + location.hostname + '). Typing works everywhere.'; | |
| document.body.appendChild(b); | |
| } | |
| } | |
| function boot() { | |
| var canvas = document.getElementById('tw-canvas'); | |
| if (!canvas) return setTimeout(boot, 200); | |
| micBanner(); | |
| if (window.__TW) return; | |
| window.__TW = engine(canvas); | |
| window.__TW.start(); | |
| } | |
| function engine(canvas) { | |
| var ctx = canvas.getContext('2d'); | |
| var DPR = Math.min(window.devicePixelRatio || 1, 2); | |
| var TS = 64, TH = 32; | |
| var S = { W: null, raw: '', chars: [], tiles: {}, scale: 1, ox: 0, oy: 0, waves: [], lastTs: 0, crisis: null, crisisTs: 0 }; | |
| function shade(hex, f) { | |
| var n = parseInt(hex.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; | |
| r = Math.min(255, r * f); g = Math.min(255, g * f); b = Math.min(255, b * f); | |
| return 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ')'; | |
| } | |
| function iso(gx, gy) { return { x: S.ox + (gx - gy) * (TS / 2) * S.scale, y: S.oy + (gx + gy) * (TH / 2) * S.scale }; } | |
| function loadWorld(W) { | |
| S.W = W; S.tiles = {}; | |
| (W.roads.cols || []).forEach(function (c) { for (var r = 0; r < W.rows; r++) S.tiles[c + ',' + r] = 'road'; }); | |
| (W.roads.rows || []).forEach(function (r) { for (var c = 0; c < W.cols; c++) S.tiles[c + ',' + r] = 'road'; }); | |
| (W.plaza || []).forEach(function (p) { S.tiles[p[0] + ',' + p[1]] = 'plaza'; }); | |
| S.chars = W.cast.map(function (c) { | |
| return { | |
| name: c.name, short: c.short, emoji: c.emoji, color: c.color, | |
| x: c.home[0], y: c.home[1], vx: 0, vy: 0, tx: c.home[0], ty: c.home[1], home: c.home.slice(), | |
| moodEmoji: '', bubble: null, bubbleAt: 0, speaking: false, activity: '', vehicle: '', running: false, | |
| idle: 1 + Math.random() * 4, bob: Math.random() * 6, returnAt: 0 | |
| }; | |
| }); | |
| S.spots = (W.hotspots ? Object.keys(W.hotspots).map(function (k) { return W.hotspots[k]; }) : []); | |
| // ---- physics: tiles nobody can walk into (buildings, water, tree trunks) ---- | |
| S.blocked = {}; | |
| function block(gx, gy) { S.blocked[Math.round(gx) + ',' + Math.round(gy)] = 1; } | |
| W.buildings.forEach(function (b) { for (var bx = b.gx; bx < b.gx + b.w; bx++) for (var by = b.gy; by < b.gy + b.d; by++) block(bx, by); }); | |
| (W.props || []).forEach(function (p) { | |
| if (p.type === 'pond') { for (var px = 0; px < (p.w || 2); px++) for (var py = 0; py < (p.d || 2); py++) block(p.gx + px, p.gy + py); } | |
| else if (p.type === 'fountain') { block(p.gx, p.gy); block(p.gx - 0.5, p.gy - 0.5); block(p.gx + 0.5, p.gy + 0.5); } | |
| }); | |
| (W.trees || []).forEach(function (t) { block(t[0], t[1]); }); | |
| // never trap a character: a home/spawn tile stays walkable even if it overlaps scenery | |
| S.chars.forEach(function (c) { delete S.blocked[Math.round(c.home[0]) + ',' + Math.round(c.home[1])]; }); | |
| // seats people can amble over to and rest at, so the town isn't a permanent jog | |
| S.seats = []; | |
| (W.props || []).forEach(function (p) { if (p.type === 'bench' || p.type === 'patio' || p.type === 'table') S.seats.push([Math.round(p.gx), Math.round(p.gy) + 1]); }); | |
| // ambient crowd — pedestrians, a cyclist, dogs that bring the town to life | |
| var SHIRTS = ['#c85a54', '#4a7bc8', '#caa23f', '#5aa86a', '#8a6fc0', '#c86fa0', '#3f9c9c', '#b5703f']; | |
| var HAIR = ['#2a2018', '#4a3526', '#6b6b6b', '#161616', '#5a3a22', '#3a2a40', '#7a6a55']; | |
| var n = (W.ambient != null) ? W.ambient : 6; | |
| S.amb = []; | |
| for (var i = 0; i < n; i++) { | |
| var t = randWalkable(); | |
| var type = (i % 6 === 0) ? 'dog' : ((i % 6 === 1) ? 'bike' : 'ped'); | |
| S.amb.push({ | |
| type: type, x: t[0], y: t[1], vx: 0, vy: 0, tx: t[0], ty: t[1], | |
| sp: (type === 'bike' ? 1.0 : 0.5) + Math.random() * 0.4, | |
| idle: Math.random() * 3, ph: Math.random() * 6, face: 1, | |
| shirt: SHIRTS[i % SHIRTS.length], hair: HAIR[i % HAIR.length], | |
| sc: (type === 'dog' ? 0.85 : 1.1), | |
| follow: type === 'dog' ? Math.floor(Math.random() * S.chars.length) : -1 | |
| }); | |
| } | |
| // cars are off unless a world opts in (W.traffic), and they cruise slowly when on | |
| var CARC = ['#d24b4b', '#3f6fb0', '#d6b13f', '#4a9c6a', '#8a8f9c'], ci = 0; | |
| S.cars = []; | |
| if (W.traffic) { | |
| (W.roads.rows || []).forEach(function (r) { S.cars.push({ axis: 'h', line: r, pos: Math.random() * W.cols, dir: Math.random() < 0.5 ? 1 : -1, sp: 0.5 + Math.random() * 0.3, color: CARC[ci++ % CARC.length] }); }); | |
| (W.roads.cols || []).forEach(function (c) { S.cars.push({ axis: 'v', line: c, pos: Math.random() * W.rows, dir: Math.random() < 0.5 ? 1 : -1, sp: 0.5 + Math.random() * 0.3, color: CARC[ci++ % CARC.length] }); }); | |
| } | |
| S.waves = []; | |
| fit(); | |
| } | |
| function randWalkable() { | |
| for (var k = 0; k < 30; k++) { var gx = Math.floor(Math.random() * S.W.cols), gy = Math.floor(Math.random() * S.W.rows); if (walkable(gx, gy)) return [gx, gy]; } | |
| return [S.W.cols >> 1, S.W.rows >> 1]; | |
| } | |
| function fit() { | |
| if (!S.W) return; | |
| var rect = canvas.getBoundingClientRect(); | |
| canvas.width = Math.max(1, rect.width * DPR); canvas.height = Math.max(1, rect.height * DPR); | |
| var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9; | |
| for (var gy = 0; gy <= S.W.rows; gy++) for (var gx = 0; gx <= S.W.cols; gx++) { | |
| var x = (gx - gy) * TS / 2, y = (gx + gy) * TH / 2; | |
| if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; | |
| } | |
| var bw = maxX - minX, bh = (maxY - minY) + 130; | |
| S.scale = Math.min(rect.width / bw, rect.height / bh) * 1.06; | |
| S.ox = rect.width / 2 - ((minX + maxX) / 2) * S.scale; | |
| S.oy = rect.height / 2 - ((minY + maxY) / 2) * S.scale + 18 * S.scale; | |
| } | |
| window.addEventListener('resize', fit); | |
| function walkable(gx, gy) { | |
| gx = Math.round(gx); gy = Math.round(gy); | |
| if (gx < 0 || gy < 0 || gx >= S.W.cols || gy >= S.W.rows) return false; | |
| return !(S.blocked && S.blocked[gx + ',' + gy]); | |
| } | |
| // ---------- tiles ---------- | |
| function drawTile(gx, gy) { | |
| var t = S.tiles[gx + ',' + gy] || 'grass'; | |
| var a = iso(gx, gy), b = iso(gx + 1, gy), c = iso(gx + 1, gy + 1), d = iso(gx, gy + 1); | |
| ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.closePath(); | |
| var fill = ((gx + gy) % 2 === 0) ? '#1e6b3e' : '#1a6038'; | |
| if (t === 'road') fill = ((gx + gy) % 2 === 0) ? '#33384c' : '#2e3344'; | |
| else if (t === 'plaza') fill = '#46415f'; | |
| ctx.fillStyle = fill; ctx.fill(); | |
| ctx.strokeStyle = 'rgba(120,200,255,0.07)'; ctx.lineWidth = 1; ctx.stroke(); | |
| if (t === 'road') { | |
| ctx.strokeStyle = 'rgba(255,215,106,0.45)'; ctx.lineWidth = Math.max(1, 1.6 * S.scale); | |
| ctx.setLineDash([5 * S.scale, 5 * S.scale]); ctx.beginPath(); | |
| ctx.moveTo((a.x + d.x) / 2, (a.y + d.y) / 2); ctx.lineTo((b.x + c.x) / 2, (b.y + c.y) / 2); | |
| ctx.stroke(); ctx.setLineDash([]); | |
| } | |
| } | |
| // ---------- buildings ---------- | |
| function corners(b, H) { | |
| var bt = iso(b.gx, b.gy), br = iso(b.gx + b.w, b.gy), bb = iso(b.gx + b.w, b.gy + b.d), bl = iso(b.gx, b.gy + b.d); | |
| return { bt: bt, br: br, bb: bb, bl: bl, tt: u(bt, H), tr: u(br, H), tb: u(bb, H), tl: u(bl, H) }; | |
| function u(p, h) { return { x: p.x, y: p.y - h }; } | |
| } | |
| function quad(p0, p1, p2, p3, fill) { ctx.fillStyle = fill; ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.lineTo(p3.x, p3.y); ctx.closePath(); ctx.fill(); } | |
| // p0=base-near, p1=base-far, p2=top-far, p3=top-near | |
| function onQuad(p0, p1, p2, p3, u, v) { | |
| var ax = p0.x + (p1.x - p0.x) * u, ay = p0.y + (p1.y - p0.y) * u; | |
| var bx = p3.x + (p2.x - p3.x) * u, by = p3.y + (p2.y - p3.y) * u; | |
| return { x: ax + (bx - ax) * v, y: ay + (by - ay) * v }; | |
| } | |
| function windowGrid(p0, p1, p2, p3, cols, rows, f) { | |
| var ws = 3.4 * S.scale; | |
| for (var i = 1; i <= cols; i++) for (var j = 1; j <= rows; j++) { | |
| var q = onQuad(p0, p1, p2, p3, i / (cols + 1), j / (rows + 1)); | |
| var lit = ((i * 7 + j * 3) % 4 !== 0); | |
| ctx.fillStyle = 'rgba(10,14,26,0.85)'; ctx.fillRect(q.x - ws, q.y - ws, ws * 2, ws * 2); | |
| ctx.fillStyle = lit ? 'rgba(255,232,150,' + (0.78 * f) + ')' : 'rgba(120,160,210,' + (0.4 * f) + ')'; | |
| ctx.fillRect(q.x - ws * 0.7, q.y - ws * 0.7, ws * 1.4, ws * 1.4); | |
| } | |
| } | |
| function door(p0, p1, p2, p3, col) { | |
| var b = onQuad(p0, p1, p2, p3, 0.5, 0.02), t = onQuad(p0, p1, p2, p3, 0.5, 0.22); | |
| var w = 4 * S.scale; | |
| ctx.fillStyle = shade(col, 0.5); | |
| ctx.beginPath(); ctx.moveTo(b.x - w, b.y); ctx.lineTo(b.x + w, b.y); ctx.lineTo(t.x + w, t.y); ctx.lineTo(t.x - w, t.y); ctx.closePath(); ctx.fill(); | |
| } | |
| function drawBuilding(b, now) { | |
| var H = b.h * S.scale, kind = b.kind || 'block'; | |
| var c = corners(b, H); | |
| // ground shadow | |
| quad(c.bt, c.br, { x: c.bb.x + 7, y: c.bb.y + 7 }, c.bl, 'rgba(0,0,0,0.30)'); | |
| // walls | |
| quad(c.bl, c.bb, c.tb, c.tl, shade(b.wall, 0.7)); // left | |
| quad(c.br, c.bb, c.tb, c.tr, shade(b.wall, 1.0)); // right | |
| var floors = Math.max(2, Math.round(b.h / 16)); | |
| windowGrid(c.bl, c.bb, c.tb, c.tl, Math.max(2, b.d), floors, 0.7); | |
| windowGrid(c.br, c.bb, c.tb, c.tr, Math.max(2, b.w), floors, 1.0); | |
| // subtle edge highlight | |
| ctx.strokeStyle = shade(b.roof, 1.0); ctx.lineWidth = 1.2 * S.scale; | |
| ctx.beginPath(); ctx.moveTo(c.bb.x, c.bb.y); ctx.lineTo(c.tb.x, c.tb.y); ctx.stroke(); | |
| // roof / crown | |
| ctx.save(); ctx.shadowColor = b.roof; ctx.shadowBlur = 7 * S.scale; | |
| if (kind === 'dome') { | |
| var cx = (c.tt.x + c.tb.x) / 2, cy = (c.tt.y + c.tb.y) / 2; | |
| var rw = Math.abs(c.tr.x - c.tl.x) / 2, rh = Math.abs(c.tb.y - c.tt.y) / 2 + 14 * S.scale; | |
| quad(c.tt, c.tr, c.tb, c.tl, shade(b.roof, 0.85)); | |
| var g = ctx.createLinearGradient(cx, cy - rh, cx, cy); g.addColorStop(0, shade(b.roof, 1.3)); g.addColorStop(1, b.roof); | |
| ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(cx, cy, rw, rh, 0, Math.PI, 2 * Math.PI); ctx.fill(); | |
| } else if (kind === 'tower') { | |
| quad(c.tt, c.tr, c.tb, c.tl, b.roof); | |
| // antenna + pulsing beacon | |
| var apex = { x: (c.tt.x + c.tb.x) / 2, y: (c.tt.y + c.tb.y) / 2 }; | |
| ctx.strokeStyle = shade(b.roof, 1.2); ctx.lineWidth = 1.6 * S.scale; | |
| ctx.beginPath(); ctx.moveTo(apex.x, apex.y); ctx.lineTo(apex.x, apex.y - 22 * S.scale); ctx.stroke(); | |
| var pulse = 0.5 + 0.5 * Math.sin(now / 350); | |
| ctx.fillStyle = 'rgba(56,232,255,' + pulse + ')'; | |
| ctx.beginPath(); ctx.arc(apex.x, apex.y - 22 * S.scale, 3.2 * S.scale, 0, 7); ctx.fill(); | |
| } else if (kind === 'house') { // residential — pitched gable roof, no neon | |
| ctx.shadowBlur = 0; | |
| var rh = 13 * S.scale; | |
| function upR(p) { return { x: p.x, y: p.y - rh }; } | |
| var rA = upR({ x: (c.tt.x + c.tl.x) / 2, y: (c.tt.y + c.tl.y) / 2 }); | |
| var rB = upR({ x: (c.tr.x + c.tb.x) / 2, y: (c.tr.y + c.tb.y) / 2 }); | |
| quad(c.tt, c.tr, rB, rA, shade(b.roof, 1.05)); // sunlit slope | |
| quad(c.tl, c.tb, rB, rA, shade(b.roof, 0.74)); // shaded slope | |
| tri(c.tt, c.tl, rA, shade(b.wall, 1.08)); // gable end | |
| tri(c.tr, c.tb, rB, shade(b.wall, 0.92)); // gable end | |
| ctx.strokeStyle = shade(b.roof, 1.2); ctx.lineWidth = 1.4 * S.scale; | |
| ctx.beginPath(); ctx.moveTo(rA.x, rA.y); ctx.lineTo(rB.x, rB.y); ctx.stroke(); // ridge | |
| } else { // block — setback crown | |
| quad(c.tt, c.tr, c.tb, c.tl, b.roof); | |
| var inH = 16 * S.scale; | |
| var sb = { gx: b.gx + b.w * 0.25, gy: b.gy + b.d * 0.25, w: b.w * 0.5, d: b.d * 0.5 }; | |
| var cc = corners(sb, H + inH); | |
| // shift base of crown up to roof level | |
| var lift = H; | |
| function L(p) { return { x: p.x, y: p.y }; } | |
| var cb = corners(sb, 0); | |
| var ct = corners(sb, inH); | |
| function up(p) { return { x: p.x, y: p.y - lift }; } | |
| quad(up(cb.bl), up(cb.bb), up(ct.tb), up(ct.tl), shade(b.wall, 0.8)); | |
| quad(up(cb.br), up(cb.bb), up(ct.tb), up(ct.tr), shade(b.wall, 1.05)); | |
| quad(up(ct.tt), up(ct.tr), up(ct.tb), up(ct.tl), shade(b.roof, 1.1)); | |
| } | |
| ctx.restore(); | |
| // ground-floor storefront only for shops/civic buildings — houses just get a door | |
| if (kind === 'house') { door(c.br, c.bb, c.tb, c.tr, b.wall); houseSign(b, c); } | |
| else drawStorefront(b, c); | |
| } | |
| function tri(a, b, d, fill) { ctx.fillStyle = fill; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(d.x, d.y); ctx.closePath(); ctx.fill(); } | |
| // small house number / family plate above the door (not a glowing holo sign) | |
| function houseSign(b, c) { | |
| if (!b.label) return; | |
| var p = onQuad(c.br, c.bb, c.tb, c.tr, 0.5, 0.34); | |
| ctx.font = '600 ' + (7 * S.scale) + 'px Rajdhani, sans-serif'; | |
| var tw = ctx.measureText(b.label).width + 8 * S.scale; | |
| ctx.fillStyle = 'rgba(18,14,10,0.8)'; rr(p.x - tw / 2, p.y - 6 * S.scale, tw, 11 * S.scale, 2 * S.scale); ctx.fill(); | |
| ctx.fillStyle = '#e8dcc0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(b.label, p.x, p.y); | |
| } | |
| // street-level retail on the most-visible (right) wall: p0=br p1=bb p2=tb p3=tr | |
| function drawStorefront(b, c) { | |
| var p0 = c.br, p1 = c.bb, p2 = c.tb, p3 = c.tr, accent = b.roof; | |
| // big glowing shop windows along the ground floor | |
| var cols = Math.max(2, Math.round(b.w * 1.6)); | |
| for (var i = 1; i <= cols; i++) { | |
| var wt = onQuad(p0, p1, p2, p3, i / (cols + 1), 0.175), wb = onQuad(p0, p1, p2, p3, i / (cols + 1), 0.03); | |
| var ww = 5 * S.scale, wh = wb.y - wt.y; | |
| ctx.fillStyle = 'rgba(6,10,20,0.92)'; ctx.fillRect(wt.x - ww, wt.y, ww * 2, wh); | |
| ctx.fillStyle = 'rgba(255,222,158,0.92)'; ctx.fillRect(wt.x - ww * 0.78, wt.y + 1.2 * S.scale, ww * 1.56, wh - 2.4 * S.scale); | |
| ctx.fillStyle = 'rgba(255,255,255,0.18)'; ctx.fillRect(wt.x - ww * 0.78, wt.y + 1.2 * S.scale, ww * 0.5, wh - 2.4 * S.scale); | |
| } | |
| door(p0, p1, p2, p3, b.wall); | |
| // awning: a coloured strip that juts toward the street with a scalloped valance | |
| var al = onQuad(p0, p1, p2, p3, 0.05, 0.205), ar = onQuad(p0, p1, p2, p3, 0.95, 0.205), jut = 9 * S.scale, seg = 8; | |
| function lerp(t) { return { x: al.x + (ar.x - al.x) * t, y: al.y + (ar.y - al.y) * t }; } | |
| for (var k = 0; k < seg; k++) { | |
| var a0 = lerp(k / seg), a1 = lerp((k + 1) / seg); | |
| ctx.fillStyle = (k % 2) ? shade(accent, 1.25) : shade(accent, 0.92); | |
| ctx.beginPath(); ctx.moveTo(a0.x, a0.y); ctx.lineTo(a1.x, a1.y); ctx.lineTo(a1.x, a1.y + jut); ctx.lineTo(a0.x, a0.y + jut); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = shade(accent, 0.78); | |
| ctx.beginPath(); ctx.moveTo(a0.x, a0.y + jut); ctx.lineTo(a1.x, a1.y + jut); ctx.lineTo((a0.x + a1.x) / 2, (a0.y + a1.y) / 2 + jut + 4 * S.scale); ctx.closePath(); ctx.fill(); | |
| } | |
| // shop sign mounted on the façade above the awning (replaces the old floating word) | |
| if (b.label) { | |
| var sc = onQuad(p0, p1, p2, p3, 0.5, 0.40); | |
| ctx.font = '700 ' + (8.5 * S.scale) + 'px Orbitron, sans-serif'; | |
| var tw = ctx.measureText(b.label).width + 12 * S.scale, th = 14 * S.scale; | |
| ctx.fillStyle = 'rgba(6,10,20,0.94)'; rr(sc.x - tw / 2, sc.y - th / 2, tw, th, 3 * S.scale); ctx.fill(); | |
| ctx.strokeStyle = shade(accent, 1.2); ctx.lineWidth = 1.3 * S.scale; ctx.stroke(); | |
| ctx.save(); ctx.shadowColor = accent; ctx.shadowBlur = 6 * S.scale; | |
| ctx.fillStyle = shade(accent, 1.3); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(b.label, sc.x, sc.y); | |
| ctx.restore(); | |
| } | |
| } | |
| // ---------- props ---------- | |
| function drawProp(p, now) { | |
| if (p.type === 'fountain') { | |
| var c = iso(p.gx, p.gy); | |
| ctx.fillStyle = 'rgba(40,60,90,0.9)'; ctx.beginPath(); ctx.ellipse(c.x, c.y, 22 * S.scale, 11 * S.scale, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = '#2bc7ff'; ctx.beginPath(); ctx.ellipse(c.x, c.y, 18 * S.scale, 8 * S.scale, 0, 0, 7); ctx.fill(); | |
| for (var k = 0; k < 3; k++) { | |
| var t = (now / 700 + k / 3) % 1, rr2 = 4 + t * 14; | |
| ctx.strokeStyle = 'rgba(160,235,255,' + (0.6 * (1 - t)) + ')'; ctx.lineWidth = 1.4 * S.scale; | |
| ctx.beginPath(); ctx.ellipse(c.x, c.y, rr2 * S.scale, rr2 * 0.5 * S.scale, 0, 0, 7); ctx.stroke(); | |
| } | |
| ctx.strokeStyle = 'rgba(180,240,255,0.8)'; ctx.lineWidth = 2 * S.scale; | |
| ctx.beginPath(); ctx.moveTo(c.x, c.y - 4 * S.scale); ctx.lineTo(c.x, c.y - 16 * S.scale); ctx.stroke(); | |
| } else if (p.type === 'pond') { | |
| var a = iso(p.gx, p.gy), b = iso(p.gx + (p.w || 2), p.gy), d = iso(p.gx + (p.w || 2), p.gy + (p.d || 2)), e = iso(p.gx, p.gy + (p.d || 2)); | |
| var g = ctx.createLinearGradient(a.x, a.y, d.x, d.y); g.addColorStop(0, '#1f6fa8'); g.addColorStop(1, '#0e4a78'); | |
| ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(d.x, d.y); ctx.lineTo(e.x, e.y); ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle = 'rgba(160,220,255,0.4)'; ctx.lineWidth = 1; ctx.stroke(); | |
| } else if (p.type === 'bench') { | |
| var c2 = iso(p.gx + 0.5, p.gy + 0.5); | |
| ctx.fillStyle = '#6a4a2e'; ctx.fillRect(c2.x - 8 * S.scale, c2.y - 4 * S.scale, 16 * S.scale, 4 * S.scale); | |
| } else if (p.type === 'lamp') { | |
| var c3 = iso(p.gx + 0.5, p.gy + 0.5); | |
| ctx.strokeStyle = '#556'; ctx.lineWidth = 2 * S.scale; ctx.beginPath(); ctx.moveTo(c3.x, c3.y); ctx.lineTo(c3.x, c3.y - 22 * S.scale); ctx.stroke(); | |
| var rg = ctx.createRadialGradient(c3.x, c3.y - 22 * S.scale, 1, c3.x, c3.y - 22 * S.scale, 12 * S.scale); | |
| rg.addColorStop(0, 'rgba(255,235,150,0.9)'); rg.addColorStop(1, 'rgba(255,235,150,0)'); | |
| ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(c3.x, c3.y - 22 * S.scale, 12 * S.scale, 0, 7); ctx.fill(); | |
| } else if (p.type === 'patio') { | |
| var c4 = iso(p.gx + 0.5, p.gy + 0.5); | |
| ctx.fillStyle = '#d65b5b'; ctx.beginPath(); ctx.moveTo(c4.x, c4.y - 16 * S.scale); ctx.lineTo(c4.x - 11 * S.scale, c4.y - 7 * S.scale); ctx.lineTo(c4.x + 11 * S.scale, c4.y - 7 * S.scale); ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle = '#888'; ctx.lineWidth = 1.5 * S.scale; ctx.beginPath(); ctx.moveTo(c4.x, c4.y - 7 * S.scale); ctx.lineTo(c4.x, c4.y); ctx.stroke(); | |
| } else if (p.type === 'table') { // café table with chairs + parasol | |
| var ct = iso(p.gx + 0.5, p.gy + 0.5), s = S.scale; | |
| [[-9, 1], [9, 1], [0, -5], [0, 6]].forEach(function (o) { | |
| ctx.fillStyle = '#39425a'; ctx.beginPath(); ctx.ellipse(ct.x + o[0] * s, ct.y + o[1] * s * 0.6, 2.8 * s, 1.6 * s, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = '#2a3146'; ctx.fillRect(ct.x + o[0] * s - 2.4 * s, ct.y + o[1] * s * 0.6 - 5 * s, 4.8 * s, 5 * s); | |
| }); | |
| ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(ct.x, ct.y + 2 * s, 8 * s, 3.5 * s, 0, 0, 7); ctx.fill(); | |
| ctx.strokeStyle = '#7a8398'; ctx.lineWidth = 1.6 * s; ctx.beginPath(); ctx.moveTo(ct.x, ct.y - 1 * s); ctx.lineTo(ct.x, ct.y + 3 * s); ctx.stroke(); | |
| ctx.fillStyle = '#cdd6ea'; ctx.beginPath(); ctx.ellipse(ct.x, ct.y - 3 * s, 7 * s, 3.2 * s, 0, 0, 7); ctx.fill(); | |
| ctx.strokeStyle = '#8a93a8'; ctx.lineWidth = 1.4 * s; ctx.beginPath(); ctx.moveTo(ct.x, ct.y - 4 * s); ctx.lineTo(ct.x, ct.y - 17 * s); ctx.stroke(); | |
| ctx.fillStyle = '#d65b5b'; ctx.beginPath(); ctx.moveTo(ct.x, ct.y - 22 * s); ctx.lineTo(ct.x - 11 * s, ct.y - 15 * s); ctx.lineTo(ct.x + 11 * s, ct.y - 15 * s); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#b94a4a'; for (var pz = 0; pz < 3; pz += 2) { ctx.beginPath(); ctx.moveTo(ct.x - 11 * s + pz * 7.3 * s, ct.y - 15 * s); ctx.lineTo(ct.x - 3.7 * s + pz * 7.3 * s, ct.y - 15 * s); ctx.lineTo(ct.x - 7.3 * s + pz * 7.3 * s, ct.y - 18.5 * s); ctx.closePath(); ctx.fill(); } | |
| } else if (p.type === 'stall') { // market stall: counter, goods, striped canopy | |
| var cs = iso(p.gx + 0.5, p.gy + 0.5), s2 = S.scale, col = p.color || '#caa23f'; | |
| ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(cs.x, cs.y + 2 * s2, 15 * s2, 4 * s2, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = '#5a4632'; ctx.fillRect(cs.x - 13 * s2, cs.y - 6 * s2, 26 * s2, 8 * s2); | |
| ctx.fillStyle = '#48372a'; ctx.fillRect(cs.x - 13 * s2, cs.y - 6 * s2, 26 * s2, 2 * s2); | |
| ['#e5663e', '#e0b53f', '#5aa86a', '#d6543f'].forEach(function (gc, gi) { ctx.fillStyle = gc; ctx.beginPath(); ctx.arc(cs.x - 9 * s2 + gi * 6 * s2, cs.y - 7 * s2, 2.6 * s2, 0, 7); ctx.fill(); }); | |
| ctx.strokeStyle = '#6a5a48'; ctx.lineWidth = 1.8 * s2; ctx.beginPath(); | |
| ctx.moveTo(cs.x - 13 * s2, cs.y - 6 * s2); ctx.lineTo(cs.x - 13 * s2, cs.y - 22 * s2); | |
| ctx.moveTo(cs.x + 13 * s2, cs.y - 6 * s2); ctx.lineTo(cs.x + 13 * s2, cs.y - 22 * s2); ctx.stroke(); | |
| for (var q = 0; q < 7; q++) { ctx.fillStyle = (q % 2) ? col : shade(col, 1.28); ctx.fillRect(cs.x - 13 * s2 + q * (26 / 7) * s2, cs.y - 25 * s2, (26 / 7) * s2 + 0.5, 5 * s2); } | |
| ctx.fillStyle = shade(col, 0.85); for (var v = 0; v < 7; v++) { var vx = cs.x - 13 * s2 + (v + 0.5) * (26 / 7) * s2; ctx.beginPath(); ctx.moveTo(vx - (13 / 7) * s2, cs.y - 20 * s2); ctx.lineTo(vx + (13 / 7) * s2, cs.y - 20 * s2); ctx.lineTo(vx, cs.y - 17 * s2); ctx.closePath(); ctx.fill(); } | |
| } else if (p.type === 'planter') { // raised flower bed | |
| var cp = iso(p.gx + 0.5, p.gy + 0.5), s3 = S.scale; | |
| ctx.fillStyle = '#5a4632'; ctx.fillRect(cp.x - 8 * s3, cp.y - 3 * s3, 16 * s3, 5 * s3); | |
| ctx.fillStyle = '#48372a'; ctx.fillRect(cp.x - 8 * s3, cp.y - 3 * s3, 16 * s3, 1.6 * s3); | |
| var fc = ['#f06ea0', '#ffd76a', '#7CFFB2', '#d65b5b']; | |
| for (var f = 0; f < 5; f++) { ctx.fillStyle = '#2f8f55'; ctx.fillRect(cp.x - 6 * s3 + f * 3 * s3, cp.y - 7 * s3, 1.4 * s3, 4 * s3); ctx.fillStyle = fc[f % fc.length]; ctx.beginPath(); ctx.arc(cp.x - 5.3 * s3 + f * 3 * s3, cp.y - 8 * s3, 1.8 * s3, 0, 7); ctx.fill(); } | |
| } | |
| } | |
| function drawTree(t) { | |
| var p = iso(t[0] + 0.5, t[1] + 0.5); | |
| ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.beginPath(); ctx.ellipse(p.x, p.y, 10 * S.scale, 5 * S.scale, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = '#4a2e1a'; ctx.fillRect(p.x - 2 * S.scale, p.y - 13 * S.scale, 4 * S.scale, 13 * S.scale); | |
| var g = ctx.createRadialGradient(p.x - 4 * S.scale, p.y - 22 * S.scale, 2, p.x, p.y - 18 * S.scale, 15 * S.scale); | |
| g.addColorStop(0, '#5fe0a0'); g.addColorStop(1, '#1c7a4a'); ctx.fillStyle = g; | |
| ctx.beginPath(); ctx.arc(p.x, p.y - 18 * S.scale, 12 * S.scale, 0, 7); ctx.fill(); | |
| } | |
| // ---------- sprite primitives ---------- | |
| // a little walking person; returns head-top y for bubble/mood anchoring | |
| function person(x, y, sc, shirt, hair, ph, moving) { | |
| var s = S.scale * sc; | |
| ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(x, y, 7 * s, 2.8 * s, 0, 0, 7); ctx.fill(); | |
| var sw = moving ? Math.sin(ph) * 3 * s : 0.6 * s; | |
| var lift = moving ? Math.abs(Math.cos(ph)) * 1.1 * s : 0; | |
| ctx.lineCap = 'round'; | |
| // legs | |
| ctx.strokeStyle = '#34384e'; ctx.lineWidth = 2.6 * s; | |
| ctx.beginPath(); ctx.moveTo(x, y - 8 * s); ctx.lineTo(x - 2.4 * s + sw, y); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(x, y - 8 * s); ctx.lineTo(x + 2.4 * s - sw, y); ctx.stroke(); | |
| var ty = y - 8 * s - lift; | |
| // arms (behind torso) | |
| ctx.strokeStyle = shade(shirt, 0.82); ctx.lineWidth = 2.3 * s; | |
| ctx.beginPath(); ctx.moveTo(x - 3.5 * s, ty - 8 * s); ctx.lineTo(x - 6 * s - sw * 0.5, ty - 1.5 * s); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(x + 3.5 * s, ty - 8 * s); ctx.lineTo(x + 6 * s + sw * 0.5, ty - 1.5 * s); ctx.stroke(); | |
| // torso | |
| ctx.fillStyle = shirt; rr(x - 4.6 * s, ty - 12 * s, 9.2 * s, 13 * s, 3 * s); ctx.fill(); | |
| // head | |
| var hcy = ty - 16 * s; | |
| ctx.fillStyle = '#e7b893'; ctx.beginPath(); ctx.arc(x, hcy, 4.6 * s, 0, 7); ctx.fill(); | |
| ctx.fillStyle = hair; ctx.beginPath(); ctx.arc(x, hcy - 1.4 * s, 4.7 * s, Math.PI, 2 * Math.PI); ctx.fill(); | |
| return hcy - 5 * s; | |
| } | |
| function dog(x, y, sc, ph, moving) { | |
| var s = S.scale * sc, wag = moving ? Math.sin(ph * 1.4) * 2 * s : 0; | |
| ctx.fillStyle = 'rgba(0,0,0,0.26)'; ctx.beginPath(); ctx.ellipse(x, y, 8 * s, 3 * s, 0, 0, 7); ctx.fill(); | |
| ctx.fillStyle = '#8a6a45'; rr(x - 7 * s, y - 8 * s, 12 * s, 6 * s, 3 * s); ctx.fill(); // body | |
| ctx.strokeStyle = '#6a4f33'; ctx.lineWidth = 1.8 * s; // legs | |
| ctx.beginPath(); ctx.moveTo(x - 5 * s, y - 3 * s); ctx.lineTo(x - 5 * s, y); ctx.moveTo(x + 3 * s, y - 3 * s); ctx.lineTo(x + 3 * s, y); ctx.stroke(); | |
| ctx.fillStyle = '#8a6a45'; ctx.beginPath(); ctx.arc(x + 6 * s, y - 9 * s, 3.4 * s, 0, 7); ctx.fill(); // head | |
| ctx.strokeStyle = '#8a6a45'; ctx.lineWidth = 2 * s; ctx.beginPath(); ctx.moveTo(x - 7 * s, y - 7 * s); ctx.lineTo(x - 10 * s - wag, y - 9 * s); ctx.stroke(); // tail | |
| } | |
| function wheel(x, y, r, ph) { | |
| ctx.strokeStyle = '#1a1d28'; ctx.lineWidth = 2.2 * S.scale; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.stroke(); | |
| ctx.strokeStyle = 'rgba(200,210,230,0.5)'; ctx.lineWidth = 1 * S.scale; | |
| for (var a = 0; a < 3; a++) { var an = ph + a * 2.1; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(an) * r, y + Math.sin(an) * r); ctx.stroke(); } | |
| } | |
| function bike(x, y, ph, moving, shirt) { | |
| var s = S.scale; | |
| ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(x, y, 12 * s, 3 * s, 0, 0, 7); ctx.fill(); | |
| var spin = moving ? ph * 2 : 0; | |
| wheel(x - 7 * s, y - 4 * s, 4.6 * s, spin); wheel(x + 7 * s, y - 4 * s, 4.6 * s, spin); | |
| ctx.strokeStyle = '#cfd6ea'; ctx.lineWidth = 1.8 * s; // frame | |
| ctx.beginPath(); ctx.moveTo(x - 7 * s, y - 4 * s); ctx.lineTo(x, y - 4 * s); ctx.lineTo(x + 7 * s, y - 4 * s); ctx.moveTo(x, y - 4 * s); ctx.lineTo(x + 2 * s, y - 12 * s); ctx.stroke(); | |
| // rider | |
| var rsw = moving ? Math.sin(ph) * 2 * s : 0; | |
| ctx.fillStyle = shirt; rr(x - 2.5 * s, y - 19 * s, 7 * s, 9 * s, 2.5 * s); ctx.fill(); | |
| ctx.fillStyle = '#e7b893'; ctx.beginPath(); ctx.arc(x + 1 * s, y - 21 * s, 3.6 * s, 0, 7); ctx.fill(); | |
| ctx.strokeStyle = shade(shirt, 0.8); ctx.lineWidth = 1.8 * s; | |
| ctx.beginPath(); ctx.moveTo(x + 1 * s, y - 15 * s); ctx.lineTo(x + 7 * s, y - 8 * s); ctx.stroke(); // arm to bars | |
| ctx.beginPath(); ctx.moveTo(x + 1 * s, y - 11 * s); ctx.lineTo(x - 1 * s + rsw, y - 4 * s); ctx.stroke(); // leg pedaling | |
| return y - 26 * s; | |
| } | |
| // ---------- ambient crowd ---------- | |
| function stepAmbient(a, dt) { | |
| if (a.follow >= 0 && S.chars[a.follow]) { var f = S.chars[a.follow]; a.tx = f.x + 0.7; a.ty = f.y + 0.7; } | |
| var dx = a.tx - a.x, dy = a.ty - a.y, d = Math.hypot(dx, dy); | |
| if (d > 0.05) { | |
| var sp = a.sp * Math.min(1, d / 1.0); | |
| a.vx += ((dx / d) * sp - a.vx) * Math.min(1, dt * 4); | |
| a.vy += ((dy / d) * sp - a.vy) * Math.min(1, dt * 4); | |
| var nx = a.x + a.vx * dt, ny = a.y + a.vy * dt; | |
| if (walkable(nx, a.y)) a.x = nx; else { a.vx = 0; a.tx = a.x; } | |
| if (walkable(a.x, ny)) a.y = ny; else { a.vy = 0; a.ty = a.y; } | |
| a.ph += dt * 6; | |
| } else if (a.follow < 0) { | |
| a.idle -= dt; | |
| if (a.idle <= 0) { var t = randWalkable(); a.tx = t[0]; a.ty = t[1]; a.idle = 2.5 + Math.random() * 5; } | |
| } | |
| } | |
| function drawAmbient(a) { | |
| var p = iso(a.x + 0.5, a.y + 0.5), mv = Math.hypot(a.vx, a.vy) > 0.08; | |
| if (a.type === 'dog') dog(p.x, p.y, a.sc, a.ph, mv); | |
| else if (a.type === 'bike') bike(p.x, p.y, a.ph, mv, a.shirt); | |
| else person(p.x, p.y, a.sc, a.shirt, a.hair, a.ph, mv); | |
| } | |
| function stepCar(c, dt) { | |
| var max = (c.axis === 'h') ? S.W.cols : S.W.rows; c.pos += c.dir * c.sp * dt; | |
| if (c.pos < 0) { c.pos = 0; c.dir = 1; } if (c.pos > max) { c.pos = max; c.dir = -1; } | |
| } | |
| function drawCar(c) { | |
| var gx = (c.axis === 'h') ? c.pos : c.line + 0.5, gy = (c.axis === 'h') ? c.line + 0.5 : c.pos, p = iso(gx, gy); | |
| var s = S.scale, fwd = c.dir > 0 ? 1 : -1; | |
| ctx.fillStyle = 'rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.ellipse(p.x, p.y, 13 * s, 4.5 * s, 0, 0, 7); ctx.fill(); | |
| // wheels | |
| ctx.fillStyle = '#15171f'; ctx.beginPath(); ctx.arc(p.x - 7 * s, p.y - 2 * s, 2.6 * s, 0, 7); ctx.arc(p.x + 7 * s, p.y - 2 * s, 2.6 * s, 0, 7); ctx.fill(); | |
| // body | |
| var g = ctx.createLinearGradient(p.x, p.y - 13 * s, p.x, p.y - 2 * s); g.addColorStop(0, shade(c.color, 1.15)); g.addColorStop(1, c.color); | |
| ctx.fillStyle = g; rr(p.x - 11 * s, p.y - 9 * s, 22 * s, 8 * s, 3 * s); ctx.fill(); | |
| // cabin | |
| ctx.fillStyle = shade(c.color, 1.05); rr(p.x - 5 * s, p.y - 14 * s, 11 * s, 6 * s, 2.5 * s); ctx.fill(); | |
| ctx.fillStyle = 'rgba(180,225,255,0.9)'; rr(p.x - 3.5 * s, p.y - 13 * s, 8 * s, 4 * s, 1.5 * s); ctx.fill(); | |
| // lights | |
| ctx.fillStyle = 'rgba(255,240,180,0.95)'; ctx.beginPath(); ctx.arc(p.x + 10 * s * fwd, p.y - 6 * s, 1.6 * s, 0, 7); ctx.fill(); | |
| ctx.fillStyle = 'rgba(255,90,90,0.9)'; ctx.beginPath(); ctx.arc(p.x - 10 * s * fwd, p.y - 6 * s, 1.4 * s, 0, 7); ctx.fill(); | |
| } | |
| // ---------- characters ---------- | |
| function drawChar(c, now) { | |
| var p = iso(c.x + 0.5, c.y + 0.5); | |
| var moving = Math.hypot(c.vx, c.vy) > 0.08; | |
| if (c.speaking) { | |
| var pr = 1 + Math.sin(now / 220) * 0.12; | |
| ctx.strokeStyle = 'rgba(56,232,255,0.9)'; ctx.lineWidth = 2.4 * S.scale; | |
| ctx.beginPath(); ctx.ellipse(p.x, p.y, 13 * S.scale * pr, 5.5 * S.scale * pr, 0, 0, 7); ctx.stroke(); | |
| } | |
| var hy = (c.vehicle === 'bike') ? bike(p.x, p.y, c.bob, moving, c.color) | |
| : person(p.x, p.y, 1.35, c.color, '#241c14', c.bob, moving); | |
| if (c.moodEmoji) { ctx.font = (12 * S.scale) + 'px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(c.moodEmoji, p.x + 9 * S.scale, hy + 3 * S.scale); } | |
| var label = c.short + (c.activity ? ' ' + c.activity : ''); | |
| ctx.font = '600 ' + (9.5 * S.scale) + 'px Rajdhani, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; | |
| var nw = ctx.measureText(label).width + 12 * S.scale; | |
| ctx.fillStyle = 'rgba(8,12,24,0.8)'; rr(p.x - nw / 2, p.y + 3 * S.scale, nw, 13 * S.scale, 5 * S.scale); ctx.fill(); | |
| ctx.strokeStyle = c.color; ctx.lineWidth = 1; ctx.stroke(); | |
| ctx.fillStyle = '#eaf0ff'; ctx.fillText(label, p.x, p.y + 9.5 * S.scale); | |
| if (c.bubble) drawBubble(c, p.x, hy, now); | |
| } | |
| function drawBubble(c, x, topY, now) { | |
| var shown = Math.min(c.bubble.length, Math.floor((now - c.bubbleAt) / 22)); | |
| var s = c.bubble.slice(0, shown) || ' '; | |
| ctx.font = '600 ' + (12 * S.scale) + 'px Rajdhani, sans-serif'; | |
| var maxW = 180 * S.scale, lines = wrap(s, maxW), lh = 15 * S.scale, pad = 8 * S.scale; | |
| var bw = 0; lines.forEach(function (l) { bw = Math.max(bw, ctx.measureText(l).width); }); | |
| bw += pad * 2; var bh = lines.length * lh + pad * 2; | |
| var bx = x - bw / 2, by = topY - 14 * S.scale - bh; | |
| ctx.fillStyle = 'rgba(12,18,34,0.93)'; rr(bx, by, bw, bh, 8 * S.scale); ctx.fill(); | |
| ctx.strokeStyle = 'rgba(120,200,255,0.4)'; ctx.lineWidth = 1; ctx.stroke(); | |
| ctx.fillStyle = 'rgba(12,18,34,0.93)'; ctx.beginPath(); ctx.moveTo(x - 6 * S.scale, by + bh); ctx.lineTo(x + 6 * S.scale, by + bh); ctx.lineTo(x, by + bh + 8 * S.scale); ctx.closePath(); ctx.fill(); | |
| ctx.fillStyle = '#eaf0ff'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; | |
| lines.forEach(function (l, i) { ctx.fillText(l, bx + pad, by + pad + i * lh); }); | |
| ctx.textAlign = 'center'; | |
| } | |
| function wrap(s, maxW) { | |
| var w = s.split(' '), out = [], cur = ''; | |
| for (var i = 0; i < w.length; i++) { var t = cur ? cur + ' ' + w[i] : w[i]; if (ctx.measureText(t).width > maxW && cur) { out.push(cur); cur = w[i]; } else cur = t; } | |
| if (cur) out.push(cur); return out.slice(0, 4); | |
| } | |
| function rr(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } | |
| // ---------- physics ---------- | |
| function step(c, dt, now) { | |
| var dx = c.tx - c.x, dy = c.ty - c.y, dist = Math.hypot(dx, dy); | |
| if (dist > 0.04) { | |
| var maxV = (c.vehicle === 'bike') ? 1.9 : (c.running ? 1.3 : 0.85), arrive = 1.1; | |
| var sp = maxV * Math.min(1, dist / arrive); | |
| c.vx += ((dx / dist) * sp - c.vx) * Math.min(1, dt * 5); | |
| c.vy += ((dy / dist) * sp - c.vy) * Math.min(1, dt * 5); | |
| // resolve per-axis so people slide along walls and never walk into buildings or the pool | |
| var nx = c.x + c.vx * dt, ny = c.y + c.vy * dt; | |
| if (walkable(nx, c.y)) c.x = nx; else c.vx = 0; | |
| if (walkable(c.x, ny)) c.y = ny; else c.vy = 0; | |
| c.bob += dt * 6; | |
| } else { c.x = c.tx; c.y = c.ty; c.vx *= 0.8; c.vy *= 0.8; } | |
| if (c.speaking && now - c.bubbleAt > 7500) { c.speaking = false; c.bubble = null; c.returnAt = now + 800; } | |
| if (!c.speaking && c.returnAt && now > c.returnAt) { c.tx = c.home[0]; c.ty = c.home[1]; c.returnAt = 0; c.activity = ''; c.vehicle = ''; c.running = false; } | |
| if (!c.speaking && !c.returnAt) { | |
| c.idle -= dt; | |
| if (c.idle <= 0 && Math.hypot(c.tx - c.x, c.ty - c.y) < 0.1) { | |
| // a calm town: mostly stand and watch, sometimes rest at a seat, rarely amble a short way | |
| var roll = Math.random(); | |
| if (roll < 0.5) { // linger where they are | |
| c.idle = 5 + Math.random() * 7; | |
| } else if (roll < 0.78 && S.seats && S.seats.length) { // go sit down for a while | |
| var st = S.seats[Math.floor(Math.random() * S.seats.length)]; | |
| if (walkable(st[0], st[1])) { c.tx = st[0]; c.ty = st[1]; } | |
| c.idle = 7 + Math.random() * 6; | |
| } else { // gentle amble to a nearby tile | |
| for (var k = 0; k < 8; k++) { var ax = Math.round(c.x + (Math.random() * 4 - 2)), ay = Math.round(c.y + (Math.random() * 4 - 2)); if (walkable(ax, ay)) { c.tx = ax; c.ty = ay; break; } } | |
| c.idle = 4 + Math.random() * 5; | |
| } | |
| c.running = false; // never run while idle — only when actually reacting to an event | |
| } | |
| } | |
| } | |
| // ---------- crisis overlay ---------- | |
| function drawCrisis(now, rect) { | |
| var cr = S.crisis; | |
| if (!cr || !cr.active) return; | |
| var t = now / 1000, s = S.scale; | |
| var has = cr.tile && S.W; | |
| var c = has ? iso(cr.tile[0] + 0.5, cr.tile[1] + 0.5) : { x: rect.width / 2, y: rect.height / 2 }; | |
| var kind = cr.kind || 'fire'; | |
| if (has && kind === 'fire') { | |
| var glow = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, 64 * s); | |
| glow.addColorStop(0, 'rgba(255,150,50,0.5)'); glow.addColorStop(1, 'rgba(255,80,0,0)'); | |
| ctx.fillStyle = glow; ctx.beginPath(); ctx.ellipse(c.x, c.y, 64 * s, 32 * s, 0, 0, 7); ctx.fill(); | |
| for (var i = 0; i < 5; i++) { | |
| var fx = c.x + Math.sin(t * 4 + i) * 6 * s, base = c.y - 2 * s, h = (18 + Math.sin(t * 9 + i * 2) * 7) * s; | |
| var fg = ctx.createLinearGradient(fx, base, fx, base - h); | |
| fg.addColorStop(0, 'rgba(255,210,80,0.95)'); fg.addColorStop(0.6, 'rgba(255,110,20,0.85)'); fg.addColorStop(1, 'rgba(200,40,0,0)'); | |
| ctx.fillStyle = fg; ctx.beginPath(); ctx.moveTo(fx - 5 * s, base); | |
| ctx.quadraticCurveTo(fx - 3 * s, base - h * 0.6, fx, base - h); | |
| ctx.quadraticCurveTo(fx + 3 * s, base - h * 0.6, fx + 5 * s, base); ctx.closePath(); ctx.fill(); | |
| } | |
| for (var k = 0; k < 4; k++) { | |
| var pt = (t * 0.5 + k * 0.25) % 1, sx = c.x + Math.sin(t + k) * 10 * s, sy = c.y - 20 * s - pt * 40 * s; | |
| ctx.fillStyle = 'rgba(70,70,80,' + (0.35 * (1 - pt)) + ')'; | |
| ctx.beginPath(); ctx.arc(sx, sy, (6 + pt * 10) * s, 0, 7); ctx.fill(); | |
| } | |
| } else if (has && kind === 'storm') { | |
| ctx.fillStyle = 'rgba(20,30,60,0.22)'; ctx.fillRect(0, 0, rect.width, rect.height); | |
| ctx.strokeStyle = 'rgba(150,200,255,0.5)'; ctx.lineWidth = 1 * s; | |
| for (var r = 0; r < 40; r++) { | |
| var rx = c.x + (Math.random() * 120 - 60) * s, ry = c.y + (Math.random() * 80 - 60) * s; | |
| ctx.beginPath(); ctx.moveTo(rx, ry); ctx.lineTo(rx - 2 * s, ry + 8 * s); ctx.stroke(); | |
| } | |
| } else if (kind === 'blackout') { | |
| var fl = 0.5 + Math.sin(t * 18) * 0.06; | |
| var v = ctx.createRadialGradient(c.x, c.y, 24 * s, c.x, c.y, Math.max(rect.width, rect.height) * 0.8); | |
| v.addColorStop(0, 'rgba(0,0,12,0.08)'); v.addColorStop(1, 'rgba(0,0,12,' + fl + ')'); | |
| ctx.fillStyle = v; ctx.fillRect(0, 0, rect.width, rect.height); | |
| } else if (has && kind === 'search') { | |
| var pr = (t % 1.2) / 1.2; | |
| ctx.strokeStyle = 'rgba(120,220,255,' + (0.7 * (1 - pr)) + ')'; ctx.lineWidth = 2 * s; | |
| ctx.beginPath(); ctx.ellipse(c.x, c.y, (10 + pr * 42) * s, (5 + pr * 21) * s, 0, 0, 7); ctx.stroke(); | |
| } | |
| if (cr.chaos > 0.01) { | |
| var cx = rect.width / 2, cy = rect.height / 2; | |
| var cv = ctx.createRadialGradient(cx, cy, rect.height * 0.3, cx, cy, rect.height * 0.78); | |
| cv.addColorStop(0, 'rgba(255,40,20,0)'); cv.addColorStop(1, 'rgba(255,30,10,' + (0.38 * cr.chaos) + ')'); | |
| ctx.fillStyle = cv; ctx.fillRect(0, 0, rect.width, rect.height); | |
| } | |
| } | |
| // ---------- loop ---------- | |
| var last = performance.now(); | |
| function frame(now) { | |
| var dt = Math.min(0.05, (now - last) / 1000); last = now; | |
| var rect = canvas.getBoundingClientRect(); | |
| ctx.setTransform(DPR, 0, 0, DPR, 0, 0); | |
| var sky = ctx.createLinearGradient(0, 0, 0, rect.height); | |
| sky.addColorStop(0, '#161038'); sky.addColorStop(0.45, '#2a1c52'); sky.addColorStop(1, '#0b1e16'); | |
| ctx.fillStyle = sky; ctx.fillRect(0, 0, rect.width, rect.height); | |
| if (!S.W) { requestAnimationFrame(frame); return; } | |
| for (var s = 0; s <= (S.W.cols + S.W.rows); s++) for (var gx = 0; gx < S.W.cols; gx++) { var gy = s - gx; if (gy < 0 || gy >= S.W.rows) continue; drawTile(gx, gy); } | |
| S.chars.forEach(function (c) { step(c, dt, now); }); | |
| (S.amb || []).forEach(function (a) { stepAmbient(a, dt); }); | |
| (S.cars || []).forEach(function (c) { stepCar(c, dt); }); | |
| var ents = []; | |
| (S.W.props || []).forEach(function (p) { var k = (p.gy + (p.d || 1)) + (p.gx + (p.w || 1)); ents.push({ d: k - 0.6, f: function () { drawProp(p, now); } }); }); | |
| S.W.buildings.forEach(function (b) { ents.push({ d: (b.gx + b.gy) + (b.w + b.d) - 0.5, f: function () { drawBuilding(b, now); } }); }); | |
| (S.W.trees || []).forEach(function (t) { ents.push({ d: t[0] + t[1] + 0.4, f: function () { drawTree(t); } }); }); | |
| (S.cars || []).forEach(function (c) { var gx = (c.axis === 'h') ? c.pos : c.line + 0.5, gy = (c.axis === 'h') ? c.line + 0.5 : c.pos; ents.push({ d: gx + gy + 0.45, f: function () { drawCar(c); } }); }); | |
| (S.amb || []).forEach(function (a) { ents.push({ d: a.x + a.y + 0.45, f: function () { drawAmbient(a); } }); }); | |
| S.chars.forEach(function (c) { ents.push({ d: c.x + c.y + 0.5, f: function () { drawChar(c, now); } }); }); | |
| ents.sort(function (a, b) { return a.d - b.d; }); ents.forEach(function (e) { e.f(); }); | |
| for (var i = S.waves.length - 1; i >= 0; i--) { | |
| var w = S.waves[i]; w.t += dt; var rr2 = w.t * 560 * S.scale, al = Math.max(0, 0.7 - w.t); | |
| if (al <= 0) { S.waves.splice(i, 1); continue; } | |
| ctx.strokeStyle = 'rgba(56,232,255,' + al + ')'; ctx.lineWidth = 2.4 * S.scale; | |
| ctx.beginPath(); ctx.ellipse(w.x, w.y, rr2, rr2 * 0.5, 0, 0, 7); ctx.stroke(); | |
| } | |
| drawCrisis(now, rect); | |
| requestAnimationFrame(frame); | |
| } | |
| function applyReactions(d) { | |
| if (!d || !d.reactions || !S.W) return; | |
| if (!d.silent) { | |
| var pc = S.W.plaza_center || [S.W.cols / 2, S.W.rows / 2]; var p = iso(pc[0], pc[1]); S.waves.push({ x: p.x, y: p.y, t: 0 }); | |
| } | |
| d.reactions.forEach(function (r) { | |
| var c = S.chars.find(function (x) { return x.name === r.name; }); if (!c) return; | |
| c.moodEmoji = r.moodEmoji || c.moodEmoji || ''; | |
| if (r.text) { c.bubble = r.text; c.bubbleAt = performance.now(); c.speaking = true; c.returnAt = 0; } | |
| c.activity = r.activity || c.activity || ''; c.vehicle = r.vehicle || ''; c.running = !!r.running; | |
| if (r.target) { c.tx = r.target[0]; c.ty = r.target[1]; } | |
| }); | |
| } | |
| function poll() { | |
| var wj = txt('#tw-world'); | |
| if (wj && wj !== S.raw) { try { var W = JSON.parse(wj); S.raw = wj; loadWorld(W); } catch (e) { } } | |
| var rj = txt('#tw-reactions'); | |
| if (rj) { try { var d = JSON.parse(rj); if (d.ts && d.ts !== S.lastTs) { S.lastTs = d.ts; applyReactions(d); } } catch (e) { } } | |
| var cj = txt('#tw-crisis'); | |
| if (cj) { try { var cd = JSON.parse(cj); if (cd.ts && cd.ts !== S.crisisTs) { S.crisisTs = cd.ts; S.crisis = cd; } } catch (e) { } } | |
| } | |
| return { | |
| start: function () { window.TINYWORLD = { applyReactions: applyReactions }; requestAnimationFrame(frame); setInterval(poll, 140); poll(); } | |
| }; | |
| } | |
| if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); | |
| else boot(); | |
| })(); | |