| new file mode 100644 |
| index 0000000..9e43d05 |
| --- /dev/null |
| +++ b/disabled-apps/battleship.js |
| @@ -0,0 +1,655 @@ |
| +// 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); |
| +} |