360 lines
13 KiB
JavaScript
360 lines
13 KiB
JavaScript
// ============================================================
|
|
// THE BEACON - Canvas Combat Visualization
|
|
// Reasoning Battles: different AI strategies compete visually
|
|
// Adapted from Paperclips combat.js (boid flocking + grid combat)
|
|
// ============================================================
|
|
|
|
const Combat = (() => {
|
|
const W = 310, H = 150;
|
|
const GRID_W = 31, GRID_H = 15;
|
|
const CELL_W = W / GRID_W, CELL_H = H / GRID_H;
|
|
|
|
// Battle names (Napoleonic Wars → AI reasoning battles)
|
|
const BATTLE_NAMES = [
|
|
'The Aboukir Test', 'Austerlitz Proof', 'Waterloo Convergence',
|
|
'Trafalgar Dispatch', 'Leipzig Consensus', 'Borodino Trial',
|
|
'Jena Analysis', 'Wagram Synthesis', 'Friedland Review',
|
|
'Eylau Deduction', 'Ligny Verification', 'Quatre Bras Audit'
|
|
];
|
|
|
|
let canvas, ctx;
|
|
let probes = [], drifters = [];
|
|
let activeBattle = null;
|
|
let battleLog = [];
|
|
let animFrameId = null;
|
|
let lastTick = 0;
|
|
|
|
// Ship unit colors
|
|
const PROBE_COLOR = '#4a9eff'; // Blue = structured reasoning
|
|
const DRIFTER_COLOR = '#f44336'; // Red = adversarial testing
|
|
|
|
class Ship {
|
|
constructor(x, y, team) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.vx = (Math.random() - 0.5) * 2;
|
|
this.vy = (Math.random() - 0.5) * 2;
|
|
this.team = team;
|
|
this.alive = true;
|
|
}
|
|
|
|
update(allies, enemies, dt) {
|
|
if (!this.alive) return;
|
|
|
|
let ax = 0, ay = 0;
|
|
|
|
// Cohesion: move toward own centroid
|
|
if (allies.length > 1) {
|
|
let cx = 0, cy = 0;
|
|
for (const a of allies) { cx += a.x; cy += a.y; }
|
|
cx /= allies.length; cy /= allies.length;
|
|
ax += (cx - this.x) * 0.01;
|
|
ay += (cy - this.y) * 0.01;
|
|
}
|
|
|
|
// Aggression: move toward enemy centroid
|
|
if (enemies.length > 0) {
|
|
let ex = 0, ey = 0;
|
|
for (const e of enemies) { ex += e.x; ey += e.y; }
|
|
ex /= enemies.length; ey /= enemies.length;
|
|
ax += (ex - this.x) * 0.02;
|
|
ay += (ey - this.y) * 0.02;
|
|
}
|
|
|
|
// Separation: avoid nearby enemies
|
|
for (const e of enemies) {
|
|
const dx = this.x - e.x, dy = this.y - e.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < 15 && dist > 0) {
|
|
ax += (dx / dist) * 0.5;
|
|
ay += (dy / dist) * 0.5;
|
|
}
|
|
}
|
|
|
|
// Apply acceleration with damping
|
|
this.vx = (this.vx + ax * dt) * 0.98;
|
|
this.vy = (this.vy + ay * dt) * 0.98;
|
|
|
|
// Clamp speed
|
|
const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy);
|
|
if (speed > 3) {
|
|
this.vx = (this.vx / speed) * 3;
|
|
this.vy = (this.vy / speed) * 3;
|
|
}
|
|
|
|
this.x += this.vx;
|
|
this.y += this.vy;
|
|
|
|
// Wrap around edges
|
|
if (this.x < 0) this.x += W;
|
|
if (this.x > W) this.x -= W;
|
|
if (this.y < 0) this.y += H;
|
|
if (this.y > H) this.y -= H;
|
|
}
|
|
|
|
draw(ctx) {
|
|
if (!this.alive) return;
|
|
const color = this.team === 'probe' ? PROBE_COLOR : DRIFTER_COLOR;
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(this.x - 1, this.y - 1, 2, 2);
|
|
}
|
|
}
|
|
|
|
function createShips(count, team) {
|
|
const ships = [];
|
|
const side = team === 'probe' ? 0.2 : 0.8;
|
|
for (let i = 0; i < count; i++) {
|
|
ships.push(new Ship(
|
|
W * side + (Math.random() - 0.5) * 40,
|
|
H * 0.5 + (Math.random() - 0.5) * 60,
|
|
team
|
|
));
|
|
}
|
|
return ships;
|
|
}
|
|
|
|
function resolveCombat() {
|
|
if (!activeBattle) return;
|
|
const probeCombat = activeBattle.probeCombat;
|
|
const driftCombat = activeBattle.drifterCombat;
|
|
const probeSpeed = activeBattle.probeSpeed;
|
|
|
|
// OODA Loop bonus
|
|
const deathThreshold = 0.15 + probeSpeed * 0.03;
|
|
|
|
for (const p of probes) {
|
|
if (!p.alive) continue;
|
|
// Check if near any drifter
|
|
for (const d of drifters) {
|
|
if (!d.alive) continue;
|
|
const dx = p.x - d.x, dy = p.y - d.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < 8) {
|
|
// Probe death probability
|
|
if (Math.random() < driftCombat * (drifters.filter(s => s.alive).length / Math.max(1, probes.filter(s => s.alive).length)) * deathThreshold) {
|
|
p.alive = false;
|
|
}
|
|
// Drifter death probability
|
|
if (Math.random() < (probeCombat * 0.15 + probeCombat * 0.1) * (probes.filter(s => s.alive).length / Math.max(1, drifters.filter(s => s.alive).length)) * deathThreshold) {
|
|
d.alive = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check battle end
|
|
const aliveProbes = probes.filter(s => s.alive).length;
|
|
const aliveDrifters = drifters.filter(s => s.alive).length;
|
|
|
|
if (aliveProbes === 0 || aliveDrifters === 0) {
|
|
endBattle(aliveProbes > 0 ? 'structured' : 'adversarial');
|
|
}
|
|
}
|
|
|
|
function endBattle(winner) {
|
|
if (!activeBattle) return;
|
|
const name = activeBattle.name;
|
|
const result = {
|
|
name,
|
|
winner,
|
|
probesLeft: probes.filter(s => s.alive).length,
|
|
driftersLeft: drifters.filter(s => s.alive).length,
|
|
time: Date.now()
|
|
};
|
|
battleLog.unshift(result);
|
|
if (battleLog.length > 10) battleLog.pop();
|
|
|
|
// Apply rewards
|
|
if (winner === 'structured') {
|
|
G.knowledge += 50 * (1 + G.phase * 0.5);
|
|
G.totalKnowledge += 50 * (1 + G.phase * 0.5);
|
|
log(`⚔ ${name}: Structured reasoning wins! +${fmt(50 * (1 + G.phase * 0.5))} knowledge`);
|
|
} else {
|
|
G.code += 30 * (1 + G.phase * 0.5);
|
|
G.totalCode += 30 * (1 + G.phase * 0.5);
|
|
log(`⚔ ${name}: Adversarial testing wins! +${fmt(30 * (1 + G.phase * 0.5))} code`);
|
|
}
|
|
|
|
activeBattle = null;
|
|
if (animFrameId) {
|
|
cancelAnimationFrame(animFrameId);
|
|
animFrameId = null;
|
|
}
|
|
renderCombatPanel();
|
|
}
|
|
|
|
function animate(ts) {
|
|
if (!ctx || !activeBattle) return;
|
|
const rawDt = (ts - lastTick) / 16;
|
|
// Guard against tab-switch: if tab was hidden, dt could be huge
|
|
const dt = Math.min(rawDt, 3);
|
|
lastTick = ts;
|
|
|
|
// If tab was hidden for too long (>5s), skip this frame to prevent teleporting
|
|
if (rawDt > 300) {
|
|
animFrameId = requestAnimationFrame(animate);
|
|
return;
|
|
}
|
|
|
|
// Clear
|
|
ctx.fillStyle = '#080810';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// Grid lines
|
|
ctx.strokeStyle = '#111120';
|
|
ctx.lineWidth = 0.5;
|
|
for (let x = 0; x <= GRID_W; x++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * CELL_W, 0);
|
|
ctx.lineTo(x * CELL_W, H);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y <= GRID_H; y++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * CELL_H);
|
|
ctx.lineTo(W, y * CELL_H);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Update and draw ships
|
|
const aliveProbes = probes.filter(s => s.alive);
|
|
const aliveDrifters = drifters.filter(s => s.alive);
|
|
|
|
for (const p of probes) p.update(aliveProbes, aliveDrifters, dt);
|
|
for (const d of drifters) d.update(aliveDrifters, aliveProbes, dt);
|
|
|
|
// Resolve combat every 30 frames
|
|
if (Math.floor(ts / 500) !== Math.floor((ts - 16) / 500)) {
|
|
resolveCombat();
|
|
}
|
|
|
|
for (const p of probes) p.draw(ctx);
|
|
for (const d of drifters) d.draw(ctx);
|
|
|
|
// HUD
|
|
ctx.fillStyle = '#555';
|
|
ctx.font = '9px monospace';
|
|
ctx.fillText(`Structured: ${aliveProbes.length}`, 4, 12);
|
|
ctx.fillText(`Adversarial: ${aliveDrifters.length}`, W - 80, 12);
|
|
ctx.fillText(activeBattle.name, W / 2 - 40, H - 4);
|
|
|
|
// Health bars
|
|
const probePct = aliveProbes.length / activeBattle.probeCount;
|
|
const driftPct = aliveDrifters.length / activeBattle.drifterCount;
|
|
ctx.fillStyle = '#1a2a3a';
|
|
ctx.fillRect(4, 16, 60, 4);
|
|
ctx.fillStyle = PROBE_COLOR;
|
|
ctx.fillRect(4, 16, 60 * probePct, 4);
|
|
ctx.fillStyle = '#3a1a1a';
|
|
ctx.fillRect(W - 64, 16, 60, 4);
|
|
ctx.fillStyle = DRIFTER_COLOR;
|
|
ctx.fillRect(W - 64, 16, 60 * driftPct, 4);
|
|
|
|
animFrameId = requestAnimationFrame(animate);
|
|
}
|
|
|
|
function startBattle() {
|
|
if (activeBattle) return;
|
|
if (G.phase < 3) {
|
|
showToast('Combat unlocks at Phase 3', 'info');
|
|
return;
|
|
}
|
|
|
|
const name = BATTLE_NAMES[Math.floor(Math.random() * BATTLE_NAMES.length)];
|
|
const probeCount = Math.min(200, Math.max(10, Math.floor(Math.sqrt(G.totalCode / 100))));
|
|
const drifterCount = Math.min(200, Math.max(10, Math.floor(G.drift * 2)));
|
|
|
|
activeBattle = {
|
|
name,
|
|
probeCount,
|
|
drifterCount,
|
|
probeCombat: 1 + (G.buildings.reasoning || 0) * 0.1,
|
|
drifterCombat: 1 + G.drift * 0.05,
|
|
probeSpeed: 1 + (G.buildings.optimizer || 0) * 0.05,
|
|
};
|
|
|
|
probes = createShips(probeCount, 'probe');
|
|
drifters = createShips(drifterCount, 'drifter');
|
|
|
|
log(`⚔ Battle begins: ${name} (${probeCount} vs ${drifterCount})`);
|
|
showToast(`⚔ ${name}`, 'combat', 3000);
|
|
|
|
lastTick = performance.now();
|
|
animFrameId = requestAnimationFrame(animate);
|
|
}
|
|
|
|
function renderCombatPanel() {
|
|
const container = document.getElementById('combat-panel');
|
|
if (!container) return;
|
|
|
|
if (activeBattle) {
|
|
const aliveP = probes.filter(s => s.alive).length;
|
|
const aliveD = drifters.filter(s => s.alive).length;
|
|
container.innerHTML = `
|
|
<div style="color:var(--gold);font-size:10px;margin-bottom:6px">${activeBattle.name}</div>
|
|
<div style="display:flex;justify-content:space-between;font-size:9px;margin-bottom:4px">
|
|
<span style="color:${PROBE_COLOR}">Structured: ${aliveP}</span>
|
|
<span style="color:${DRIFTER_COLOR}">Adversarial: ${aliveD}</span>
|
|
</div>
|
|
`;
|
|
} else {
|
|
let historyHtml = '';
|
|
for (const b of battleLog.slice(0, 5)) {
|
|
const wColor = b.winner === 'structured' ? PROBE_COLOR : DRIFTER_COLOR;
|
|
const wLabel = b.winner === 'structured' ? 'S' : 'A';
|
|
historyHtml += `<div style="font-size:9px;color:#555;padding:1px 0"><span style="color:${wColor}">[${wLabel}]</span> ${b.name}</div>`;
|
|
}
|
|
container.innerHTML = `
|
|
<div style="font-size:10px;color:#555;margin-bottom:6px">Reasoning Battles</div>
|
|
${historyHtml}
|
|
`;
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
canvas = document.getElementById('combat-canvas');
|
|
if (!canvas) return;
|
|
canvas.width = W;
|
|
canvas.height = H;
|
|
ctx = canvas.getContext('2d');
|
|
|
|
// Draw idle state
|
|
ctx.fillStyle = '#080810';
|
|
ctx.fillRect(0, 0, W, H);
|
|
ctx.strokeStyle = '#111120';
|
|
ctx.lineWidth = 0.5;
|
|
for (let x = 0; x <= GRID_W; x++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * CELL_W, 0);
|
|
ctx.lineTo(x * CELL_W, H);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y <= GRID_H; y++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * CELL_H);
|
|
ctx.lineTo(W, y * CELL_H);
|
|
ctx.stroke();
|
|
}
|
|
ctx.fillStyle = '#333';
|
|
ctx.font = '11px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Combat unlocks at Phase 3', W / 2, H / 2);
|
|
ctx.textAlign = 'left';
|
|
|
|
renderCombatPanel();
|
|
}
|
|
|
|
// Tick integration: auto-trigger battles periodically
|
|
function tickBattle(dt) {
|
|
if (G.phase < 3) return;
|
|
if (activeBattle) return;
|
|
// Chance increases with drift and phase
|
|
const chance = 0.001 * (1 + G.drift * 0.02) * (1 + G.phase * 0.3);
|
|
if (Math.random() < chance) {
|
|
startBattle();
|
|
}
|
|
}
|
|
|
|
return { init, startBattle, renderCombatPanel, tickBattle, cleanup: () => { if (animFrameId) { cancelAnimationFrame(animFrameId); animFrameId = null; } } };
|
|
})();
|