137 lines
5.5 KiB
JavaScript
137 lines
5.5 KiB
JavaScript
// ---- math / helper utilities ----
|
|
const TAU = Math.PI * 2;
|
|
|
|
function rand(a, b) { return a + Math.random() * (b - a); }
|
|
function irand(a, b) { return Math.floor(rand(a, b + 1)); }
|
|
function choice(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
|
function chance(p) { return Math.random() < p; }
|
|
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
|
|
function lerp(a, b, t) { return a + (b - a) * t; }
|
|
function dist(x1, y1, x2, y2) { return Math.hypot(x2 - x1, y2 - y1); }
|
|
function dist2(x1, y1, x2, y2) { const dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; }
|
|
function angleTo(x1, y1, x2, y2) { return Math.atan2(y2 - y1, x2 - x1); }
|
|
function angleLerp(a, b, t) {
|
|
let d = (b - a) % TAU;
|
|
if (d > Math.PI) d -= TAU;
|
|
if (d < -Math.PI) d += TAU;
|
|
return a + d * t;
|
|
}
|
|
// distance from point to segment (for beam weapons)
|
|
function distToSegment(px, py, x1, y1, x2, y2) {
|
|
const dx = x2 - x1, dy = y2 - y1;
|
|
const l2 = dx * dx + dy * dy;
|
|
if (l2 === 0) return dist(px, py, x1, y1);
|
|
let t = ((px - x1) * dx + (py - y1) * dy) / l2;
|
|
t = clamp(t, 0, 1);
|
|
return dist(px, py, x1 + t * dx, y1 + t * dy);
|
|
}
|
|
function shuffle(arr) {
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
// Deterministic blood trim for UI panels. It is deliberately static so richer
|
|
// presentation never adds per-frame allocations or visual noise during combat.
|
|
function drawBloodTrim(ctx, x, y, w, seed, alpha) {
|
|
const oldAlpha = typeof ctx.globalAlpha === 'number' ? ctx.globalAlpha : 1;
|
|
const usable = Math.max(1, Math.floor(w - 18));
|
|
ctx.save();
|
|
ctx.globalAlpha = oldAlpha * (alpha === undefined ? 0.72 : alpha);
|
|
ctx.fillStyle = '#861020';
|
|
ctx.fillRect(x + 2, y, w - 4, 3);
|
|
for (let i = 0; i < 6; i++) {
|
|
const px = x + 9 + ((i * 47 + (seed || 0) * 29) % usable);
|
|
const r = 2 + ((i + (seed || 0)) % 3);
|
|
ctx.beginPath(); ctx.arc(px, y + 2, r, 0, TAU); ctx.fill();
|
|
if ((i + (seed || 0)) % 2 === 0) {
|
|
const drip = 4 + ((i * 7 + (seed || 0) * 3) % 13);
|
|
ctx.fillRect(px - 1, y + 2, 2, drip);
|
|
ctx.beginPath(); ctx.arc(px, y + 2 + drip, 1.6, 0, TAU); ctx.fill();
|
|
}
|
|
}
|
|
ctx.fillStyle = 'rgba(235,54,70,0.45)';
|
|
ctx.fillRect(x + 4, y, Math.max(10, w * 0.22), 1);
|
|
ctx.restore();
|
|
}
|
|
|
|
function pixelPanelPath(ctx, x, y, w, h, cut) {
|
|
const c = Math.max(0, Math.min(cut === undefined ? 7 : cut, w / 3, h / 3));
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + c, y); ctx.lineTo(x + w - c, y);
|
|
ctx.lineTo(x + w, y + c); ctx.lineTo(x + w, y + h - c);
|
|
ctx.lineTo(x + w - c, y + h); ctx.lineTo(x + c, y + h);
|
|
ctx.lineTo(x, y + h - c); ctx.lineTo(x, y + c); ctx.closePath();
|
|
}
|
|
|
|
// Shared hard-edged panel language for HUD and modal screens. Chamfered corners,
|
|
// one-pixel highlights, and square corner pins keep it modern without losing the
|
|
// authored 2D pixel aesthetic.
|
|
function drawPixelPanel(ctx, x, y, w, h, opts) {
|
|
const o = opts || {};
|
|
const cut = o.cut === undefined ? 7 : o.cut;
|
|
ctx.save();
|
|
if (o.shadow !== false) {
|
|
ctx.fillStyle = o.shadowColor || 'rgba(0,0,0,0.5)';
|
|
pixelPanelPath(ctx, x + 4, y + 5, w, h, cut); ctx.fill();
|
|
}
|
|
ctx.fillStyle = o.fill || 'rgba(10,5,8,0.88)';
|
|
pixelPanelPath(ctx, x, y, w, h, cut); ctx.fill();
|
|
ctx.strokeStyle = o.border || 'rgba(111,45,57,0.9)';
|
|
ctx.lineWidth = o.lineWidth || 1;
|
|
pixelPanelPath(ctx, x + 0.5, y + 0.5, w - 1, h - 1, Math.max(1, cut - 0.5)); ctx.stroke();
|
|
ctx.strokeStyle = o.highlight || 'rgba(255,217,186,0.08)';
|
|
ctx.beginPath(); ctx.moveTo(x + cut + 2, y + 2); ctx.lineTo(x + w - cut - 2, y + 2); ctx.stroke();
|
|
const accent = o.accent || '#8f1f2f';
|
|
ctx.fillStyle = accent;
|
|
ctx.fillRect(x + cut, y, Math.max(16, Math.min(w * 0.34, 92)), 2);
|
|
ctx.fillRect(x + 2, y + cut, 2, Math.max(12, Math.min(h * 0.32, 34)));
|
|
ctx.fillStyle = 'rgba(210,171,115,0.28)';
|
|
ctx.fillRect(x + cut, y + 5, 3, 3);
|
|
ctx.fillRect(x + w - cut - 3, y + 5, 3, 3);
|
|
ctx.fillRect(x + cut, y + h - 8, 3, 3);
|
|
ctx.fillRect(x + w - cut - 3, y + h - 8, 3, 3);
|
|
if (o.blood) drawBloodTrim(ctx, x + 2, y + 2, w - 4, o.seed || 0, o.bloodAlpha === undefined ? 0.38 : o.bloodAlpha);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawPixelBar(ctx, x, y, w, h, value, opts) {
|
|
const o = opts || {};
|
|
const k = clamp(value, 0, 1);
|
|
ctx.save();
|
|
ctx.fillStyle = o.track || '#11080c'; ctx.fillRect(x, y, w, h);
|
|
ctx.fillStyle = o.fill || '#b51d35'; ctx.fillRect(x + 2, y + 2, Math.max(0, (w - 4) * k), Math.max(1, h - 4));
|
|
if (k > 0) {
|
|
ctx.fillStyle = o.glint || 'rgba(255,210,180,0.3)';
|
|
ctx.fillRect(x + 2, y + 2, Math.max(0, (w - 4) * k), 1);
|
|
}
|
|
const segments = o.segments || 0;
|
|
if (segments > 1) {
|
|
ctx.fillStyle = o.divider || 'rgba(5,2,4,0.6)';
|
|
for (let i = 1; i < segments; i++) {
|
|
const sx = x + Math.round((w * i) / segments);
|
|
ctx.fillRect(sx, y + 1, 1, h - 2);
|
|
}
|
|
}
|
|
ctx.strokeStyle = o.border || '#55303a'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawPixelTag(ctx, text, x, y, opts) {
|
|
const o = opts || {};
|
|
ctx.save();
|
|
ctx.font = o.font || 'bold 10px monospace';
|
|
const w = o.width || Math.ceil(ctx.measureText(text).width) + 16;
|
|
const h = o.height || 22;
|
|
drawPixelPanel(ctx, x, y, w, h, {
|
|
cut: 4, shadow: false, fill: o.fill || 'rgba(15,7,10,0.86)',
|
|
border: o.border || 'rgba(101,48,58,0.8)', accent: o.accent || '#7d1a29',
|
|
});
|
|
ctx.fillStyle = o.color || '#cdbdb2'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, x + w / 2, y + h / 2 + 1);
|
|
ctx.restore();
|
|
return w;
|
|
}
|