diff --git a/ROADMAP.md b/ROADMAP.md
index f97ace1..4e409fa 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -81,7 +81,7 @@ Autonomous development — Claude works on it daily, deploying improvements.
## Day 10 — Premium Polish
- [x] Loading screen with pixel art progress bar
- [x] Settings panel (toggle sounds, particles, animations)
-- [ ] Export office as PNG screenshot
+- [x] Export office as PNG screenshot
- [ ] Export as GIF animation (5 second loop)
## Day 11 — Social Features
diff --git a/src/app.js b/src/app.js
index 7682931..8636a10 100644
--- a/src/app.js
+++ b/src/app.js
@@ -34,13 +34,15 @@ import { launchCoffeeGame } from "./minigames/coffee.js";
import { launchWhiteboardGame } from "./minigames/whiteboard.js";
import { launchPlantGame } from "./minigames/plant.js";
import { launchJukeboxGame } from "./minigames/jukebox.js";
+import { launchFoosballGame } from "./minigames/foosball.js";
// ════════════════════════════════════════════════════════════════
// CONSTANTS
// ════════════════════════════════════════════════════════════════
let CW = 1400;
const T = 32; // tile size px
-const OX = 150; // canvas left margin
+const OX = 180; // canvas left margin (left side panel width)
+const OX_RIGHT = 160; // canvas right margin (right side panel width)
const OY = 12; // canvas top margin
let COLS = 35; // room width in tiles (mutable for wall editor)
let ROWS = 14;
@@ -7121,7 +7123,7 @@ function buildBackground() {
lg.addColorStop(0, "#fffff0");
lg.addColorStop(1, "transparent");
ctx.fillStyle = lg;
- ctx.fillRect(OX, ly, CW - OX * 2, T * 8);
+ ctx.fillRect(OX, ly, CW - OX - OX_RIGHT, T * 8);
ctx.restore();
}
@@ -12396,8 +12398,8 @@ function drawLeftPanel(ctx, tick) {
}
function drawRightPanel(ctx, tick) {
- const panelX = CW - OX + 3;
- const W = OX - 6,
+ const panelX = CW - OX_RIGHT + 3;
+ const W = OX_RIGHT - 6,
H = CH;
// Background
@@ -15386,7 +15388,7 @@ function applyWallPositions() {
if (saved.wall_zone !== undefined) ACT_ZONE_Y = Math.round(saved.wall_zone);
if (saved.wall_right !== undefined) {
COLS = Math.round(saved.wall_right);
- CW = OX + COLS * T + OX;
+ CW = OX + COLS * T + OX_RIGHT;
const cv = document.getElementById("office");
if (cv) cv.width = CW;
bgBuf.width = CW;
@@ -15711,7 +15713,7 @@ canvas.addEventListener("mousemove", (e) => {
// Live-resize canvas when dragging outer walls (no rebuild during drag)
if (w.id === "wall_right") {
COLS = Math.round(w.pos);
- CW = OX + COLS * T + OX;
+ CW = OX + COLS * T + OX_RIGHT;
canvas.width = CW;
bgBuf.width = CW;
}
@@ -15783,7 +15785,7 @@ document.addEventListener("mousemove", (e) => {
}
if (w.id === "wall_right") {
COLS = Math.round(w.pos);
- CW = OX + COLS * T + OX;
+ CW = OX + COLS * T + OX_RIGHT;
canvas.width = CW;
bgBuf.width = CW;
}
@@ -15811,7 +15813,7 @@ document.addEventListener("mouseup", (e) => {
if (w2.id === "wall_zone") ACT_ZONE_Y = Math.round(w2.pos);
if (w2.id === "wall_right") {
COLS = Math.round(w2.pos);
- CW = OX + COLS * T + OX;
+ CW = OX + COLS * T + OX_RIGHT;
canvas.width = CW;
bgBuf.width = CW;
}
@@ -15846,7 +15848,7 @@ canvas.addEventListener("mouseup", (e) => {
if (w.id === "wall_zone") ACT_ZONE_Y = Math.round(w.pos);
if (w.id === "wall_right") {
COLS = Math.round(w.pos);
- CW = OX + COLS * T + OX;
+ CW = OX + COLS * T + OX_RIGHT;
canvas.width = CW;
bgBuf.width = CW;
}
@@ -16363,6 +16365,12 @@ canvas.addEventListener("click", (e) => {
setTimeout(() => blip(659, 0.08, "sine", 0.03), 200);
return;
}
+ if (hit.type === "foosball") {
+ launchFoosballGame();
+ blip(440, 0.08, "square", 0.04);
+ setTimeout(() => blip(660, 0.06, "square", 0.03), 80);
+ return;
+ }
if (clickAnims.some((a) => a.id === hit.id)) return;
clickAnims.push({
id: hit.id,
@@ -17924,7 +17932,7 @@ document.getElementById("admin-start-pos").onclick = () => {
window._adminPos = Object.assign({}, BUILTIN_POSITIONS);
// Reset walls to default sizes
COLS = 35;
- CW = OX + COLS * T + OX;
+ CW = OX + COLS * T + OX_RIGHT;
canvas.width = CW;
bgBuf.width = CW;
generateLayout(Math.max(12, Object.keys(agentsData).length));
diff --git a/src/index.html b/src/index.html
index 0ec02f8..e30dfe2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -49,6 +49,7 @@
0 agents
+
⏳ tunnel...
diff --git a/src/main.js b/src/main.js
index de8776d..3e66c18 100644
--- a/src/main.js
+++ b/src/main.js
@@ -550,3 +550,45 @@ if (typeof window !== "undefined") {
localStorage.setItem(key, JSON.stringify(lb));
};
})();
+
+// ════════════════════════════════════════════════════════════════
+// SCREENSHOT EXPORT — capture office canvas as PNG
+// ════════════════════════════════════════════════════════════════
+(function initScreenshot() {
+ const btn = document.getElementById("btn-screenshot");
+ if (!btn) return;
+
+ btn.addEventListener("click", () => {
+ const canvas = document.getElementById("office");
+ if (!canvas) return;
+
+ // Flash effect
+ btn.textContent = "⏳";
+ btn.disabled = true;
+
+ // Use timeout so the current frame finishes rendering
+ setTimeout(() => {
+ try {
+ const dataUrl = canvas.toDataURL("image/png");
+ const link = document.createElement("a");
+ const now = new Date();
+ const stamp =
+ now.getFullYear() +
+ String(now.getMonth() + 1).padStart(2, "0") +
+ String(now.getDate()).padStart(2, "0") +
+ "_" +
+ String(now.getHours()).padStart(2, "0") +
+ String(now.getMinutes()).padStart(2, "0") +
+ String(now.getSeconds()).padStart(2, "0");
+ link.download = "agent-office_" + stamp + ".png";
+ link.href = dataUrl;
+ link.click();
+ } catch (e) {
+ console.error("[screenshot] failed:", e);
+ } finally {
+ btn.textContent = "📸 PNG";
+ btn.disabled = false;
+ }
+ }, 50);
+ });
+})();
diff --git a/src/minigames/foosball.js b/src/minigames/foosball.js
new file mode 100644
index 0000000..f910e3f
--- /dev/null
+++ b/src/minigames/foosball.js
@@ -0,0 +1,340 @@
+// ════════════════════════════════════════════════════════════════
+// FOOSBALL MINIGAME — top-down table football, no external deps
+// Triggered when user clicks the foosball table object.
+// ════════════════════════════════════════════════════════════════
+
+const GW = 360,
+ GH = 240;
+const GOAL_W = 24,
+ GOAL_H = 70;
+const PLAYER_R = 8,
+ BALL_R = 6;
+const WIN_SCORE = 5;
+const CPU_SPEED = 2.8;
+
+export function launchFoosballGame() {
+ // ── Overlay ───────────────────────────────────────────────────
+ const overlay = document.createElement("div");
+ overlay.style.cssText =
+ "position:fixed;inset:0;background:rgba(5,5,15,0.95);display:flex;" +
+ "flex-direction:column;align-items:center;justify-content:center;" +
+ "z-index:1000;font-family:'Press Start 2P',monospace;";
+
+ const title = document.createElement("div");
+ title.style.cssText =
+ "color:#e0af68;font-size:11px;margin-bottom:10px;" +
+ "text-shadow:0 0 14px #e0af68aa;letter-spacing:2px;";
+ title.textContent = "⚽ FOOSBALL";
+ overlay.appendChild(title);
+
+ const gc = document.createElement("canvas");
+ gc.width = GW;
+ gc.height = GH;
+ gc.style.cssText =
+ "border:2px solid #3a3860;border-radius:4px;display:block;" +
+ "image-rendering:pixelated;outline:none;";
+ gc.setAttribute("tabindex", "0");
+ overlay.appendChild(gc);
+ const ctx = gc.getContext("2d");
+
+ const instrEl = document.createElement("div");
+ instrEl.style.cssText =
+ "color:#a9b1d640;font-size:5px;margin-top:8px;letter-spacing:1px;";
+ instrEl.textContent = "↑↓ or W/S to move your players | ESC: exit";
+ overlay.appendChild(instrEl);
+
+ const closeBtn = document.createElement("button");
+ closeBtn.textContent = "✕ EXIT";
+ closeBtn.style.cssText =
+ "margin-top:8px;background:#2a2848;color:#f7768e;" +
+ "border:1px solid #f7768e50;padding:6px 14px;" +
+ "font-family:inherit;font-size:6px;cursor:pointer;border-radius:3px;";
+ overlay.appendChild(closeBtn);
+ document.body.appendChild(overlay);
+ gc.focus();
+
+ // ── Game state ─────────────────────────────────────────────────
+ let playerScore = 0,
+ cpuScore = 0;
+ let running = true,
+ rafId = null;
+ let flashMsg = null,
+ flashTimer = 0,
+ gameOver = false;
+ let keys = {};
+
+ // Players: user row at x=90 (3 players), cpu row at x=270 (3 players)
+ const USER_X = 90,
+ CPU_X = 270;
+ const ROW_SPACING = 55;
+ let userY = GH / 2; // center of user's row (moves as unit)
+ let cpuY = GH / 2;
+
+ function makeRow(cx, cy) {
+ return [-ROW_SPACING, 0, ROW_SPACING].map((dy) => ({ x: cx, y: cy + dy }));
+ }
+
+ function resetBall(dir) {
+ const angle = Math.random() * 0.6 - 0.3;
+ return {
+ x: GW / 2,
+ y: GH / 2,
+ vx: dir * (3.2 + Math.random() * 1.2),
+ vy: Math.sin(angle) * 2.5,
+ };
+ }
+
+ let ball = resetBall(1);
+
+ // ── Physics ────────────────────────────────────────────────────
+ function deflectByRow(rowX, rowCY, col) {
+ const PR = PLAYER_R + BALL_R;
+ for (const dy of [-ROW_SPACING, 0, ROW_SPACING]) {
+ const py = rowCY + dy;
+ const dx = ball.x - rowX,
+ ddy = ball.y - py;
+ const dist = Math.sqrt(dx * dx + ddy * ddy);
+ if (dist < PR) {
+ // Push ball away
+ const nx = dx / dist,
+ ny = ddy / dist;
+ const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
+ const boost = col === "user" ? 1.1 : 1.0;
+ ball.vx = nx * speed * boost;
+ ball.vy =
+ ny * speed * boost +
+ (col === "user"
+ ? keys["ArrowUp"]
+ ? -1
+ : keys["ArrowDown"]
+ ? 1
+ : 0
+ : 0);
+ // Separate
+ const overlap = PR - dist + 1;
+ ball.x += nx * overlap;
+ ball.y += ny * overlap;
+ // Clamp speed
+ const spd = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
+ if (spd > 7) {
+ ball.vx = (ball.vx / spd) * 7;
+ ball.vy = (ball.vy / spd) * 7;
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function update() {
+ if (gameOver || flashTimer > 0) {
+ if (flashTimer > 0) flashTimer--;
+ if (flashTimer === 0 && flashMsg) {
+ flashMsg = null;
+ if (!gameOver) ball = resetBall(cpuScore > playerScore ? 1 : -1);
+ }
+ return;
+ }
+
+ // User input
+ const MOVE = 4;
+ if (keys["ArrowUp"] || keys["w"] || keys["W"])
+ userY = Math.max(GOAL_H / 2 + 10, userY - MOVE);
+ if (keys["ArrowDown"] || keys["s"] || keys["S"])
+ userY = Math.min(GH - GOAL_H / 2 - 10, userY + MOVE);
+
+ // CPU tracks ball
+ const cpuDy = ball.y - cpuY;
+ cpuY += Math.sign(cpuDy) * Math.min(CPU_SPEED, Math.abs(cpuDy));
+ cpuY = Math.max(GOAL_H / 2 + 10, Math.min(GH - GOAL_H / 2 - 10, cpuY));
+
+ // Move ball
+ ball.x += ball.vx;
+ ball.y += ball.vy;
+
+ // Top / bottom wall bounce
+ if (ball.y - BALL_R < 0) {
+ ball.y = BALL_R;
+ ball.vy *= -1;
+ }
+ if (ball.y + BALL_R > GH) {
+ ball.y = GH - BALL_R;
+ ball.vy *= -1;
+ }
+
+ // Deflect by player rows
+ deflectByRow(USER_X, userY, "user");
+ deflectByRow(CPU_X, cpuY, "cpu");
+
+ // Goals: left edge = CPU scores, right edge = user scores
+ const goalTop = (GH - GOAL_H) / 2,
+ goalBot = (GH + GOAL_H) / 2;
+ if (ball.x - BALL_R < 0) {
+ if (ball.y >= goalTop && ball.y <= goalBot) {
+ cpuScore++;
+ flashMsg = cpuScore >= WIN_SCORE ? "CPU WINS!" : "CPU GOAL!";
+ if (cpuScore >= WIN_SCORE) gameOver = true;
+ flashTimer = 80;
+ ball.vx = 0;
+ ball.vy = 0;
+ } else {
+ ball.x = BALL_R;
+ ball.vx = Math.abs(ball.vx);
+ }
+ }
+ if (ball.x + BALL_R > GW) {
+ if (ball.y >= goalTop && ball.y <= goalBot) {
+ playerScore++;
+ if (
+ playerScore >= WIN_SCORE &&
+ typeof window.saveGameScore === "function"
+ )
+ window.saveGameScore("foosball", playerScore);
+ flashMsg = playerScore >= WIN_SCORE ? "YOU WIN!" : "GOAL!";
+ if (playerScore >= WIN_SCORE) gameOver = true;
+ flashTimer = 80;
+ ball.vx = 0;
+ ball.vy = 0;
+ } else {
+ ball.x = GW - BALL_R;
+ ball.vx = -Math.abs(ball.vx);
+ }
+ }
+ }
+
+ // ── Render ─────────────────────────────────────────────────────
+ function render() {
+ if (!running) return;
+ update();
+
+ ctx.clearRect(0, 0, GW, GH);
+
+ // Field
+ ctx.fillStyle = "#1a3a18";
+ ctx.fillRect(0, 0, GW, GH);
+
+ // Field lines
+ ctx.strokeStyle = "#2a5a28";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(GW / 2, 0);
+ ctx.lineTo(GW / 2, GH);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.arc(GW / 2, GH / 2, 30, 0, Math.PI * 2);
+ ctx.stroke();
+
+ // Goals (left = user, right = cpu)
+ const gTop = (GH - GOAL_H) / 2,
+ gBot = (GH + GOAL_H) / 2;
+ ctx.fillStyle = "#7aa2f720";
+ ctx.fillRect(0, gTop, GOAL_W, GOAL_H);
+ ctx.fillStyle = "#f7768e20";
+ ctx.fillRect(GW - GOAL_W, gTop, GOAL_W, GOAL_H);
+ ctx.strokeStyle = "#7aa2f7";
+ ctx.strokeRect(0, gTop, GOAL_W, GOAL_H);
+ ctx.strokeStyle = "#f7768e";
+ ctx.strokeRect(GW - GOAL_W, gTop, GOAL_W, GOAL_H);
+
+ // Player rows
+ const drawRow = (cx, cy, col) => {
+ for (const dy of [-ROW_SPACING, 0, ROW_SPACING]) {
+ ctx.save();
+ ctx.shadowColor = col;
+ ctx.shadowBlur = 8;
+ ctx.fillStyle = col;
+ ctx.beginPath();
+ ctx.arc(cx, cy + dy, PLAYER_R, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ ctx.fillStyle = "#000a";
+ ctx.beginPath();
+ ctx.arc(cx, cy + dy, PLAYER_R - 3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ };
+ drawRow(USER_X, userY, "#7aa2f7");
+ drawRow(CPU_X, cpuY, "#f7768e");
+
+ // Ball
+ ctx.save();
+ ctx.shadowColor = "#ffffff80";
+ ctx.shadowBlur = 10;
+ ctx.fillStyle = "#ffffff";
+ ctx.beginPath();
+ ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ ctx.fillStyle = "#cccccc";
+ ctx.fillRect(ball.x - 2, ball.y - 2, 2, 2);
+
+ // Scoreboard
+ ctx.font = "10px 'Press Start 2P',monospace";
+ ctx.textAlign = "center";
+ ctx.fillStyle = "#7aa2f7";
+ ctx.fillText(playerScore, GW / 2 - 28, 20);
+ ctx.fillStyle = "#a9b1d6";
+ ctx.fillText("-", GW / 2, 20);
+ ctx.fillStyle = "#f7768e";
+ ctx.fillText(cpuScore, GW / 2 + 28, 20);
+
+ // Flash message
+ if (flashMsg) {
+ ctx.save();
+ ctx.globalAlpha = 0.92;
+ ctx.fillStyle = "#0a0a18";
+ ctx.fillRect(GW / 2 - 70, GH / 2 - 18, 140, 32);
+ ctx.globalAlpha = 1;
+ ctx.font = "9px 'Press Start 2P',monospace";
+ ctx.fillStyle = flashMsg.includes("WIN")
+ ? "#ffd700"
+ : flashMsg === "GOAL!"
+ ? "#9ece6a"
+ : "#f7768e";
+ ctx.shadowColor = ctx.fillStyle;
+ ctx.shadowBlur = 14;
+ ctx.fillText(flashMsg, GW / 2, GH / 2 + 4);
+ ctx.restore();
+ }
+
+ if (gameOver) {
+ ctx.font = "6px 'Press Start 2P',monospace";
+ ctx.textAlign = "center";
+ ctx.fillStyle = "#a9b1d680";
+ ctx.fillText("CLICK or ESC to exit", GW / 2, GH - 10);
+ }
+
+ rafId = requestAnimationFrame(render);
+ }
+
+ // ── Input ─────────────────────────────────────────────────────
+ function onKey(e) {
+ if (e.type === "keydown") {
+ keys[e.key] = true;
+ if (e.key === "Escape") {
+ close();
+ return;
+ }
+ if ((e.key === " " || e.key === "Enter") && gameOver) close();
+ if (["ArrowUp", "ArrowDown", " "].includes(e.key)) e.preventDefault();
+ } else {
+ keys[e.key] = false;
+ }
+ }
+ gc.addEventListener("click", () => {
+ if (gameOver) close();
+ });
+ document.addEventListener("keydown", onKey);
+ document.addEventListener("keyup", onKey);
+
+ function close() {
+ running = false;
+ cancelAnimationFrame(rafId);
+ overlay.remove();
+ document.removeEventListener("keydown", onKey);
+ document.removeEventListener("keyup", onKey);
+ }
+
+ closeBtn.addEventListener("click", close);
+ rafId = requestAnimationFrame(render);
+}