// A member app (not a built-in — it's not imported anywhere and isn't part // of the trusted web/static/apps/ registry). Parked here, not deployed: to // activate it, paste this source into App Studio (⋮ → App Studio → paste + // Deploy) on an instance. Updated for groupCapable member apps (see PR #553) // so it can be added to a group chat, not just used personally. import { defineApp, addItem, updateItem, removeItem } from "../apps.js"; const GRID_SIZE = 10; const SHIP_LENGTHS = [4, 3, 2, 2]; export const app = defineApp({ name: "battleship", title: "Battleship", glyph: "🚢", groupCapable: true, initialState: () => ({ players: [], p1: null, p2: null, p1Ships: [], p2Ships: [], p1Ready: false, p2Ready: false, currentPlayer: null, shots: [], gameOver: false, winner: null, }), events: { "battleship.player-joined": (s, e) => { if (!e.uid || typeof e.playerNumber !== "number") return s; const playerNum = e.playerNumber; if (playerNum === 1 && !s.p1) { return { ...s, p1: { uid: e.uid, name: e.name }, players: [...s.players, { uid: e.uid, playerNumber: 1 }], currentPlayer: s.p2 ? 1 : null, }; } if (playerNum === 2 && !s.p2) { return { ...s, p2: { uid: e.uid, name: e.name }, players: [...s.players, { uid: e.uid, playerNumber: 2 }], currentPlayer: s.p1 ? 1 : null, }; } return s; }, "battleship.ships-placed": (s, e) => { if (!e.uid || !Array.isArray(e.ships)) return s; if (s.p1 && s.p1.uid === e.uid) { return { ...s, p1Ships: e.ships }; } if (s.p2 && s.p2.uid === e.uid) { return { ...s, p2Ships: e.ships }; } return s; }, "battleship.ready": (s, e) => { if (!e.uid) return s; if (s.p1 && s.p1.uid === e.uid) { return { ...s, p1Ready: true }; } if (s.p2 && s.p2.uid === e.uid) { return { ...s, p2Ready: true }; } return s; }, "battleship.shot-fired": (s, e) => { if (!e.uid || typeof e.x !== "number" || typeof e.y !== "number") return s; const newShots = [ ...s.shots, { x: e.x, y: e.y, result: e.result, firedBy: e.uid, ts: e.ts }, ]; const nextPlayer = s.currentPlayer === 1 ? 2 : 1; return { ...s, shots: newShots, currentPlayer: nextPlayer, }; }, "battleship.game-over": (s, e) => { if (!e.winner) return s; return { ...s, gameOver: true, winner: e.winner }; }, }, styles: ` .battleship-container { display: flex; flex-direction: column; gap: 16px; padding: 16px; height: 100%; overflow-y: auto; } .battleship-header { text-align: center; font-size: 18px; font-weight: bold; color: var(--ink); } .battleship-status { text-align: center; font-size: 14px; color: var(--muted); } .battleship-boards { display: flex; gap: 24px; justify-content: center; } .battleship-board { display: flex; flex-direction: column; gap: 8px; } .battleship-board-title { text-align: center; font-weight: bold; font-size: 14px; color: var(--ink); } .battleship-grid { display: inline-grid; grid-template-columns: repeat(10, 24px); gap: 2px; background: var(--panel); padding: 4px; border-radius: var(--radius); } .battleship-cell { width: 24px; height: 24px; border: 1px solid var(--border); background: var(--bg); cursor: var(--ui-cursor, pointer); display: flex; align-items: center; justify-content: center; font-size: 10px; border-radius: 2px; } .battleship-cell.ship { background: var(--accent); color: var(--accent-ink); } .battleship-cell.hit { background: var(--danger); color: white; font-weight: bold; } .battleship-cell.miss { background: var(--muted); color: var(--ink); } .battleship-cell.sunk { background: var(--danger); color: white; font-weight: bold; } .battleship-cell.disabled { opacity: 0.5; cursor: not-allowed; } .battleship-cell.preview { background: rgba(0, 150, 255, 0.3); border: 2px dashed var(--accent); } .battleship-button-group { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } .battleship-button { padding: 8px 16px; background: var(--accent); color: var(--accent-ink); border: none; border-radius: var(--radius); cursor: var(--ui-cursor, pointer); font-weight: bold; font-size: 14px; } .battleship-button:disabled { opacity: 0.5; cursor: not-allowed; } .battleship-button:hover:not(:disabled) { opacity: 0.8; } .battleship-waiting { text-align: center; padding: 32px 16px; color: var(--muted); font-size: 16px; } .battleship-placement-container { display: flex; flex-direction: column; gap: 16px; align-items: center; } .battleship-ship-list { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } .battleship-ship-badge { padding: 4px 12px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); font-size: 12px; color: var(--ink); } .battleship-ship-badge.placed { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); } `, render(state, el, ctx) { const me = ctx.me; const myPlayerNum = state.p1 && state.p1.uid === me ? 1 : state.p2 && state.p2.uid === me ? 2 : null; const opponentPlayerNum = myPlayerNum === 1 ? 2 : myPlayerNum === 2 ? 1 : null; const container = document.createElement("div"); container.className = "battleship-container"; // Waiting for opponent if (!state.p1 || !state.p2) { const header = document.createElement("div"); header.className = "battleship-header"; header.textContent = "⚓ Battleship"; container.appendChild(header); if (!myPlayerNum) { const joinBtn = document.createElement("button"); joinBtn.className = "battleship-button"; joinBtn.textContent = state.p1 ? "Join as Player 2" : "Join as Player 1"; joinBtn.onclick = () => { const playerNum = state.p1 ? 2 : 1; ctx.emit({ type: "battleship.player-joined", playerNumber: playerNum }); }; const btnGroup = document.createElement("div"); btnGroup.className = "battleship-button-group"; btnGroup.appendChild(joinBtn); container.appendChild(btnGroup); } const waiting = document.createElement("div"); waiting.className = "battleship-waiting"; waiting.textContent = state.p1 && !state.p2 ? "Waiting for Player 2…" : "Waiting for Player 1…"; container.appendChild(waiting); el.appendChild(container); return; } // Both players joined const myShips = myPlayerNum === 1 ? state.p1Ships : state.p2Ships; const opponentShips = opponentPlayerNum === 1 ? state.p1Ships : state.p2Ships; const myReady = myPlayerNum === 1 ? state.p1Ready : state.p2Ready; const opponentReady = opponentPlayerNum === 1 ? state.p1Ready : state.p2Ready; // Ship placement phase if (myShips.length === 0 || !myReady) { renderShipPlacement(state, el, ctx, me, myPlayerNum, myShips, container); return; } // Game over if (state.gameOver) { renderGameOver(state, container); el.appendChild(container); return; } // Battle phase const header = document.createElement("div"); header.className = "battleship-header"; header.textContent = "⚓ Battleship"; container.appendChild(header); const status = document.createElement("div"); status.className = "battleship-status"; if (state.currentPlayer === myPlayerNum) { status.textContent = "🎯 Your turn!"; } else { status.textContent = "⏳ Opponent's turn…"; } container.appendChild(status); const boardsDiv = document.createElement("div"); boardsDiv.className = "battleship-boards"; // Own board (shows opponent's shots) const ownBoardDiv = document.createElement("div"); ownBoardDiv.className = "battleship-board"; const ownTitle = document.createElement("div"); ownTitle.className = "battleship-board-title"; ownTitle.textContent = "Your Board"; ownBoardDiv.appendChild(ownTitle); const ownGrid = renderBoardGrid( myShips, state.shots.filter((s) => s.firedBy !== me), false ); ownBoardDiv.appendChild(ownGrid); boardsDiv.appendChild(ownBoardDiv); // Opponent board (clickable if my turn) const oppBoardDiv = document.createElement("div"); oppBoardDiv.className = "battleship-board"; const oppTitle = document.createElement("div"); oppTitle.className = "battleship-board-title"; oppTitle.textContent = "Opponent's Board"; oppBoardDiv.appendChild(oppTitle); const myShotsOnOpponent = state.shots.filter((s) => s.firedBy === me); const oppGrid = renderBoardGrid(opponentShips, myShotsOnOpponent, true, (x, y) => { if (state.currentPlayer !== myPlayerNum) return; if (myShotsOnOpponent.some((s) => s.x === x && s.y === y)) return; fireShot(ctx, x, y, state.shots, opponentShips); }); if (state.currentPlayer !== myPlayerNum) { oppGrid.classList.add("battleship-disabled"); oppGrid.style.opacity = "0.5"; oppGrid.style.pointerEvents = "none"; } oppBoardDiv.appendChild(oppGrid); boardsDiv.appendChild(oppBoardDiv); container.appendChild(boardsDiv); el.appendChild(container); }, }); function renderShipPlacement(state, el, ctx, me, myPlayerNum, myShips, container) { const header = document.createElement("div"); header.className = "battleship-header"; header.textContent = "Place Your Ships"; container.appendChild(header); const status = document.createElement("div"); status.className = "battleship-status"; status.textContent = `Ships placed: ${myShips.length} / 4`; container.appendChild(status); const placementDiv = document.createElement("div"); placementDiv.className = "battleship-placement-container"; const grid = document.createElement("div"); grid.className = "battleship-grid"; grid.style.gridTemplateColumns = "repeat(10, 32px)"; let dragStart = null; let dragPreview = []; const cells = []; for (let y = 0; y < GRID_SIZE; y++) { for (let x = 0; x < GRID_SIZE; x++) { const cell = document.createElement("div"); cell.className = "battleship-cell"; cell.style.width = "32px"; cell.style.height = "32px"; cell.dataset.x = x; cell.dataset.y = y; const ship = myShips.find( (s) => (s.orientation === "h" && s.y === y && x >= s.x && x < s.x + s.length) || (s.orientation === "v" && s.x === x && y >= s.y && y < s.y + s.length) ); if (ship) { cell.classList.add("ship"); cell.textContent = "🚢"; } cell.onmousedown = () => { dragStart = { x, y }; dragPreview = []; }; cell.onmouseover = () => { if (!dragStart) return; clearPreview(grid); dragPreview = []; const dx = x - dragStart.x; const dy = y - dragStart.y; const nextShipLen = SHIP_LENGTHS[myShips.length]; if (nextShipLen === undefined) return; let isValid = true; let ship = null; if (Math.abs(dx) > 0 && dy === 0) { const sx = Math.min(dragStart.x, x); const ex = Math.max(dragStart.x, x); if (ex - sx + 1 === nextShipLen) { ship = { x: sx, y, length: nextShipLen, orientation: "h" }; for (let i = sx; i <= ex; i++) { dragPreview.push({ x: i, y }); } } } else if (dx === 0 && Math.abs(dy) > 0) { const sy = Math.min(dragStart.y, y); const ey = Math.max(dragStart.y, y); if (ey - sy + 1 === nextShipLen) { ship = { x, y: sy, length: nextShipLen, orientation: "v" }; for (let i = sy; i <= ey; i++) { dragPreview.push({ x, y: i }); } } } if (ship && !shipsOverlap(myShips, ship)) { dragPreview.forEach((p) => { const c = grid.querySelector(`[data-x="${p.x}"][data-y="${p.y}"]`); if (c) c.classList.add("preview"); }); } else { isValid = false; } }; cell.onmouseup = () => { if (!dragStart) return; const dx = x - dragStart.x; const dy = y - dragStart.y; const nextShipLen = SHIP_LENGTHS[myShips.length]; if (nextShipLen === undefined) { dragStart = null; return; } let ship = null; if (Math.abs(dx) > 0 && dy === 0) { const sx = Math.min(dragStart.x, x); const ex = Math.max(dragStart.x, x); if (ex - sx + 1 === nextShipLen) { ship = { x: sx, y, length: nextShipLen, orientation: "h" }; } } else if (dx === 0 && Math.abs(dy) > 0) { const sy = Math.min(dragStart.y, y); const ey = Math.max(dragStart.y, y); if (ey - sy + 1 === nextShipLen) { ship = { x, y: sy, length: nextShipLen, orientation: "v" }; } } if (ship && !shipsOverlap(myShips, ship)) { ctx.emit({ type: "battleship.ships-placed", ships: [...myShips, ship] }); } dragStart = null; clearPreview(grid); }; grid.appendChild(cell); cells.push(cell); } } placementDiv.appendChild(grid); const shipList = document.createElement("div"); shipList.className = "battleship-ship-list"; SHIP_LENGTHS.forEach((len, idx) => { const badge = document.createElement("div"); badge.className = "battleship-ship-badge"; if (idx < myShips.length) badge.classList.add("placed"); badge.textContent = `🚢 ${len}`; shipList.appendChild(badge); }); placementDiv.appendChild(shipList); if (myShips.length === SHIP_LENGTHS.length) { const readyBtn = document.createElement("button"); readyBtn.className = "battleship-button"; readyBtn.textContent = "Ready!"; readyBtn.onclick = () => { ctx.emit({ type: "battleship.ready" }); }; placementDiv.appendChild(readyBtn); } container.appendChild(placementDiv); el.appendChild(container); } function renderBoardGrid(ships, shots, isOpponentBoard, onCellClick) { const grid = document.createElement("div"); grid.className = "battleship-grid"; for (let y = 0; y < GRID_SIZE; y++) { for (let x = 0; x < GRID_SIZE; x++) { const cell = document.createElement("div"); cell.className = "battleship-cell"; cell.dataset.x = x; cell.dataset.y = y; const shot = shots.find((s) => s.x === x && s.y === y); const ship = ships.find( (s) => (s.orientation === "h" && s.y === y && x >= s.x && x < s.x + s.length) || (s.orientation === "v" && s.x === x && y >= s.y && y < s.y + s.length) ); if (shot) { if (shot.result === "hit") { cell.classList.add("hit"); cell.textContent = "💥"; } else if (shot.result === "miss") { cell.classList.add("miss"); cell.textContent = "○"; } else if (shot.result === "sunk") { cell.classList.add("sunk"); cell.textContent = "🔥"; } } else if (!isOpponentBoard && ship) { cell.classList.add("ship"); cell.textContent = "🚢"; } if (onCellClick && !shot) { cell.onclick = () => onCellClick(x, y); cell.style.cursor = "var(--ui-cursor, pointer)"; } grid.appendChild(cell); } } return grid; } function fireShot(ctx, x, y, shots, opponentShips) { if (shots.some((s) => s.x === x && s.y === y)) return; const ship = opponentShips.find( (s) => (s.orientation === "h" && s.y === y && x >= s.x && x < s.x + s.length) || (s.orientation === "v" && s.x === x && y >= s.y && y < s.y + s.length) ); let result = "miss"; if (ship) { const hitsOnShip = shots.filter((s) => { return ( s.result === "hit" && ((ship.orientation === "h" && s.y === ship.y && s.x >= ship.x && s.x < ship.x + ship.length) || (ship.orientation === "v" && s.x === ship.x && s.y >= ship.y && s.y < ship.y + ship.length)) ); }).length; if (hitsOnShip + 1 === ship.length) { result = "sunk"; } else { result = "hit"; } } ctx.emit({ type: "battleship.shot-fired", x, y, result }); } function shipsOverlap(ships, newShip) { for (const ship of ships) { if (ship.orientation === "h" && newShip.orientation === "h") { if ( ship.y === newShip.y && !(ship.x + ship.length <= newShip.x || newShip.x + newShip.length <= ship.x) ) { return true; } } else if (ship.orientation === "v" && newShip.orientation === "v") { if ( ship.x === newShip.x && !(ship.y + ship.length <= newShip.y || newShip.y + newShip.length <= ship.y) ) { return true; } } else if (ship.orientation === "h" && newShip.orientation === "v") { for (let i = 0; i < newShip.length; i++) { const cy = newShip.y + i; if ( cy === ship.y && !(ship.x + ship.length <= newShip.x || newShip.x + 1 <= ship.x) ) { return true; } } } else if (ship.orientation === "v" && newShip.orientation === "h") { for (let i = 0; i < newShip.length; i++) { const cx = newShip.x + i; if ( cx === ship.x && !(ship.y + ship.length <= newShip.y || newShip.y + 1 <= ship.y) ) { return true; } } } } return false; } function clearPreview(grid) { grid.querySelectorAll(".preview").forEach((c) => c.classList.remove("preview")); } function renderGameOver(state, container) { const header = document.createElement("div"); header.className = "battleship-header"; header.textContent = "⚓ Game Over"; container.appendChild(header); const result = document.createElement("div"); result.className = "battleship-status"; result.style.fontSize = "18px"; result.style.fontWeight = "bold"; result.style.marginTop = "16px"; if (state.winner === "p1") { result.textContent = "🎉 Player 1 Wins!"; } else if (state.winner === "p2") { result.textContent = "🎉 Player 2 Wins!"; } else { result.textContent = "Draw!"; } container.appendChild(result); }