eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// A member app (not a built-in — it's not imported anywhere and isn't part
2// of the trusted web/static/apps/ registry). Parked here, not deployed: to
3// activate it, paste this source into App Studio (⋮ → App Studio → paste +
4// Deploy) on an instance. Updated for groupCapable member apps (see PR #553)
5// so it can be added to a group chat, not just used personally.
6
7import { defineApp, addItem, updateItem, removeItem } from "../apps.js";
8
9const GRID_SIZE = 10;
10const SHIP_LENGTHS = [4, 3, 2, 2];
11
12export const app = defineApp({
13 name: "battleship",
14 title: "Battleship",
15 glyph: "🚢",
16 groupCapable: true,
17
18 initialState: () => ({
19 players: [],
20 p1: null,
21 p2: null,
22 p1Ships: [],
23 p2Ships: [],
24 p1Ready: false,
25 p2Ready: false,
26 currentPlayer: null,
27 shots: [],
28 gameOver: false,
29 winner: null,
30 }),
31
32 events: {
33 "battleship.player-joined": (s, e) => {
34 if (!e.uid || typeof e.playerNumber !== "number") return s;
35 const playerNum = e.playerNumber;
36 if (playerNum === 1 && !s.p1) {
37 return {
38 ...s,
39 p1: { uid: e.uid, name: e.name },
40 players: [...s.players, { uid: e.uid, playerNumber: 1 }],
41 currentPlayer: s.p2 ? 1 : null,
42 };
43 }
44 if (playerNum === 2 && !s.p2) {
45 return {
46 ...s,
47 p2: { uid: e.uid, name: e.name },
48 players: [...s.players, { uid: e.uid, playerNumber: 2 }],
49 currentPlayer: s.p1 ? 1 : null,
50 };
51 }
52 return s;
53 },
54 "battleship.ships-placed": (s, e) => {
55 if (!e.uid || !Array.isArray(e.ships)) return s;
56 if (s.p1 && s.p1.uid === e.uid) {
57 return { ...s, p1Ships: e.ships };
58 }
59 if (s.p2 && s.p2.uid === e.uid) {
60 return { ...s, p2Ships: e.ships };
61 }
62 return s;
63 },
64 "battleship.ready": (s, e) => {
65 if (!e.uid) return s;
66 if (s.p1 && s.p1.uid === e.uid) {
67 return { ...s, p1Ready: true };
68 }
69 if (s.p2 && s.p2.uid === e.uid) {
70 return { ...s, p2Ready: true };
71 }
72 return s;
73 },
74 "battleship.shot-fired": (s, e) => {
75 if (!e.uid || typeof e.x !== "number" || typeof e.y !== "number") return s;
76 const newShots = [
77 ...s.shots,
78 { x: e.x, y: e.y, result: e.result, firedBy: e.uid, ts: e.ts },
79 ];
80 const nextPlayer = s.currentPlayer === 1 ? 2 : 1;
81 return {
82 ...s,
83 shots: newShots,
84 currentPlayer: nextPlayer,
85 };
86 },
87 "battleship.game-over": (s, e) => {
88 if (!e.winner) return s;
89 return { ...s, gameOver: true, winner: e.winner };
90 },
91 },
92
93 styles: `
94.battleship-container {
95 display: flex;
96 flex-direction: column;
97 gap: 16px;
98 padding: 16px;
99 height: 100%;
100 overflow-y: auto;
101}
102.battleship-header {
103 text-align: center;
104 font-size: 18px;
105 font-weight: bold;
106 color: var(--ink);
107}
108.battleship-status {
109 text-align: center;
110 font-size: 14px;
111 color: var(--muted);
112}
113.battleship-boards {
114 display: flex;
115 gap: 24px;
116 justify-content: center;
117}
118.battleship-board {
119 display: flex;
120 flex-direction: column;
121 gap: 8px;
122}
123.battleship-board-title {
124 text-align: center;
125 font-weight: bold;
126 font-size: 14px;
127 color: var(--ink);
128}
129.battleship-grid {
130 display: inline-grid;
131 grid-template-columns: repeat(10, 24px);
132 gap: 2px;
133 background: var(--panel);
134 padding: 4px;
135 border-radius: var(--radius);
136}
137.battleship-cell {
138 width: 24px;
139 height: 24px;
140 border: 1px solid var(--border);
141 background: var(--bg);
142 cursor: var(--ui-cursor, pointer);
143 display: flex;
144 align-items: center;
145 justify-content: center;
146 font-size: 10px;
147 border-radius: 2px;
148}
149.battleship-cell.ship {
150 background: var(--accent);
151 color: var(--accent-ink);
152}
153.battleship-cell.hit {
154 background: var(--danger);
155 color: white;
156 font-weight: bold;
157}
158.battleship-cell.miss {
159 background: var(--muted);
160 color: var(--ink);
161}
162.battleship-cell.sunk {
163 background: var(--danger);
164 color: white;
165 font-weight: bold;
166}
167.battleship-cell.disabled {
168 opacity: 0.5;
169 cursor: not-allowed;
170}
171.battleship-cell.preview {
172 background: rgba(0, 150, 255, 0.3);
173 border: 2px dashed var(--accent);
174}
175.battleship-button-group {
176 display: flex;
177 gap: 8px;
178 justify-content: center;
179 flex-wrap: wrap;
180}
181.battleship-button {
182 padding: 8px 16px;
183 background: var(--accent);
184 color: var(--accent-ink);
185 border: none;
186 border-radius: var(--radius);
187 cursor: var(--ui-cursor, pointer);
188 font-weight: bold;
189 font-size: 14px;
190}
191.battleship-button:disabled {
192 opacity: 0.5;
193 cursor: not-allowed;
194}
195.battleship-button:hover:not(:disabled) {
196 opacity: 0.8;
197}
198.battleship-waiting {
199 text-align: center;
200 padding: 32px 16px;
201 color: var(--muted);
202 font-size: 16px;
203}
204.battleship-placement-container {
205 display: flex;
206 flex-direction: column;
207 gap: 16px;
208 align-items: center;
209}
210.battleship-ship-list {
211 display: flex;
212 gap: 8px;
213 flex-wrap: wrap;
214 justify-content: center;
215}
216.battleship-ship-badge {
217 padding: 4px 12px;
218 background: var(--panel);
219 border: 1px solid var(--border);
220 border-radius: var(--radius);
221 font-size: 12px;
222 color: var(--ink);
223}
224.battleship-ship-badge.placed {
225 background: var(--accent);
226 color: var(--accent-ink);
227 border-color: var(--accent);
228}
229`,
230
231 render(state, el, ctx) {
232 const me = ctx.me;
233 const myPlayerNum =
234 state.p1 && state.p1.uid === me ? 1 : state.p2 && state.p2.uid === me ? 2 : null;
235 const opponentPlayerNum = myPlayerNum === 1 ? 2 : myPlayerNum === 2 ? 1 : null;
236
237 const container = document.createElement("div");
238 container.className = "battleship-container";
239
240 // Waiting for opponent
241 if (!state.p1 || !state.p2) {
242 const header = document.createElement("div");
243 header.className = "battleship-header";
244 header.textContent = "⚓ Battleship";
245 container.appendChild(header);
246
247 if (!myPlayerNum) {
248 const joinBtn = document.createElement("button");
249 joinBtn.className = "battleship-button";
250 joinBtn.textContent = state.p1 ? "Join as Player 2" : "Join as Player 1";
251 joinBtn.onclick = () => {
252 const playerNum = state.p1 ? 2 : 1;
253 ctx.emit({ type: "battleship.player-joined", playerNumber: playerNum });
254 };
255 const btnGroup = document.createElement("div");
256 btnGroup.className = "battleship-button-group";
257 btnGroup.appendChild(joinBtn);
258 container.appendChild(btnGroup);
259 }
260
261 const waiting = document.createElement("div");
262 waiting.className = "battleship-waiting";
263 waiting.textContent = state.p1 && !state.p2 ? "Waiting for Player 2…" : "Waiting for Player 1…";
264 container.appendChild(waiting);
265 el.appendChild(container);
266 return;
267 }
268
269 // Both players joined
270 const myShips = myPlayerNum === 1 ? state.p1Ships : state.p2Ships;
271 const opponentShips = opponentPlayerNum === 1 ? state.p1Ships : state.p2Ships;
272 const myReady = myPlayerNum === 1 ? state.p1Ready : state.p2Ready;
273 const opponentReady = opponentPlayerNum === 1 ? state.p1Ready : state.p2Ready;
274
275 // Ship placement phase
276 if (myShips.length === 0 || !myReady) {
277 renderShipPlacement(state, el, ctx, me, myPlayerNum, myShips, container);
278 return;
279 }
280
281 // Game over
282 if (state.gameOver) {
283 renderGameOver(state, container);
284 el.appendChild(container);
285 return;
286 }
287
288 // Battle phase
289 const header = document.createElement("div");
290 header.className = "battleship-header";
291 header.textContent = "⚓ Battleship";
292 container.appendChild(header);
293
294 const status = document.createElement("div");
295 status.className = "battleship-status";
296 if (state.currentPlayer === myPlayerNum) {
297 status.textContent = "🎯 Your turn!";
298 } else {
299 status.textContent = "⏳ Opponent's turn…";
300 }
301 container.appendChild(status);
302
303 const boardsDiv = document.createElement("div");
304 boardsDiv.className = "battleship-boards";
305
306 // Own board (shows opponent's shots)
307 const ownBoardDiv = document.createElement("div");
308 ownBoardDiv.className = "battleship-board";
309 const ownTitle = document.createElement("div");
310 ownTitle.className = "battleship-board-title";
311 ownTitle.textContent = "Your Board";
312 ownBoardDiv.appendChild(ownTitle);
313 const ownGrid = renderBoardGrid(
314 myShips,
315 state.shots.filter((s) => s.firedBy !== me),
316 false
317 );
318 ownBoardDiv.appendChild(ownGrid);
319 boardsDiv.appendChild(ownBoardDiv);
320
321 // Opponent board (clickable if my turn)
322 const oppBoardDiv = document.createElement("div");
323 oppBoardDiv.className = "battleship-board";
324 const oppTitle = document.createElement("div");
325 oppTitle.className = "battleship-board-title";
326 oppTitle.textContent = "Opponent's Board";
327 oppBoardDiv.appendChild(oppTitle);
328
329 const myShotsOnOpponent = state.shots.filter((s) => s.firedBy === me);
330 const oppGrid = renderBoardGrid(opponentShips, myShotsOnOpponent, true, (x, y) => {
331 if (state.currentPlayer !== myPlayerNum) return;
332 if (myShotsOnOpponent.some((s) => s.x === x && s.y === y)) return;
333 fireShot(ctx, x, y, state.shots, opponentShips);
334 });
335 if (state.currentPlayer !== myPlayerNum) {
336 oppGrid.classList.add("battleship-disabled");
337 oppGrid.style.opacity = "0.5";
338 oppGrid.style.pointerEvents = "none";
339 }
340 oppBoardDiv.appendChild(oppGrid);
341 boardsDiv.appendChild(oppBoardDiv);
342
343 container.appendChild(boardsDiv);
344 el.appendChild(container);
345 },
346});
347
348function renderShipPlacement(state, el, ctx, me, myPlayerNum, myShips, container) {
349 const header = document.createElement("div");
350 header.className = "battleship-header";
351 header.textContent = "Place Your Ships";
352 container.appendChild(header);
353
354 const status = document.createElement("div");
355 status.className = "battleship-status";
356 status.textContent = `Ships placed: ${myShips.length} / 4`;
357 container.appendChild(status);
358
359 const placementDiv = document.createElement("div");
360 placementDiv.className = "battleship-placement-container";
361
362 const grid = document.createElement("div");
363 grid.className = "battleship-grid";
364 grid.style.gridTemplateColumns = "repeat(10, 32px)";
365
366 let dragStart = null;
367 let dragPreview = [];
368
369 const cells = [];
370 for (let y = 0; y < GRID_SIZE; y++) {
371 for (let x = 0; x < GRID_SIZE; x++) {
372 const cell = document.createElement("div");
373 cell.className = "battleship-cell";
374 cell.style.width = "32px";
375 cell.style.height = "32px";
376 cell.dataset.x = x;
377 cell.dataset.y = y;
378
379 const ship = myShips.find(
380 (s) =>
381 (s.orientation === "h" &&
382 s.y === y &&
383 x >= s.x &&
384 x < s.x + s.length) ||
385 (s.orientation === "v" && s.x === x && y >= s.y && y < s.y + s.length)
386 );
387
388 if (ship) {
389 cell.classList.add("ship");
390 cell.textContent = "🚢";
391 }
392
393 cell.onmousedown = () => {
394 dragStart = { x, y };
395 dragPreview = [];
396 };
397
398 cell.onmouseover = () => {
399 if (!dragStart) return;
400 clearPreview(grid);
401 dragPreview = [];
402
403 const dx = x - dragStart.x;
404 const dy = y - dragStart.y;
405 const nextShipLen = SHIP_LENGTHS[myShips.length];
406
407 if (nextShipLen === undefined) return;
408
409 let isValid = true;
410 let ship = null;
411
412 if (Math.abs(dx) > 0 && dy === 0) {
413 const sx = Math.min(dragStart.x, x);
414 const ex = Math.max(dragStart.x, x);
415 if (ex - sx + 1 === nextShipLen) {
416 ship = { x: sx, y, length: nextShipLen, orientation: "h" };
417 for (let i = sx; i <= ex; i++) {
418 dragPreview.push({ x: i, y });
419 }
420 }
421 } else if (dx === 0 && Math.abs(dy) > 0) {
422 const sy = Math.min(dragStart.y, y);
423 const ey = Math.max(dragStart.y, y);
424 if (ey - sy + 1 === nextShipLen) {
425 ship = { x, y: sy, length: nextShipLen, orientation: "v" };
426 for (let i = sy; i <= ey; i++) {
427 dragPreview.push({ x, y: i });
428 }
429 }
430 }
431
432 if (ship && !shipsOverlap(myShips, ship)) {
433 dragPreview.forEach((p) => {
434 const c = grid.querySelector(`[data-x="${p.x}"][data-y="${p.y}"]`);
435 if (c) c.classList.add("preview");
436 });
437 } else {
438 isValid = false;
439 }
440 };
441
442 cell.onmouseup = () => {
443 if (!dragStart) return;
444 const dx = x - dragStart.x;
445 const dy = y - dragStart.y;
446 const nextShipLen = SHIP_LENGTHS[myShips.length];
447
448 if (nextShipLen === undefined) {
449 dragStart = null;
450 return;
451 }
452
453 let ship = null;
454 if (Math.abs(dx) > 0 && dy === 0) {
455 const sx = Math.min(dragStart.x, x);
456 const ex = Math.max(dragStart.x, x);
457 if (ex - sx + 1 === nextShipLen) {
458 ship = { x: sx, y, length: nextShipLen, orientation: "h" };
459 }
460 } else if (dx === 0 && Math.abs(dy) > 0) {
461 const sy = Math.min(dragStart.y, y);
462 const ey = Math.max(dragStart.y, y);
463 if (ey - sy + 1 === nextShipLen) {
464 ship = { x, y: sy, length: nextShipLen, orientation: "v" };
465 }
466 }
467
468 if (ship && !shipsOverlap(myShips, ship)) {
469 ctx.emit({ type: "battleship.ships-placed", ships: [...myShips, ship] });
470 }
471
472 dragStart = null;
473 clearPreview(grid);
474 };
475
476 grid.appendChild(cell);
477 cells.push(cell);
478 }
479 }
480
481 placementDiv.appendChild(grid);
482
483 const shipList = document.createElement("div");
484 shipList.className = "battleship-ship-list";
485 SHIP_LENGTHS.forEach((len, idx) => {
486 const badge = document.createElement("div");
487 badge.className = "battleship-ship-badge";
488 if (idx < myShips.length) badge.classList.add("placed");
489 badge.textContent = `🚢 ${len}`;
490 shipList.appendChild(badge);
491 });
492 placementDiv.appendChild(shipList);
493
494 if (myShips.length === SHIP_LENGTHS.length) {
495 const readyBtn = document.createElement("button");
496 readyBtn.className = "battleship-button";
497 readyBtn.textContent = "Ready!";
498 readyBtn.onclick = () => {
499 ctx.emit({ type: "battleship.ready" });
500 };
501 placementDiv.appendChild(readyBtn);
502 }
503
504 container.appendChild(placementDiv);
505 el.appendChild(container);
506}
507
508function renderBoardGrid(ships, shots, isOpponentBoard, onCellClick) {
509 const grid = document.createElement("div");
510 grid.className = "battleship-grid";
511
512 for (let y = 0; y < GRID_SIZE; y++) {
513 for (let x = 0; x < GRID_SIZE; x++) {
514 const cell = document.createElement("div");
515 cell.className = "battleship-cell";
516 cell.dataset.x = x;
517 cell.dataset.y = y;
518
519 const shot = shots.find((s) => s.x === x && s.y === y);
520 const ship = ships.find(
521 (s) =>
522 (s.orientation === "h" && s.y === y && x >= s.x && x < s.x + s.length) ||
523 (s.orientation === "v" && s.x === x && y >= s.y && y < s.y + s.length)
524 );
525
526 if (shot) {
527 if (shot.result === "hit") {
528 cell.classList.add("hit");
529 cell.textContent = "💥";
530 } else if (shot.result === "miss") {
531 cell.classList.add("miss");
532 cell.textContent = "○";
533 } else if (shot.result === "sunk") {
534 cell.classList.add("sunk");
535 cell.textContent = "🔥";
536 }
537 } else if (!isOpponentBoard && ship) {
538 cell.classList.add("ship");
539 cell.textContent = "🚢";
540 }
541
542 if (onCellClick && !shot) {
543 cell.onclick = () => onCellClick(x, y);
544 cell.style.cursor = "var(--ui-cursor, pointer)";
545 }
546
547 grid.appendChild(cell);
548 }
549 }
550
551 return grid;
552}
553
554function fireShot(ctx, x, y, shots, opponentShips) {
555 if (shots.some((s) => s.x === x && s.y === y)) return;
556
557 const ship = opponentShips.find(
558 (s) =>
559 (s.orientation === "h" && s.y === y && x >= s.x && x < s.x + s.length) ||
560 (s.orientation === "v" && s.x === x && y >= s.y && y < s.y + s.length)
561 );
562
563 let result = "miss";
564 if (ship) {
565 const hitsOnShip = shots.filter((s) => {
566 return (
567 s.result === "hit" &&
568 ((ship.orientation === "h" &&
569 s.y === ship.y &&
570 s.x >= ship.x &&
571 s.x < ship.x + ship.length) ||
572 (ship.orientation === "v" &&
573 s.x === ship.x &&
574 s.y >= ship.y &&
575 s.y < ship.y + ship.length))
576 );
577 }).length;
578
579 if (hitsOnShip + 1 === ship.length) {
580 result = "sunk";
581 } else {
582 result = "hit";
583 }
584 }
585
586 ctx.emit({ type: "battleship.shot-fired", x, y, result });
587}
588
589function shipsOverlap(ships, newShip) {
590 for (const ship of ships) {
591 if (ship.orientation === "h" && newShip.orientation === "h") {
592 if (
593 ship.y === newShip.y &&
594 !(ship.x + ship.length <= newShip.x || newShip.x + newShip.length <= ship.x)
595 ) {
596 return true;
597 }
598 } else if (ship.orientation === "v" && newShip.orientation === "v") {
599 if (
600 ship.x === newShip.x &&
601 !(ship.y + ship.length <= newShip.y || newShip.y + newShip.length <= ship.y)
602 ) {
603 return true;
604 }
605 } else if (ship.orientation === "h" && newShip.orientation === "v") {
606 for (let i = 0; i < newShip.length; i++) {
607 const cy = newShip.y + i;
608 if (
609 cy === ship.y &&
610 !(ship.x + ship.length <= newShip.x || newShip.x + 1 <= ship.x)
611 ) {
612 return true;
613 }
614 }
615 } else if (ship.orientation === "v" && newShip.orientation === "h") {
616 for (let i = 0; i < newShip.length; i++) {
617 const cx = newShip.x + i;
618 if (
619 cx === ship.x &&
620 !(ship.y + ship.length <= newShip.y || newShip.y + 1 <= ship.y)
621 ) {
622 return true;
623 }
624 }
625 }
626 }
627 return false;
628}
629
630function clearPreview(grid) {
631 grid.querySelectorAll(".preview").forEach((c) => c.classList.remove("preview"));
632}
633
634function renderGameOver(state, container) {
635 const header = document.createElement("div");
636 header.className = "battleship-header";
637 header.textContent = "⚓ Game Over";
638 container.appendChild(header);
639
640 const result = document.createElement("div");
641 result.className = "battleship-status";
642 result.style.fontSize = "18px";
643 result.style.fontWeight = "bold";
644 result.style.marginTop = "16px";
645
646 if (state.winner === "p1") {
647 result.textContent = "🎉 Player 1 Wins!";
648 } else if (state.winner === "p2") {
649 result.textContent = "🎉 Player 2 Wins!";
650 } else {
651 result.textContent = "Draw!";
652 }
653
654 container.appendChild(result);
655}
656