Five battles of escalating difficulty.
+ Score: 0
+
+
+
+
+
+
+
+
+
+
+
+
Deploy Your Fleet
@@ -317,6 +344,9 @@ Enemy Fleet
+
+
+
diff --git a/demos/battleships/js/drag-placement.js b/demos/battleships/js/drag-placement.js
new file mode 100644
index 0000000..2357872
--- /dev/null
+++ b/demos/battleships/js/drag-placement.js
@@ -0,0 +1,427 @@
+(function () {
+ 'use strict';
+
+ // ----- State -----
+ var playerGrid = null;
+ var placementPanel = null;
+ var options = {};
+ var ghost = null;
+ var draggingShip = null; // { key, name, size, element }
+ var horizontal = true;
+ var highlightedCells = [];
+ var listeners = []; // track listeners for cleanup
+ var touchState = null; // { ship, startX, startY, identifier }
+ var initialized = false;
+
+ // ----- Helpers -----
+ function getCell(grid, row, col) {
+ return grid.querySelector('.cell[data-row="' + row + '"][data-col="' + col + '"]');
+ }
+
+ function getCellSize() {
+ var cell = playerGrid ? playerGrid.querySelector('.cell') : null;
+ if (cell) {
+ var rect = cell.getBoundingClientRect();
+ return { w: rect.width, h: rect.height };
+ }
+ return { w: 40, h: 40 };
+ }
+
+ function getCellFromPoint(x, y) {
+ var cells = playerGrid.querySelectorAll('.cell');
+ for (var i = 0; i < cells.length; i++) {
+ var rect = cells[i].getBoundingClientRect();
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
+ return {
+ row: parseInt(cells[i].getAttribute('data-row'), 10),
+ col: parseInt(cells[i].getAttribute('data-col'), 10),
+ element: cells[i]
+ };
+ }
+ }
+ return null;
+ }
+
+ function addListener(el, event, handler, opts) {
+ el.addEventListener(event, handler, opts || false);
+ listeners.push({ el: el, event: event, handler: handler, opts: opts || false });
+ }
+
+ // ----- Ghost element -----
+ function createGhost(size) {
+ removeGhost();
+ ghost = document.createElement('div');
+ ghost.className = 'drag-ghost' + (horizontal ? '' : ' vertical');
+ for (var i = 0; i < size; i++) {
+ var cell = document.createElement('div');
+ cell.className = 'drag-ghost-cell';
+ ghost.appendChild(cell);
+ }
+ document.body.appendChild(ghost);
+ }
+
+ function updateGhostPosition(x, y) {
+ if (!ghost) return;
+ var cs = getCellSize();
+ if (horizontal) {
+ ghost.style.left = (x - cs.w / 2) + 'px';
+ ghost.style.top = (y - cs.h / 2) + 'px';
+ } else {
+ ghost.style.left = (x - cs.w / 2) + 'px';
+ ghost.style.top = (y - cs.h / 2) + 'px';
+ }
+ }
+
+ function updateGhostOrientation() {
+ if (!ghost) return;
+ if (horizontal) {
+ ghost.classList.remove('vertical');
+ } else {
+ ghost.classList.add('vertical');
+ }
+ }
+
+ function removeGhost() {
+ if (ghost && ghost.parentNode) {
+ ghost.parentNode.removeChild(ghost);
+ }
+ ghost = null;
+ }
+
+ // ----- Drop zone highlighting -----
+ function clearHighlights() {
+ for (var i = 0; i < highlightedCells.length; i++) {
+ highlightedCells[i].classList.remove('drop-valid', 'drop-invalid', 'drop-hover');
+ }
+ highlightedCells = [];
+ }
+
+ function showDropZone(row, col, size, horiz, hoverRow, hoverCol) {
+ clearHighlights();
+ if (!draggingShip) return;
+
+ var canPlace = options.canPlace ? options.canPlace(size, row, col, horiz) : true;
+ var cls = canPlace ? 'drop-valid' : 'drop-invalid';
+
+ for (var i = 0; i < size; i++) {
+ var r = horiz ? row : row + i;
+ var c = horiz ? col + i : col;
+ if (r < 0 || r >= 10 || c < 0 || c >= 10) continue;
+ var el = getCell(playerGrid, r, c);
+ if (el) {
+ el.classList.add(cls);
+ if (r === hoverRow && c === hoverCol) {
+ el.classList.add('drop-hover');
+ }
+ highlightedCells.push(el);
+ }
+ }
+ }
+
+ // ----- HTML5 Drag & Drop -----
+ function onDragStart(e) {
+ var target = e.currentTarget;
+ var shipKey = target.getAttribute('data-ship');
+ var shipSize = parseInt(target.getAttribute('data-size'), 10);
+ var shipName = target.textContent.replace(/\s*\(\d+\)\s*$/, '').trim();
+
+ if (target.classList.contains('placed')) {
+ e.preventDefault();
+ return;
+ }
+
+ draggingShip = { key: shipKey, name: shipName, size: shipSize, element: target };
+
+ // Read current orientation from the game
+ if (options.getHorizontal) {
+ horizontal = options.getHorizontal();
+ }
+
+ // Set drag data
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', shipKey);
+
+ // Create invisible drag image (we use our own ghost)
+ var img = document.createElement('div');
+ img.style.width = '1px';
+ img.style.height = '1px';
+ img.style.opacity = '0.01';
+ img.style.position = 'fixed';
+ img.style.top = '-9999px';
+ document.body.appendChild(img);
+ e.dataTransfer.setDragImage(img, 0, 0);
+ setTimeout(function () {
+ if (img.parentNode) img.parentNode.removeChild(img);
+ }, 0);
+
+ target.classList.add('dragging');
+ createGhost(shipSize);
+ updateGhostPosition(e.clientX, e.clientY);
+ }
+
+ function onDrag(e) {
+ if (!draggingShip || !ghost) return;
+ // Some browsers fire drag with 0,0 at end
+ if (e.clientX === 0 && e.clientY === 0) return;
+ updateGhostPosition(e.clientX, e.clientY);
+
+ var cellInfo = getCellFromPoint(e.clientX, e.clientY);
+ if (cellInfo) {
+ showDropZone(cellInfo.row, cellInfo.col, draggingShip.size, horizontal, cellInfo.row, cellInfo.col);
+ } else {
+ clearHighlights();
+ }
+ }
+
+ function onDragEnd(e) {
+ if (draggingShip && draggingShip.element) {
+ draggingShip.element.classList.remove('dragging');
+ }
+ clearHighlights();
+ removeGhost();
+ draggingShip = null;
+ }
+
+ function onGridDragOver(e) {
+ if (!draggingShip) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ }
+
+ function onGridDrop(e) {
+ e.preventDefault();
+ if (!draggingShip) return;
+
+ var cellInfo = getCellFromPoint(e.clientX, e.clientY);
+ if (!cellInfo) return;
+
+ var canPlace = options.canPlace ? options.canPlace(draggingShip.size, cellInfo.row, cellInfo.col, horizontal) : true;
+ if (canPlace && options.onPlace) {
+ options.onPlace(draggingShip.key, draggingShip.name, draggingShip.size, cellInfo.row, cellInfo.col, horizontal);
+ }
+
+ clearHighlights();
+ removeGhost();
+ if (draggingShip && draggingShip.element) {
+ draggingShip.element.classList.remove('dragging');
+ }
+ draggingShip = null;
+ }
+
+ // ----- Rotation during drag (keydown) -----
+ function onKeyDown(e) {
+ if (!draggingShip && !touchState) return;
+ if (e.key === 'r' || e.key === 'R') {
+ horizontal = !horizontal;
+ updateGhostOrientation();
+ if (options.onRotate) options.onRotate();
+ }
+ }
+
+ // ----- Touch support -----
+ function onTouchStart(e) {
+ var target = e.currentTarget;
+ if (target.classList.contains('placed')) return;
+
+ var touch = e.touches[0];
+ var shipKey = target.getAttribute('data-ship');
+ var shipSize = parseInt(target.getAttribute('data-size'), 10);
+ var shipName = target.textContent.replace(/\s*\(\d+\)\s*$/, '').trim();
+
+ if (options.getHorizontal) {
+ horizontal = options.getHorizontal();
+ }
+
+ touchState = {
+ ship: { key: shipKey, name: shipName, size: shipSize, element: target },
+ startX: touch.clientX,
+ startY: touch.clientY,
+ identifier: touch.identifier,
+ dragging: false
+ };
+ }
+
+ function onTouchMove(e) {
+ if (!touchState) return;
+ var touch = null;
+ for (var i = 0; i < e.touches.length; i++) {
+ if (e.touches[i].identifier === touchState.identifier) {
+ touch = e.touches[i];
+ break;
+ }
+ }
+ if (!touch) return;
+
+ var dx = touch.clientX - touchState.startX;
+ var dy = touch.clientY - touchState.startY;
+
+ // Start drag after small threshold
+ if (!touchState.dragging && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) {
+ touchState.dragging = true;
+ draggingShip = touchState.ship;
+ touchState.ship.element.classList.add('dragging');
+ createGhost(touchState.ship.size);
+ }
+
+ if (touchState.dragging) {
+ e.preventDefault();
+ updateGhostPosition(touch.clientX, touch.clientY);
+
+ var cellInfo = getCellFromPoint(touch.clientX, touch.clientY);
+ if (cellInfo) {
+ showDropZone(cellInfo.row, cellInfo.col, draggingShip.size, horizontal, cellInfo.row, cellInfo.col);
+ } else {
+ clearHighlights();
+ }
+ }
+ }
+
+ function onTouchEnd(e) {
+ if (!touchState) return;
+
+ if (touchState.dragging && draggingShip) {
+ // Find the last touch position from changedTouches
+ var touch = null;
+ for (var i = 0; i < e.changedTouches.length; i++) {
+ if (e.changedTouches[i].identifier === touchState.identifier) {
+ touch = e.changedTouches[i];
+ break;
+ }
+ }
+
+ if (touch) {
+ var cellInfo = getCellFromPoint(touch.clientX, touch.clientY);
+ if (cellInfo) {
+ var canPlace = options.canPlace ? options.canPlace(draggingShip.size, cellInfo.row, cellInfo.col, horizontal) : true;
+ if (canPlace && options.onPlace) {
+ options.onPlace(draggingShip.key, draggingShip.name, draggingShip.size, cellInfo.row, cellInfo.col, horizontal);
+ }
+ }
+ }
+
+ draggingShip.element.classList.remove('dragging');
+ clearHighlights();
+ removeGhost();
+ draggingShip = null;
+ }
+
+ touchState = null;
+ }
+
+ // ----- Right-click rotation during drag -----
+ function onContextMenu(e) {
+ if (draggingShip || touchState) {
+ e.preventDefault();
+ horizontal = !horizontal;
+ updateGhostOrientation();
+ if (options.onRotate) options.onRotate();
+ }
+ }
+
+ // ----- Public API -----
+ function init(grid, panel, opts) {
+ if (initialized) destroy();
+
+ playerGrid = grid;
+ placementPanel = panel;
+ options = opts || {};
+
+ // Check for HTML5 drag-and-drop support
+ var supportsDrag = ('draggable' in document.createElement('div'));
+
+ var shipEls = panel.querySelectorAll('.ship-option');
+ for (var i = 0; i < shipEls.length; i++) {
+ var el = shipEls[i];
+
+ if (el.classList.contains('placed')) continue;
+
+ // Enable HTML5 drag
+ if (supportsDrag) {
+ el.setAttribute('draggable', 'true');
+ addListener(el, 'dragstart', onDragStart);
+ addListener(el, 'drag', onDrag);
+ addListener(el, 'dragend', onDragEnd);
+ }
+
+ // Touch events for mobile
+ addListener(el, 'touchstart', onTouchStart, { passive: true });
+ addListener(el, 'touchmove', onTouchMove, { passive: false });
+ addListener(el, 'touchend', onTouchEnd);
+ }
+
+ // Grid drop zone
+ if (supportsDrag) {
+ addListener(grid, 'dragover', onGridDragOver);
+ addListener(grid, 'drop', onGridDrop);
+ }
+
+ // Rotation via keyboard
+ addListener(document, 'keydown', onKeyDown);
+
+ // Right-click rotation while dragging
+ addListener(document, 'contextmenu', onContextMenu);
+
+ initialized = true;
+ }
+
+ function setHorizontal(val) {
+ horizontal = !!val;
+ updateGhostOrientation();
+ }
+
+ function markPlaced(shipKey) {
+ if (!placementPanel) return;
+ var el = placementPanel.querySelector('.ship-option[data-ship="' + shipKey + '"]');
+ if (el) {
+ el.setAttribute('draggable', 'false');
+ el.classList.remove('dragging');
+ }
+ }
+
+ function reset() {
+ if (!placementPanel) return;
+ var shipEls = placementPanel.querySelectorAll('.ship-option');
+ for (var i = 0; i < shipEls.length; i++) {
+ shipEls[i].setAttribute('draggable', 'true');
+ shipEls[i].classList.remove('dragging');
+ }
+ clearHighlights();
+ removeGhost();
+ draggingShip = null;
+ touchState = null;
+ }
+
+ function destroy() {
+ for (var i = 0; i < listeners.length; i++) {
+ var l = listeners[i];
+ l.el.removeEventListener(l.event, l.handler, l.opts);
+ }
+ listeners = [];
+ clearHighlights();
+ removeGhost();
+
+ if (placementPanel) {
+ var shipEls = placementPanel.querySelectorAll('.ship-option');
+ for (var j = 0; j < shipEls.length; j++) {
+ shipEls[j].removeAttribute('draggable');
+ shipEls[j].classList.remove('dragging');
+ }
+ }
+
+ playerGrid = null;
+ placementPanel = null;
+ options = {};
+ draggingShip = null;
+ touchState = null;
+ initialized = false;
+ }
+
+ // ----- Expose -----
+ window.DragPlacement = {
+ init: init,
+ setHorizontal: setHorizontal,
+ markPlaced: markPlaced,
+ reset: reset,
+ destroy: destroy
+ };
+})();
diff --git a/demos/battleships/js/fog-of-war.js b/demos/battleships/js/fog-of-war.js
new file mode 100644
index 0000000..94e6670
--- /dev/null
+++ b/demos/battleships/js/fog-of-war.js
@@ -0,0 +1,143 @@
+(function () {
+ 'use strict';
+
+ // ---------------------------------------------------------------
+ // Fog of War Module
+ // ---------------------------------------------------------------
+ // Manages fog state for the enemy grid in Fog of War mode.
+ // Cells start fogged; firing reveals the target cell and its
+ // 8 neighbours (3x3 area). Sonar integration can reveal larger
+ // areas via revealArea().
+ //
+ // Depends on: nothing (pure data / DOM-class module)
+ // ---------------------------------------------------------------
+
+ /**
+ * Create a fresh fog state — a 2D boolean array where
+ * false = fogged and true = revealed.
+ */
+ function createState(gridSize) {
+ gridSize = gridSize || 10;
+ var state = [];
+ for (var r = 0; r < gridSize; r++) {
+ var row = [];
+ for (var c = 0; c < gridSize; c++) {
+ row.push(false);
+ }
+ state.push(row);
+ }
+ return state;
+ }
+
+ /**
+ * Reveal a cell and its 8 neighbours (3x3 area).
+ * Returns an array of {row, col} for cells that were newly revealed.
+ */
+ function reveal(state, row, col) {
+ return revealArea(state, row, col, 1);
+ }
+
+ /**
+ * Reveal a larger area centred on (row, col).
+ * radius=1 gives 3x3, radius=2 gives 5x5, etc.
+ * Returns an array of {row, col} for cells that were newly revealed.
+ */
+ function revealArea(state, row, col, radius) {
+ radius = (typeof radius === 'number') ? radius : 1;
+ var size = state.length;
+ var newly = [];
+
+ for (var dr = -radius; dr <= radius; dr++) {
+ for (var dc = -radius; dc <= radius; dc++) {
+ var r = row + dr;
+ var c = col + dc;
+ if (r >= 0 && r < size && c >= 0 && c < size && !state[r][c]) {
+ state[r][c] = true;
+ newly.push({ row: r, col: c });
+ }
+ }
+ }
+
+ return newly;
+ }
+
+ /**
+ * Check whether a cell is revealed (fog lifted).
+ */
+ function isRevealed(state, row, col) {
+ if (row < 0 || row >= state.length || col < 0 || col >= state.length) {
+ return false;
+ }
+ return !!state[row][col];
+ }
+
+ /**
+ * Apply fog classes to a grid element.
+ * Adds 'fogged' to unrevealed cells, removes it from revealed cells.
+ * Adds a brief 'fog-revealing' animation class to newly-revealed cells.
+ */
+ function applyFog(gridEl, state) {
+ var cells = gridEl.querySelectorAll('.cell[data-row][data-col]');
+ for (var i = 0; i < cells.length; i++) {
+ var cell = cells[i];
+ var r = parseInt(cell.getAttribute('data-row'), 10);
+ var c = parseInt(cell.getAttribute('data-col'), 10);
+
+ if (state[r] && state[r][c]) {
+ // Revealed
+ if (cell.classList.contains('fogged')) {
+ cell.classList.remove('fogged');
+ cell.classList.add('fog-revealing');
+ // Remove the animation class after it plays
+ (function (el) {
+ setTimeout(function () {
+ el.classList.remove('fog-revealing');
+ }, 500);
+ })(cell);
+ }
+ } else {
+ // Fogged
+ if (!cell.classList.contains('fogged')) {
+ cell.classList.add('fogged');
+ }
+ }
+ }
+ }
+
+ /**
+ * Count how many cells have been revealed.
+ */
+ function countRevealed(state) {
+ var count = 0;
+ for (var r = 0; r < state.length; r++) {
+ for (var c = 0; c < state[r].length; c++) {
+ if (state[r][c]) count++;
+ }
+ }
+ return count;
+ }
+
+ /**
+ * Reveal all cells (e.g. on game over).
+ */
+ function revealAll(state) {
+ for (var r = 0; r < state.length; r++) {
+ for (var c = 0; c < state[r].length; c++) {
+ state[r][c] = true;
+ }
+ }
+ }
+
+ // =============================================================
+ // Public API
+ // =============================================================
+ window.FogOfWar = {
+ createState: createState,
+ reveal: reveal,
+ revealArea: revealArea,
+ isRevealed: isRevealed,
+ applyFog: applyFog,
+ countRevealed: countRevealed,
+ revealAll: revealAll
+ };
+})();
diff --git a/demos/battleships/js/game.js b/demos/battleships/js/game.js
index 8e3362a..7d2d650 100644
--- a/demos/battleships/js/game.js
+++ b/demos/battleships/js/game.js
@@ -21,6 +21,10 @@
var enemyFleetList = document.getElementById('enemy-fleet-list');
var logEntries = document.getElementById('log-entries');
+ // Weapons/Fog DOM references
+ var weaponsPanelEl = document.getElementById('weapons-panel');
+ var directionPickerEl = document.getElementById('direction-picker');
+
// New DOM references
var modeSelectScreen = document.getElementById('mode-select-screen');
var startGameBtn = document.getElementById('start-game-btn');
@@ -64,6 +68,11 @@
var salvoShotsRemaining = 0;
var salvoTurnActive = false;
+ // Weapons & Fog of War state
+ var weaponsState = null;
+ var fogState = null;
+ var pendingWeaponClick = null; // {row, col} for torpedo awaiting direction
+
var shipDefs = [
{ key: 'carrier', name: 'Carrier', size: 5 },
{ key: 'battleship', name: 'Battleship', size: 4 },
@@ -380,6 +389,7 @@
phase = 'placement';
placementPanel.classList.remove('hidden');
resetPlacement();
+ initDragPlacement();
setStatus('Place your ' + shipDefs[0].name + ' (' + shipDefs[0].size + ' cells)');
// Show campaign round info if applicable
@@ -679,12 +689,297 @@
startBattle();
});
+ // ----- Drag-and-drop placement -----
+ function initDragPlacement() {
+ DragPlacement.init(playerGrid, placementPanel, {
+ onPlace: function (shipKey, shipName, size, row, col, horiz) {
+ var result = Engine.placeShip(playerBoard, playerShips, shipName, size, row, col, horiz);
+ if (result === null) return;
+ SFX.place();
+ applyShipClasses(playerGrid, playerShips[playerShips.length - 1]);
+ placedShips[shipKey] = true;
+ DragPlacement.markPlaced(shipKey);
+ // Mark ship option in panel
+ for (var d = 0; d < shipDefs.length; d++) {
+ if (shipDefs[d].key === shipKey) { markShipPlaced(d); break; }
+ }
+ // Find next unplaced ship
+ var foundNext = false;
+ for (var n = 0; n < shipDefs.length; n++) {
+ if (!placedShips[shipDefs[n].key]) {
+ currentShipIndex = n;
+ selectShipOption(n);
+ setStatus('Place your ' + shipDefs[n].name + ' (' + shipDefs[n].size + ' cells)');
+ foundNext = true;
+ break;
+ }
+ }
+ if (!foundNext) startBattle();
+ },
+ canPlace: function (size, row, col, horiz) {
+ return Engine.canPlaceShip(playerBoard, size, row, col, horiz);
+ },
+ onRotate: function () {
+ horizontal = !horizontal;
+ rotateBtn.textContent = horizontal ? 'Rotate (R)' : 'Rotate (R) \u2014 Vertical';
+ },
+ getHorizontal: function () { return horizontal; }
+ });
+ }
+
+ // ----- Weapons panel helpers -----
+ function updateWeaponsPanel() {
+ if (!weaponsState) return;
+ var btns = weaponsPanelEl.querySelectorAll('.weapon-btn');
+ for (var i = 0; i < btns.length; i++) {
+ var btn = btns[i];
+ var type = btn.getAttribute('data-weapon');
+ var usesEl = btn.querySelector('.weapon-uses');
+ if (weaponsState[type]) {
+ usesEl.textContent = weaponsState[type].uses;
+ if (weaponsState[type].uses <= 0) {
+ btn.classList.add('exhausted');
+ btn.classList.remove('active');
+ } else {
+ btn.classList.remove('exhausted');
+ }
+ }
+ // Highlight active weapon
+ if (weaponsState.active === type) {
+ btn.classList.add('active');
+ } else {
+ btn.classList.remove('active');
+ }
+ }
+ }
+
+ function hideWeaponsPanel() {
+ weaponsPanelEl.style.display = 'none';
+ directionPickerEl.style.display = 'none';
+ pendingWeaponClick = null;
+ }
+
+ // ----- Weapon button click handlers -----
+ (function () {
+ var btns = weaponsPanelEl.querySelectorAll('.weapon-btn');
+ for (var i = 0; i < btns.length; i++) {
+ (function (btn) {
+ btn.addEventListener('click', function () {
+ if (phase !== 'battle' || locked || !weaponsState) return;
+ var type = btn.getAttribute('data-weapon');
+ if (weaponsState[type].uses <= 0) return;
+
+ // Toggle weapon off if already active
+ if (weaponsState.active === type) {
+ Weapons.deactivate(weaponsState);
+ directionPickerEl.style.display = 'none';
+ pendingWeaponClick = null;
+ updateWeaponsPanel();
+ setStatus("Turn " + turnNumber + " \u2014 Fire at enemy waters!");
+ return;
+ }
+
+ var info = Weapons.activate(weaponsState, type);
+ if (!info) return;
+ SFX.weaponSelect();
+ updateWeaponsPanel();
+ setStatus("Turn " + turnNumber + " \u2014 " + info.type.charAt(0).toUpperCase() + info.type.slice(1) + " active! Click enemy waters.");
+ });
+ })(btns[i]);
+ }
+ })();
+
+ // ----- Direction picker handlers -----
+ (function () {
+ var dirBtns = directionPickerEl.querySelectorAll('.direction-btn');
+ for (var i = 0; i < dirBtns.length; i++) {
+ (function (btn) {
+ btn.addEventListener('click', function () {
+ var dir = btn.getAttribute('data-direction');
+ if (dir === 'cancel') {
+ directionPickerEl.style.display = 'none';
+ if (weaponsState) Weapons.deactivate(weaponsState);
+ pendingWeaponClick = null;
+ updateWeaponsPanel();
+ setStatus("Turn " + turnNumber + " \u2014 Fire at enemy waters!");
+ locked = false;
+ return;
+ }
+ if (!pendingWeaponClick || !weaponsState) return;
+ directionPickerEl.style.display = 'none';
+ executeWeapon(pendingWeaponClick.row, pendingWeaponClick.col, dir);
+ pendingWeaponClick = null;
+ });
+ })(dirBtns[i]);
+ }
+ })();
+
+ // ----- Execute weapon after direction (if needed) -----
+ function executeWeapon(row, col, direction) {
+ var result = Weapons.execute(weaponsState, enemyBoard, enemyShips, row, col, direction);
+ if (!result) { locked = false; return; }
+
+ updateWeaponsPanel();
+
+ if (result.type === 'torpedo') {
+ SFX.torpedo();
+ // Animate torpedo trail
+ var startCell = getCell(enemyGrid, row, col);
+ if (startCell) {
+ var sRect = startCell.getBoundingClientRect();
+ var cx = sRect.left + sRect.width / 2;
+ var cy = sRect.top + sRect.height / 2;
+ // Find end cell
+ var lastCell = result.cells[result.cells.length - 1];
+ var endEl = getCell(enemyGrid, lastCell.row, lastCell.col);
+ if (endEl) {
+ var eRect = endEl.getBoundingClientRect();
+ Particles.torpedoTrail(cx, cy, eRect.left + eRect.width / 2, eRect.top + eRect.height / 2);
+ }
+ }
+ processWeaponCells(result.cells, true);
+ } else if (result.type === 'airstrike') {
+ SFX.airstrike();
+ // Airstrike particle at center
+ var centerEl = getCell(enemyGrid, row, col);
+ if (centerEl) {
+ var cRect = centerEl.getBoundingClientRect();
+ Particles.airstrike(cRect.left + cRect.width / 2, cRect.top + cRect.height / 2);
+ }
+ processWeaponCells(result.cells, true);
+ } else if (result.type === 'sonar') {
+ SFX.sonarPing();
+ // Sonar particle at center
+ var sonarEl = getCell(enemyGrid, row, col);
+ if (sonarEl) {
+ var sonarRect = sonarEl.getBoundingClientRect();
+ Particles.sonarPing(sonarRect.left + sonarRect.width / 2, sonarRect.top + sonarRect.height / 2);
+ }
+ // Sonar doesn't fire shots — just reveals info visually
+ for (var i = 0; i < result.cells.length; i++) {
+ var sc = result.cells[i];
+ var scEl = getCell(enemyGrid, sc.row, sc.col);
+ if (scEl) {
+ scEl.classList.add('sonar-revealed');
+ if (sc.hasShip) {
+ scEl.classList.add('sonar-ship');
+ }
+ }
+ }
+ // Fog of War: sonar reveals 5x5 area
+ if (fogState) {
+ FogOfWar.revealArea(fogState, row, col, 2);
+ FogOfWar.applyFog(enemyGrid, fogState);
+ SFX.fogReveal();
+ }
+ addLog('Sonar scan at ' + Engine.formatCoord(row, col) + ' \u2014 area scanned!', '');
+ // Sonar doesn't end the turn — player still fires normally
+ locked = false;
+ return;
+ }
+ }
+
+ function processWeaponCells(cells, endsTurn) {
+ var hitCount = 0;
+ var sunkNames = [];
+ for (var i = 0; i < cells.length; i++) {
+ var c = cells[i];
+ if (c.alreadyFired) continue;
+ var cellEl = getCell(enemyGrid, c.row, c.col);
+ if (!cellEl) continue;
+
+ if (c.result === 'sunk') {
+ cellEl.classList.remove('hit', 'miss');
+ cellEl.classList.add('sunk');
+ var sunkShip = Engine.getShipAt(enemyShips, c.row, c.col);
+ if (sunkShip) {
+ for (var p = 0; p < sunkShip.positions.length; p++) {
+ var sunkCellEl = getCell(enemyGrid, sunkShip.positions[p].row, sunkShip.positions[p].col);
+ if (sunkCellEl) { sunkCellEl.classList.remove('hit', 'miss'); sunkCellEl.classList.add('sunk'); }
+ }
+ if (sunkNames.indexOf(sunkShip.name) === -1) {
+ sunkNames.push(sunkShip.name);
+ SFX.shipSinking();
+ var sRect = cellEl.getBoundingClientRect();
+ Particles.sinking(sRect.left + sRect.width / 2, sRect.top + sRect.height / 2);
+ }
+ }
+ hitCount++;
+ playerHitCount++;
+ playerShotCount++;
+ Stats.scoreHit();
+ if (sunkShip) Stats.scoreSink(sunkShip.positions.length);
+ } else if (c.result === 'hit') {
+ cellEl.classList.add('hit');
+ hitCount++;
+ playerHitCount++;
+ playerShotCount++;
+ Stats.scoreHit();
+ } else if (c.result === 'miss') {
+ cellEl.classList.add('miss');
+ playerShotCount++;
+ Stats.scoreMiss();
+ }
+
+ // Fog of War: reveal around each fired cell
+ if (fogState) {
+ FogOfWar.reveal(fogState, c.row, c.col);
+ }
+ }
+
+ if (fogState) {
+ FogOfWar.applyFog(enemyGrid, fogState);
+ SFX.fogReveal();
+ }
+
+ updateScoreDisplay();
+ updateFleetStatus(enemyFleetList, enemyShips);
+
+ var coordStr = Engine.formatCoord(cells[0].row, cells[0].col);
+ if (sunkNames.length > 0) {
+ setStatus("Weapon strike \u2014 SUNK " + sunkNames.join(', ') + "!", 'sunk');
+ addLog('Weapon strike at ' + coordStr + ' \u2014 SUNK ' + sunkNames.join(', ') + '!', 'sunk');
+ } else if (hitCount > 0) {
+ setStatus("Weapon strike \u2014 " + hitCount + " hit(s)!", 'hit');
+ addLog('Weapon strike at ' + coordStr + ' \u2014 ' + hitCount + ' hit(s)!', 'hit');
+ } else {
+ setStatus("Weapon strike \u2014 all miss!", 'miss');
+ addLog('Weapon strike at ' + coordStr + ' \u2014 all miss', 'miss');
+ }
+
+ if (Engine.isGameOver(enemyShips)) {
+ phase = 'gameover';
+ showGameOver(true);
+ return;
+ }
+
+ if (endsTurn) {
+ setTimeout(function () { doAITurn(); }, 600);
+ }
+ }
+
// ----- Battle phase -----
function startBattle() {
phase = 'battle';
placementPanel.classList.add('hidden');
pauseBtn.style.display = '';
+ // Clean up drag placement
+ DragPlacement.destroy();
+
+ // Initialize weapons
+ weaponsState = Weapons.create();
+ updateWeaponsPanel();
+ weaponsPanelEl.style.display = '';
+
+ // Initialize Fog of War if applicable
+ if (selectedMode === 'fogofwar') {
+ fogState = FogOfWar.createState(10);
+ FogOfWar.applyFog(enemyGrid, fogState);
+ } else {
+ fogState = null;
+ }
+
// Determine the effective difficulty for AI
var effectiveDifficulty = selectedDifficulty;
if (selectedMode === 'campaign' && campaignState) {
@@ -771,93 +1066,123 @@
}
});
cell.addEventListener('click', function () {
- if (phase !== 'battle' || locked) return;
- if (kbMode && !kbFiring) { kbMode = false; updateCursor(); }
- var row = parseInt(cell.getAttribute('data-row'), 10);
- var col = parseInt(cell.getAttribute('data-col'), 10);
- if (cell.classList.contains('hit') || cell.classList.contains('miss') || cell.classList.contains('sunk')) return;
+ handleEnemyCellClick(cell);
+ });
+ })(eCells[ei]);
+ }
- // In salvo mode, don't lock until all shots are fired
- if (selectedMode !== 'salvo') {
- locked = true;
- }
+ // ----- Centralised enemy cell click handler -----
+ function handleEnemyCellClick(cell) {
+ if (phase !== 'battle' || locked) return;
+ if (kbMode && !kbFiring) { kbMode = false; updateCursor(); }
+ var row = parseInt(cell.getAttribute('data-row'), 10);
+ var col = parseInt(cell.getAttribute('data-col'), 10);
+ if (cell.classList.contains('hit') || cell.classList.contains('miss') || cell.classList.contains('sunk')) return;
+
+ // --- Weapon active? ---
+ if (weaponsState && Weapons.isActive(weaponsState)) {
+ locked = true;
+ var activeType = weaponsState.active;
+
+ if (activeType === 'torpedo') {
+ // Torpedo needs direction — show picker
+ pendingWeaponClick = { row: row, col: col };
+ directionPickerEl.style.display = '';
+ return;
+ }
+ // Airstrike and sonar execute immediately
+ executeWeapon(row, col, null);
+ return;
+ }
- playerShotCount++;
+ // --- Normal shot logic ---
+ // In salvo mode, don't lock until all shots are fired
+ if (selectedMode !== 'salvo') {
+ locked = true;
+ }
- var shot = Engine.processShot(enemyBoard, enemyShips, row, col);
- if (shot.alreadyFired) { if (selectedMode !== 'salvo') locked = false; playerShotCount--; return; }
+ playerShotCount++;
- var coord = Engine.formatCoord(row, col);
- var _rect;
+ var shot = Engine.processShot(enemyBoard, enemyShips, row, col);
+ if (shot.alreadyFired) { if (selectedMode !== 'salvo') locked = false; playerShotCount--; return; }
- if (shot.result === 'sunk') {
- SFX.sunk();
- var sunkShip = Engine.getShipAt(enemyShips, row, col);
- if (sunkShip) {
- for (var p = 0; p < sunkShip.positions.length; p++) {
- var sunkCell = getCell(enemyGrid, sunkShip.positions[p].row, sunkShip.positions[p].col);
- if (sunkCell) { sunkCell.classList.remove('hit', 'miss'); sunkCell.classList.add('sunk'); }
- }
- }
- _rect = cell.getBoundingClientRect();
- Particles.debris(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
- playerHitCount++;
- Stats.scoreHit();
- var sunkShipForScore = Engine.getShipAt(enemyShips, row, col);
- if (sunkShipForScore) Stats.scoreSink(sunkShipForScore.positions.length);
- updateScoreDisplay();
- setStatus("You sunk their " + shot.shipName + "!", 'sunk');
- addLog('You fired ' + coord + ' \u2014 SUNK ' + shot.shipName + '!', 'sunk');
- } else if (shot.result === 'hit') {
- SFX.hit();
- cell.classList.add('hit');
- _rect = cell.getBoundingClientRect();
- Particles.fire(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
- // Add smoke after hit
- setTimeout(function () {
- var r2 = cell.getBoundingClientRect();
- Particles.smoke(r2.left + r2.width / 2, r2.top + r2.height / 2);
- }, 200);
- playerHitCount++;
- Stats.scoreHit();
- updateScoreDisplay();
- setStatus("Hit at " + coord + "!", 'hit');
- addLog('You fired ' + coord + ' \u2014 HIT!', 'hit');
- } else {
- SFX.miss();
- cell.classList.add('miss');
- _rect = cell.getBoundingClientRect();
- Particles.splash(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
- Particles.wake(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
- Stats.scoreMiss();
- updateScoreDisplay();
- setStatus("Miss at " + coord, 'miss');
- addLog('You fired ' + coord + ' \u2014 miss', 'miss');
+ var coord = Engine.formatCoord(row, col);
+ var _rect;
+
+ if (shot.result === 'sunk') {
+ SFX.sunk();
+ var sunkShip = Engine.getShipAt(enemyShips, row, col);
+ if (sunkShip) {
+ for (var p = 0; p < sunkShip.positions.length; p++) {
+ var sunkCell = getCell(enemyGrid, sunkShip.positions[p].row, sunkShip.positions[p].col);
+ if (sunkCell) { sunkCell.classList.remove('hit', 'miss'); sunkCell.classList.add('sunk'); }
}
+ SFX.shipSinking();
+ _rect = cell.getBoundingClientRect();
+ Particles.sinking(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
+ }
+ _rect = cell.getBoundingClientRect();
+ Particles.debris(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
+ playerHitCount++;
+ Stats.scoreHit();
+ var sunkShipForScore = Engine.getShipAt(enemyShips, row, col);
+ if (sunkShipForScore) Stats.scoreSink(sunkShipForScore.positions.length);
+ updateScoreDisplay();
+ setStatus("You sunk their " + shot.shipName + "!", 'sunk');
+ addLog('You fired ' + coord + ' \u2014 SUNK ' + shot.shipName + '!', 'sunk');
+ } else if (shot.result === 'hit') {
+ SFX.hit();
+ cell.classList.add('hit');
+ _rect = cell.getBoundingClientRect();
+ Particles.fire(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
+ setTimeout(function () {
+ var r2 = cell.getBoundingClientRect();
+ Particles.smoke(r2.left + r2.width / 2, r2.top + r2.height / 2);
+ }, 200);
+ playerHitCount++;
+ Stats.scoreHit();
+ updateScoreDisplay();
+ setStatus("Hit at " + coord + "!", 'hit');
+ addLog('You fired ' + coord + ' \u2014 HIT!', 'hit');
+ } else {
+ SFX.miss();
+ cell.classList.add('miss');
+ _rect = cell.getBoundingClientRect();
+ Particles.splash(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
+ Particles.wake(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
+ Stats.scoreMiss();
+ updateScoreDisplay();
+ setStatus("Miss at " + coord, 'miss');
+ addLog('You fired ' + coord + ' \u2014 miss', 'miss');
+ }
- updateFleetStatus(enemyFleetList, enemyShips);
+ // Fog of War: reveal around the fired cell
+ if (fogState) {
+ FogOfWar.reveal(fogState, row, col);
+ FogOfWar.applyFog(enemyGrid, fogState);
+ SFX.fogReveal();
+ }
- if (Engine.isGameOver(enemyShips)) {
- phase = 'gameover';
- showGameOver(true);
- return;
- }
+ updateFleetStatus(enemyFleetList, enemyShips);
- // Salvo mode: decrement shots
- if (selectedMode === 'salvo') {
- salvoShotsRemaining--;
- if (salvoShotsRemaining > 0) {
- setStatus("Turn " + turnNumber + " \u2014 Shots remaining: " + salvoShotsRemaining);
- return; // Player still has shots
- }
- // All player shots fired, now AI turn
- locked = true;
- setTimeout(function () { doAISalvoTurn(); }, 600);
- } else {
- setTimeout(function () { doAITurn(); }, 600);
- }
- });
- })(eCells[ei]);
+ if (Engine.isGameOver(enemyShips)) {
+ phase = 'gameover';
+ showGameOver(true);
+ return;
+ }
+
+ // Salvo mode: decrement shots
+ if (selectedMode === 'salvo') {
+ salvoShotsRemaining--;
+ if (salvoShotsRemaining > 0) {
+ setStatus("Turn " + turnNumber + " \u2014 Shots remaining: " + salvoShotsRemaining);
+ return;
+ }
+ locked = true;
+ setTimeout(function () { doAISalvoTurn(); }, 600);
+ } else {
+ setTimeout(function () { doAITurn(); }, 600);
+ }
}
// ----- AI turn (single shot) -----
@@ -990,6 +1315,13 @@
function showGameOver(playerWon) {
locked = true;
pauseBtn.style.display = 'none';
+ hideWeaponsPanel();
+
+ // Fog of War: reveal all on game over
+ if (fogState) {
+ FogOfWar.revealAll(fogState);
+ FogOfWar.applyFog(enemyGrid, fogState);
+ }
// Stop ambient audio
SFX.stopAmbient();
@@ -1110,7 +1442,9 @@
// Reset placement
phase = 'placement';
placementPanel.classList.remove('hidden');
+ hideWeaponsPanel();
resetPlacement();
+ initDragPlacement();
setStatus('Round ' + round.round + ': ' + round.title + ' \u2014 Place your ships!');
}
@@ -1249,6 +1583,10 @@
kbMode = false;
kbRow = 0;
kbCol = 0;
+ weaponsState = null;
+ fogState = null;
+ pendingWeaponClick = null;
+ hideWeaponsPanel();
// Clear grids
buildGrid(playerGrid);
@@ -1356,87 +1694,7 @@
}
});
cell.addEventListener('click', function () {
- if (phase !== 'battle' || locked) return;
- if (kbMode && !kbFiring) { kbMode = false; updateCursor(); }
- var row = parseInt(cell.getAttribute('data-row'), 10);
- var col = parseInt(cell.getAttribute('data-col'), 10);
- if (cell.classList.contains('hit') || cell.classList.contains('miss') || cell.classList.contains('sunk')) return;
-
- if (selectedMode !== 'salvo') {
- locked = true;
- }
-
- playerShotCount++;
-
- var shot = Engine.processShot(enemyBoard, enemyShips, row, col);
- if (shot.alreadyFired) { if (selectedMode !== 'salvo') locked = false; playerShotCount--; return; }
-
- var coord = Engine.formatCoord(row, col);
- var _rect;
-
- if (shot.result === 'sunk') {
- SFX.sunk();
- var sunkShip = Engine.getShipAt(enemyShips, row, col);
- if (sunkShip) {
- for (var p = 0; p < sunkShip.positions.length; p++) {
- var sunkCell = getCell(enemyGrid, sunkShip.positions[p].row, sunkShip.positions[p].col);
- if (sunkCell) { sunkCell.classList.remove('hit', 'miss'); sunkCell.classList.add('sunk'); }
- }
- }
- _rect = cell.getBoundingClientRect();
- Particles.debris(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
- playerHitCount++;
- Stats.scoreHit();
- var sunkShipForScore = Engine.getShipAt(enemyShips, row, col);
- if (sunkShipForScore) Stats.scoreSink(sunkShipForScore.positions.length);
- updateScoreDisplay();
- setStatus("You sunk their " + shot.shipName + "!", 'sunk');
- addLog('You fired ' + coord + ' \u2014 SUNK ' + shot.shipName + '!', 'sunk');
- } else if (shot.result === 'hit') {
- SFX.hit();
- cell.classList.add('hit');
- _rect = cell.getBoundingClientRect();
- Particles.fire(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
- setTimeout(function () {
- var r2 = cell.getBoundingClientRect();
- Particles.smoke(r2.left + r2.width / 2, r2.top + r2.height / 2);
- }, 200);
- playerHitCount++;
- Stats.scoreHit();
- updateScoreDisplay();
- setStatus("Hit at " + coord + "!", 'hit');
- addLog('You fired ' + coord + ' \u2014 HIT!', 'hit');
- } else {
- SFX.miss();
- cell.classList.add('miss');
- _rect = cell.getBoundingClientRect();
- Particles.splash(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
- Particles.wake(_rect.left + _rect.width / 2, _rect.top + _rect.height / 2);
- Stats.scoreMiss();
- updateScoreDisplay();
- setStatus("Miss at " + coord, 'miss');
- addLog('You fired ' + coord + ' \u2014 miss', 'miss');
- }
-
- updateFleetStatus(enemyFleetList, enemyShips);
-
- if (Engine.isGameOver(enemyShips)) {
- phase = 'gameover';
- showGameOver(true);
- return;
- }
-
- if (selectedMode === 'salvo') {
- salvoShotsRemaining--;
- if (salvoShotsRemaining > 0) {
- setStatus("Turn " + turnNumber + " \u2014 Shots remaining: " + salvoShotsRemaining);
- return;
- }
- locked = true;
- setTimeout(function () { doAISalvoTurn(); }, 600);
- } else {
- setTimeout(function () { doAITurn(); }, 600);
- }
+ handleEnemyCellClick(cell);
});
})(cells[i]);
}
diff --git a/demos/battleships/js/modes.js b/demos/battleships/js/modes.js
index 3c2a53a..f21efad 100644
--- a/demos/battleships/js/modes.js
+++ b/demos/battleships/js/modes.js
@@ -147,17 +147,34 @@
scoreMultiplier: 2.0
};
+ // =============================================================
+ // Fog of War Mode
+ // =============================================================
+ var FOG_OF_WAR = {
+ key: 'fogofwar',
+ name: 'Fog of War',
+ description: 'Enemy waters are shrouded in fog \u2014 firing reveals nearby cells. Navigate blind.',
+ icon: '\uD83C\uDF2B\uFE0F',
+
+ shotsPerTurn: function () { return 1; },
+ aiShotsPerTurn: function () { return 1; },
+
+ campaign: null,
+ scoreMultiplier: 1.75
+ };
+
// =============================================================
// Registry helpers
// =============================================================
var registry = {
classic: CLASSIC,
salvo: SALVO,
- campaign: CAMPAIGN
+ campaign: CAMPAIGN,
+ fogofwar: FOG_OF_WAR
};
function list() {
- return ['classic', 'salvo', 'campaign'];
+ return ['classic', 'salvo', 'campaign', 'fogofwar'];
}
function get(modeKey) {
@@ -168,10 +185,11 @@
// Public API
// =============================================================
window.Modes = {
- list: list,
- get: get,
- CLASSIC: CLASSIC,
- SALVO: SALVO,
- CAMPAIGN: CAMPAIGN
+ list: list,
+ get: get,
+ CLASSIC: CLASSIC,
+ SALVO: SALVO,
+ CAMPAIGN: CAMPAIGN,
+ FOG_OF_WAR: FOG_OF_WAR
};
})();
diff --git a/demos/battleships/js/particles.js b/demos/battleships/js/particles.js
index 88838d0..25a9318 100644
--- a/demos/battleships/js/particles.js
+++ b/demos/battleships/js/particles.js
@@ -337,5 +337,275 @@ window.Particles = (function () {
ensureAnimating();
}
- return { splash: splash, fire: fire, explode: explode, debris: debris, wake: wake, smoke: smoke };
+ /* --- New effect: torpedoTrail — animated streak from source to target --- */
+
+ function torpedoTrail(x1, y1, x2, y2) {
+ var colors = ['#4fc3f7', '#e1f5fe', '#ffffff'];
+ var dx = x2 - x1;
+ var dy = y2 - y1;
+ var dist = Math.sqrt(dx * dx + dy * dy);
+ var steps = Math.max(12, Math.floor(dist / 8));
+ var duration = 500; /* ms total travel time */
+
+ for (var i = 0; i < steps; i++) {
+ (function (index) {
+ setTimeout(function () {
+ var t = index / (steps - 1);
+ var px = x1 + dx * t;
+ var py = y1 + dy * t;
+ for (var j = 0; j < 3; j++) {
+ var p = acquire(); if (!p) return;
+ var spread = rand(-6, 6);
+ var perpX = -dy / dist * spread;
+ var perpY = dx / dist * spread;
+ p.x = px + perpX + rand(-2, 2);
+ p.y = py + perpY + rand(-2, 2);
+ p.vx = rand(-15, 15);
+ p.vy = rand(-15, 15);
+ p.gravity = 0;
+ p.life = rand(0.2, 0.5);
+ p.maxLife = p.life;
+ p.size = rand(1.5, 4);
+ p.sizeEnd = 0.5;
+ p.color = colors[Math.floor(Math.random() * colors.length)];
+ p.type = 'circle';
+ p.rotation = 0;
+ p.rotationSpeed = 0;
+ p.drag = 1.0;
+ }
+ ensureAnimating();
+ }, (index / steps) * duration);
+ })(i);
+ }
+ }
+
+ /* --- New effect: radarSweep — rotating green line sweep --- */
+
+ function radarSweep(centerX, centerY, radius) {
+ var sweepDuration = 2000; /* ms */
+ var totalSteps = 60;
+ var angleStep = (Math.PI * 2) / totalSteps;
+
+ for (var s = 0; s < totalSteps; s++) {
+ (function (step) {
+ setTimeout(function () {
+ var angle = step * angleStep;
+ var lineParticles = 8;
+ for (var i = 0; i < lineParticles; i++) {
+ var p = acquire(); if (!p) return;
+ var r = (i / lineParticles) * radius;
+ p.x = centerX + Math.cos(angle) * r;
+ p.y = centerY + Math.sin(angle) * r;
+ p.vx = Math.cos(angle) * rand(2, 8);
+ p.vy = Math.sin(angle) * rand(2, 8);
+ p.gravity = 0;
+ p.life = rand(0.3, 0.6);
+ p.maxLife = p.life;
+ p.size = rand(1.5, 3);
+ p.sizeEnd = 0.5;
+ p.color = 'rgba(46,204,113,0.6)';
+ p.type = 'circle';
+ p.rotation = 0;
+ p.rotationSpeed = 0;
+ p.drag = 0.5;
+ }
+ ensureAnimating();
+ }, (step / totalSteps) * sweepDuration);
+ })(s);
+ }
+ }
+
+ /* --- New effect: sonarPing — expanding concentric rings --- */
+
+ function sonarPing(x, y) {
+ var ringDelays = [0, 300, 600, 900];
+ var colors = ['#2ecc71', '#27ae60', '#2ecc71', '#27ae60'];
+
+ for (var i = 0; i < ringDelays.length; i++) {
+ (function (index) {
+ setTimeout(function () {
+ var ring = acquire(); if (!ring) return;
+ ring.x = x;
+ ring.y = y;
+ ring.vx = 0;
+ ring.vy = 0;
+ ring.gravity = 0;
+ ring.life = 0.8;
+ ring.maxLife = 0.8;
+ ring.size = 0;
+ ring.sizeEnd = 0;
+ ring.color = colors[index];
+ ring.type = 'ring';
+ ring.ringRadius = 4;
+ ring.ringMaxRadius = 50 + index * 15;
+ ring.rotation = 0;
+ ring.rotationSpeed = 0;
+ ring.drag = 0;
+ ensureAnimating();
+ }, ringDelays[index]);
+ })(i);
+ }
+
+ /* Central pulse particles */
+ for (var j = 0; j < 6; j++) {
+ var p = acquire(); if (!p) break;
+ var angle = (j / 6) * Math.PI * 2;
+ p.x = x;
+ p.y = y;
+ p.vx = Math.cos(angle) * rand(10, 25);
+ p.vy = Math.sin(angle) * rand(10, 25);
+ p.gravity = 0;
+ p.life = rand(0.4, 0.8);
+ p.maxLife = p.life;
+ p.size = rand(2, 4);
+ p.sizeEnd = 0.5;
+ p.color = '#2ecc71';
+ p.type = 'circle';
+ p.rotation = 0;
+ p.rotationSpeed = 0;
+ p.drag = 1.5;
+ }
+ ensureAnimating();
+ }
+
+ /* --- New effect: airstrike — falling particles then explosion --- */
+
+ function airstrike(x, y) {
+ var streakColors = ['#ff6d00', '#ffab00', '#ffffff', '#ff3d00'];
+ var startY = -40; /* above viewport */
+ var fallDuration = 600; /* ms before impact */
+ var streakCount = 8;
+
+ for (var i = 0; i < streakCount; i++) {
+ (function (index) {
+ setTimeout(function () {
+ var p = acquire(); if (!p) return;
+ p.x = x + rand(-15, 15);
+ p.y = startY + rand(-20, 0);
+ var totalDist = y - p.y;
+ p.vx = rand(-10, 10);
+ p.vy = totalDist / (fallDuration / 1000 * 0.6);
+ p.gravity = 400;
+ p.life = rand(0.5, 0.8);
+ p.maxLife = p.life;
+ p.size = rand(2, 4);
+ p.sizeEnd = 1;
+ p.color = streakColors[Math.floor(Math.random() * streakColors.length)];
+ p.type = 'circle';
+ p.rotation = 0;
+ p.rotationSpeed = 0;
+ p.drag = 0;
+
+ /* Trailing sparks behind each streak */
+ for (var t = 0; t < 3; t++) {
+ var tp = acquire(); if (!tp) break;
+ tp.x = p.x + rand(-3, 3);
+ tp.y = p.y + rand(0, 20);
+ tp.vx = rand(-5, 5);
+ tp.vy = p.vy * 0.3;
+ tp.gravity = 100;
+ tp.life = rand(0.2, 0.4);
+ tp.maxLife = tp.life;
+ tp.size = rand(1, 2);
+ tp.sizeEnd = 0.5;
+ tp.color = '#ffab00';
+ tp.type = 'circle';
+ tp.rotation = 0;
+ tp.rotationSpeed = 0;
+ tp.drag = 0.5;
+ }
+ ensureAnimating();
+ }, index * 40);
+ })(i);
+ }
+
+ /* Explosion at impact point after fall */
+ setTimeout(function () {
+ explode(x, y);
+ }, fallDuration);
+ }
+
+ /* --- New effect: sinking — bubbles rising, debris sinking --- */
+
+ function sinking(x, y) {
+ var metalColors = ['#90a4ae', '#b0bec5', '#78909c', '#607d8b'];
+ var bubbleColors = ['#b3e5fc', '#e1f5fe', '#81d4fa'];
+
+ /* Metallic debris sinking downward */
+ for (var i = 0; i < 12; i++) {
+ var d = acquire(); if (!d) break;
+ d.x = x + rand(-12, 12);
+ d.y = y + rand(-6, 6);
+ d.vx = rand(-15, 15);
+ d.vy = rand(20, 80);
+ d.gravity = 60;
+ d.life = rand(1.2, 2.0);
+ d.maxLife = d.life;
+ d.size = rand(2, 6);
+ d.sizeEnd = rand(1, 2);
+ d.color = metalColors[Math.floor(Math.random() * metalColors.length)];
+ d.type = 'debris';
+ d.rotation = rand(0, Math.PI * 2);
+ d.rotationSpeed = rand(-6, 6);
+ d.drag = 0.8;
+ }
+
+ /* Bubbles rising from the sinking point */
+ for (var b = 0; b < 20; b++) {
+ (function (index) {
+ setTimeout(function () {
+ var p = acquire(); if (!p) return;
+ p.x = x + rand(-10, 10);
+ p.y = y + rand(-4, 8);
+ p.vx = rand(-8, 8);
+ p.vy = rand(-80, -30);
+ p.gravity = -20;
+ p.life = rand(0.6, 1.5);
+ p.maxLife = p.life;
+ p.size = rand(1.5, 5);
+ p.sizeEnd = rand(0.5, 2);
+ p.color = bubbleColors[Math.floor(Math.random() * bubbleColors.length)];
+ p.type = 'circle';
+ p.rotation = 0;
+ p.rotationSpeed = 0;
+ p.drag = 1.2;
+ ensureAnimating();
+ }, index * 80);
+ })(b);
+ }
+
+ /* Slow expanding rings at surface */
+ for (var r = 0; r < 3; r++) {
+ (function (index) {
+ setTimeout(function () {
+ var ring = acquire(); if (!ring) return;
+ ring.x = x + rand(-4, 4);
+ ring.y = y;
+ ring.vx = 0;
+ ring.vy = 0;
+ ring.gravity = 0;
+ ring.life = 1.0;
+ ring.maxLife = 1.0;
+ ring.size = 0;
+ ring.sizeEnd = 0;
+ ring.color = '#81d4fa';
+ ring.type = 'ring';
+ ring.ringRadius = 3;
+ ring.ringMaxRadius = 35 + index * 10;
+ ring.rotation = 0;
+ ring.rotationSpeed = 0;
+ ring.drag = 0;
+ ensureAnimating();
+ }, index * 400);
+ })(r);
+ }
+
+ ensureAnimating();
+ }
+
+ return {
+ splash: splash, fire: fire, explode: explode, debris: debris, wake: wake, smoke: smoke,
+ torpedoTrail: torpedoTrail, radarSweep: radarSweep, sonarPing: sonarPing,
+ airstrike: airstrike, sinking: sinking
+ };
})();
diff --git a/demos/battleships/js/sfx.js b/demos/battleships/js/sfx.js
index e4fb2fc..5549a5f 100644
--- a/demos/battleships/js/sfx.js
+++ b/demos/battleships/js/sfx.js
@@ -374,6 +374,512 @@ window.SFX = (function () {
cleanup(nodes, dur);
}
+ /* --- New sound: torpedo — fast whoosh/streak --- */
+ function torpedo() {
+ if (!ensureContext()) return;
+ var t = now(), dur = 0.4, nodes = [];
+
+ /* High-frequency noise burst that sweeps from high to low */
+ var noise = ctx.createBufferSource();
+ noise.buffer = getNoiseBuffer();
+
+ var bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(6000, t);
+ bp.frequency.exponentialRampToValueAtTime(400, t + dur);
+ bp.Q.value = 2.0;
+
+ var ng = ctx.createGain();
+ ng.gain.setValueAtTime(0.001, t);
+ ng.gain.linearRampToValueAtTime(0.4, t + 0.03);
+ ng.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ noise.connect(bp);
+ bp.connect(ng);
+ ng.connect(masterGain);
+ noise.start(t);
+ noise.stop(t + dur);
+ nodes.push(noise, bp, ng);
+
+ /* Thin sine whistle for the streak character */
+ var whistle = ctx.createOscillator();
+ whistle.type = 'sine';
+ whistle.frequency.setValueAtTime(4000, t);
+ whistle.frequency.exponentialRampToValueAtTime(800, t + dur);
+
+ var wg = ctx.createGain();
+ wg.gain.setValueAtTime(0.001, t);
+ wg.gain.linearRampToValueAtTime(0.12, t + 0.02);
+ wg.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ whistle.connect(wg);
+ wg.connect(masterGain);
+ whistle.start(t);
+ whistle.stop(t + dur);
+ nodes.push(whistle, wg);
+
+ cleanup(nodes, dur);
+ }
+
+ /* --- New sound: airstrike — incoming whistle then heavy explosion --- */
+ function airstrike() {
+ if (!ensureContext()) return;
+ var t = now(), dur = 1.4, nodes = [];
+
+ /* Phase 1: Descending whistle (0–0.6s) */
+ var whistle = ctx.createOscillator();
+ whistle.type = 'sine';
+ whistle.frequency.setValueAtTime(2000, t);
+ whistle.frequency.exponentialRampToValueAtTime(400, t + 0.6);
+
+ var wg = ctx.createGain();
+ wg.gain.setValueAtTime(0.001, t);
+ wg.gain.linearRampToValueAtTime(0.25, t + 0.05);
+ wg.gain.setValueAtTime(0.25, t + 0.5);
+ wg.gain.exponentialRampToValueAtTime(0.001, t + 0.6);
+
+ whistle.connect(wg);
+ wg.connect(masterGain);
+ whistle.start(t);
+ whistle.stop(t + 0.65);
+ nodes.push(whistle, wg);
+
+ /* Phase 2: Heavy explosion (0.6–1.4s) — noise burst */
+ var expNoise = ctx.createBufferSource();
+ expNoise.buffer = getNoiseBuffer();
+
+ var expBp = ctx.createBiquadFilter();
+ expBp.type = 'bandpass';
+ expBp.frequency.setValueAtTime(1000, t + 0.6);
+ expBp.frequency.exponentialRampToValueAtTime(150, t + dur);
+ expBp.Q.value = 0.8;
+
+ var expGain = ctx.createGain();
+ expGain.gain.setValueAtTime(0.001, t);
+ expGain.gain.setValueAtTime(0.001, t + 0.58);
+ expGain.gain.linearRampToValueAtTime(0.7, t + 0.62);
+ expGain.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ expNoise.connect(expBp);
+ expBp.connect(expGain);
+ expGain.connect(masterGain);
+ expNoise.start(t + 0.55);
+ expNoise.stop(t + dur);
+ nodes.push(expNoise, expBp, expGain);
+
+ /* Phase 2: Bass thud underneath explosion */
+ var bass = ctx.createOscillator();
+ bass.type = 'sine';
+ bass.frequency.setValueAtTime(100, t + 0.6);
+ bass.frequency.exponentialRampToValueAtTime(25, t + dur);
+
+ var bg = ctx.createGain();
+ bg.gain.setValueAtTime(0.001, t);
+ bg.gain.setValueAtTime(0.001, t + 0.58);
+ bg.gain.linearRampToValueAtTime(0.5, t + 0.63);
+ bg.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ bass.connect(bg);
+ bg.connect(masterGain);
+ bass.start(t + 0.55);
+ bass.stop(t + dur);
+ nodes.push(bass, bg);
+
+ cleanup(nodes, dur);
+ }
+
+ /* --- New sound: sonarPing — high-pitched scanning ping with long reverb --- */
+ function sonarPing() {
+ if (!ensureContext()) return;
+ var t = now(), dur = 1.5, nodes = [];
+
+ /* Primary ping — higher pitch than turnStart (~2000Hz) */
+ var ping = ctx.createOscillator();
+ ping.type = 'sine';
+ ping.frequency.setValueAtTime(2000, t);
+ ping.frequency.exponentialRampToValueAtTime(2100, t + 0.03);
+ ping.frequency.exponentialRampToValueAtTime(2000, t + dur);
+
+ /* Narrow bandpass for tight sonar character */
+ var bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.value = 2000;
+ bp.Q.value = 14;
+
+ var pg = ctx.createGain();
+ pg.gain.setValueAtTime(0.001, t);
+ pg.gain.linearRampToValueAtTime(0.2, t + 0.01);
+ pg.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ ping.connect(bp);
+ bp.connect(pg);
+ pg.connect(masterGain);
+ ping.start(t);
+ ping.stop(t + dur);
+ nodes.push(ping, bp, pg);
+
+ /* Echo 1 — delayed attenuated repeat */
+ var echo1 = ctx.createOscillator();
+ echo1.type = 'sine';
+ echo1.frequency.value = 2000;
+
+ var e1bp = ctx.createBiquadFilter();
+ e1bp.type = 'bandpass';
+ e1bp.frequency.value = 2000;
+ e1bp.Q.value = 16;
+
+ var e1g = ctx.createGain();
+ e1g.gain.setValueAtTime(0.001, t);
+ e1g.gain.setValueAtTime(0.001, t + 0.18);
+ e1g.gain.linearRampToValueAtTime(0.08, t + 0.2);
+ e1g.gain.exponentialRampToValueAtTime(0.001, t + 0.9);
+
+ echo1.connect(e1bp);
+ e1bp.connect(e1g);
+ e1g.connect(masterGain);
+ echo1.start(t);
+ echo1.stop(t + 1.0);
+ nodes.push(echo1, e1bp, e1g);
+
+ /* Echo 2 — even quieter, further delayed */
+ var echo2 = ctx.createOscillator();
+ echo2.type = 'sine';
+ echo2.frequency.value = 2000;
+
+ var e2bp = ctx.createBiquadFilter();
+ e2bp.type = 'bandpass';
+ e2bp.frequency.value = 2000;
+ e2bp.Q.value = 18;
+
+ var e2g = ctx.createGain();
+ e2g.gain.setValueAtTime(0.001, t);
+ e2g.gain.setValueAtTime(0.001, t + 0.4);
+ e2g.gain.linearRampToValueAtTime(0.03, t + 0.42);
+ e2g.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ echo2.connect(e2bp);
+ e2bp.connect(e2g);
+ e2g.connect(masterGain);
+ echo2.start(t);
+ echo2.stop(t + dur);
+ nodes.push(echo2, e2bp, e2g);
+
+ cleanup(nodes, dur);
+ }
+
+ /* --- New sound: fogReveal — gentle atmospheric whoosh --- */
+ function fogReveal() {
+ if (!ensureContext()) return;
+ var t = now(), dur = 0.5, nodes = [];
+
+ /* Filtered noise that swells then fades — like a curtain drawn */
+ var noise = ctx.createBufferSource();
+ noise.buffer = getNoiseBuffer();
+
+ var lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(800, t);
+ lp.frequency.linearRampToValueAtTime(3000, t + 0.15);
+ lp.frequency.exponentialRampToValueAtTime(600, t + dur);
+ lp.Q.value = 0.5;
+
+ var hp = ctx.createBiquadFilter();
+ hp.type = 'highpass';
+ hp.frequency.value = 300;
+
+ var ng = ctx.createGain();
+ ng.gain.setValueAtTime(0.001, t);
+ ng.gain.linearRampToValueAtTime(0.15, t + 0.12);
+ ng.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ noise.connect(hp);
+ hp.connect(lp);
+ lp.connect(ng);
+ ng.connect(masterGain);
+ noise.start(t);
+ noise.stop(t + dur);
+ nodes.push(noise, lp, hp, ng);
+
+ /* Subtle sine shimmer for an airy quality */
+ var shimmer = ctx.createOscillator();
+ shimmer.type = 'sine';
+ shimmer.frequency.value = 1200;
+
+ var sg = ctx.createGain();
+ sg.gain.setValueAtTime(0.001, t);
+ sg.gain.linearRampToValueAtTime(0.04, t + 0.1);
+ sg.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
+
+ shimmer.connect(sg);
+ sg.connect(masterGain);
+ shimmer.start(t);
+ shimmer.stop(t + 0.4);
+ nodes.push(shimmer, sg);
+
+ cleanup(nodes, dur);
+ }
+
+ /* --- New sound: weaponSelect — mechanical click with metallic resonance --- */
+ function weaponSelect() {
+ if (!ensureContext()) return;
+ var t = now(), dur = 0.15, nodes = [];
+
+ /* Quick square wave click */
+ var click = ctx.createOscillator();
+ click.type = 'square';
+ click.frequency.setValueAtTime(800, t);
+ click.frequency.exponentialRampToValueAtTime(200, t + 0.02);
+
+ var cg = ctx.createGain();
+ cg.gain.setValueAtTime(0.2, t);
+ cg.gain.exponentialRampToValueAtTime(0.001, t + 0.03);
+
+ click.connect(cg);
+ cg.connect(masterGain);
+ click.start(t);
+ click.stop(t + 0.04);
+ nodes.push(click, cg);
+
+ /* Metallic resonance — high-Q bandpassed sine ring */
+ var metal = ctx.createOscillator();
+ metal.type = 'sine';
+ metal.frequency.value = 3500;
+
+ var mbp = ctx.createBiquadFilter();
+ mbp.type = 'bandpass';
+ mbp.frequency.value = 3500;
+ mbp.Q.value = 20;
+
+ var mg = ctx.createGain();
+ mg.gain.setValueAtTime(0.001, t);
+ mg.gain.linearRampToValueAtTime(0.1, t + 0.005);
+ mg.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ metal.connect(mbp);
+ mbp.connect(mg);
+ mg.connect(masterGain);
+ metal.start(t);
+ metal.stop(t + dur);
+ nodes.push(metal, mbp, mg);
+
+ cleanup(nodes, dur);
+ }
+
+ /* --- New sound: radarSweep — quiet continuous sweeping tone --- */
+ function radarSweep() {
+ if (!ensureContext()) return;
+ var t = now(), dur = 2.0, nodes = [];
+
+ /* Low-frequency oscillator modulated by LFO */
+ var osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.value = 220;
+
+ /* LFO to create sweeping effect */
+ var lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.value = 0.5; /* one full sweep per 2 seconds */
+
+ var lfoGain = ctx.createGain();
+ lfoGain.gain.value = 80; /* modulation depth */
+
+ lfo.connect(lfoGain);
+ lfoGain.connect(osc.frequency);
+
+ /* Soft low-pass to keep it muted/ambient */
+ var lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.value = 500;
+ lp.Q.value = 1.0;
+
+ var og = ctx.createGain();
+ og.gain.setValueAtTime(0.001, t);
+ og.gain.linearRampToValueAtTime(0.08, t + 0.15);
+ og.gain.setValueAtTime(0.08, t + dur - 0.2);
+ og.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ osc.connect(lp);
+ lp.connect(og);
+ og.connect(masterGain);
+ osc.start(t);
+ osc.stop(t + dur);
+ lfo.start(t);
+ lfo.stop(t + dur);
+ nodes.push(osc, lp, og, lfo, lfoGain);
+
+ /* Secondary subtle noise layer for texture */
+ var noise = ctx.createBufferSource();
+ noise.buffer = getNoiseBuffer();
+
+ var nbp = ctx.createBiquadFilter();
+ nbp.type = 'bandpass';
+ nbp.frequency.value = 300;
+ nbp.Q.value = 3;
+
+ var nGain = ctx.createGain();
+ nGain.gain.setValueAtTime(0.001, t);
+ nGain.gain.linearRampToValueAtTime(0.02, t + 0.15);
+ nGain.gain.setValueAtTime(0.02, t + dur - 0.2);
+ nGain.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ noise.connect(nbp);
+ nbp.connect(nGain);
+ nGain.connect(masterGain);
+ noise.start(t);
+ noise.stop(t + dur);
+ nodes.push(noise, nbp, nGain);
+
+ cleanup(nodes, dur);
+ }
+
+ /* --- New sound: shipSinking — extended sinking with rumble, creaking, bubbling --- */
+ function shipSinking() {
+ if (!ensureContext()) return;
+ var t = now(), dur = 3.0, nodes = [];
+
+ /* (a) Deep rumble — low noise through low-pass */
+ var rumbleNoise = ctx.createBufferSource();
+ rumbleNoise.buffer = getNoiseBuffer();
+
+ var rumbleLp = ctx.createBiquadFilter();
+ rumbleLp.type = 'lowpass';
+ rumbleLp.frequency.setValueAtTime(200, t);
+ rumbleLp.frequency.exponentialRampToValueAtTime(60, t + dur);
+ rumbleLp.Q.value = 0.7;
+
+ var rumbleGain = ctx.createGain();
+ rumbleGain.gain.setValueAtTime(0.001, t);
+ rumbleGain.gain.linearRampToValueAtTime(0.4, t + 0.2);
+ rumbleGain.gain.setValueAtTime(0.35, t + 1.5);
+ rumbleGain.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ rumbleNoise.connect(rumbleLp);
+ rumbleLp.connect(rumbleGain);
+ rumbleGain.connect(masterGain);
+ rumbleNoise.start(t);
+ rumbleNoise.stop(t + dur);
+ nodes.push(rumbleNoise, rumbleLp, rumbleGain);
+
+ /* Deep bass sine for weight */
+ var bassDrone = ctx.createOscillator();
+ bassDrone.type = 'sine';
+ bassDrone.frequency.setValueAtTime(80, t);
+ bassDrone.frequency.exponentialRampToValueAtTime(20, t + dur);
+
+ var bassGain = ctx.createGain();
+ bassGain.gain.setValueAtTime(0.001, t);
+ bassGain.gain.linearRampToValueAtTime(0.35, t + 0.3);
+ bassGain.gain.setValueAtTime(0.3, t + 1.5);
+ bassGain.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ bassDrone.connect(bassGain);
+ bassGain.connect(masterGain);
+ bassDrone.start(t);
+ bassDrone.stop(t + dur);
+ nodes.push(bassDrone, bassGain);
+
+ /* (b) Creaking — modulated sawtooth through bandpass */
+ var creak = ctx.createOscillator();
+ creak.type = 'sawtooth';
+ creak.frequency.setValueAtTime(350, t + 0.3);
+ creak.frequency.linearRampToValueAtTime(120, t + 2.5);
+
+ var creakMod = ctx.createOscillator();
+ creakMod.type = 'sine';
+ creakMod.frequency.value = 4;
+
+ var creakModGain = ctx.createGain();
+ creakModGain.gain.value = 60;
+
+ creakMod.connect(creakModGain);
+ creakModGain.connect(creak.frequency);
+
+ var creakBp = ctx.createBiquadFilter();
+ creakBp.type = 'bandpass';
+ creakBp.frequency.value = 600;
+ creakBp.Q.value = 5;
+
+ var creakGain = ctx.createGain();
+ creakGain.gain.setValueAtTime(0.001, t);
+ creakGain.gain.setValueAtTime(0.001, t + 0.3);
+ creakGain.gain.linearRampToValueAtTime(0.18, t + 0.6);
+ creakGain.gain.setValueAtTime(0.15, t + 1.5);
+ creakGain.gain.linearRampToValueAtTime(0.1, t + 2.2);
+ creakGain.gain.exponentialRampToValueAtTime(0.001, t + 2.8);
+
+ creak.connect(creakBp);
+ creakBp.connect(creakGain);
+ creakGain.connect(masterGain);
+ creak.start(t + 0.3);
+ creak.stop(t + 2.8);
+ creakMod.start(t + 0.3);
+ creakMod.stop(t + 2.8);
+ nodes.push(creak, creakMod, creakModGain, creakBp, creakGain);
+
+ /* (c) Heavy bubbling — frequency-modulated sine */
+ var bubble = ctx.createOscillator();
+ bubble.type = 'sine';
+ bubble.frequency.setValueAtTime(500, t + 0.8);
+ bubble.frequency.exponentialRampToValueAtTime(150, t + dur);
+
+ var bubbleMod = ctx.createOscillator();
+ bubbleMod.type = 'sine';
+ bubbleMod.frequency.value = 18;
+
+ var bubbleModGain = ctx.createGain();
+ bubbleModGain.gain.value = 120;
+
+ bubbleMod.connect(bubbleModGain);
+ bubbleModGain.connect(bubble.frequency);
+
+ var bubbleGain = ctx.createGain();
+ bubbleGain.gain.setValueAtTime(0.001, t);
+ bubbleGain.gain.setValueAtTime(0.001, t + 0.8);
+ bubbleGain.gain.linearRampToValueAtTime(0.15, t + 1.2);
+ bubbleGain.gain.setValueAtTime(0.15, t + 2.0);
+ bubbleGain.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ bubble.connect(bubbleGain);
+ bubbleGain.connect(masterGain);
+ bubble.start(t + 0.8);
+ bubble.stop(t + dur);
+ bubbleMod.start(t + 0.8);
+ bubbleMod.stop(t + dur);
+ nodes.push(bubble, bubbleMod, bubbleModGain, bubbleGain);
+
+ /* Additional high bubble cluster for detail */
+ var hiBub = ctx.createOscillator();
+ hiBub.type = 'sine';
+ hiBub.frequency.setValueAtTime(800, t + 1.0);
+ hiBub.frequency.exponentialRampToValueAtTime(300, t + dur);
+
+ var hiBubMod = ctx.createOscillator();
+ hiBubMod.type = 'sine';
+ hiBubMod.frequency.value = 25;
+
+ var hiBubModGain = ctx.createGain();
+ hiBubModGain.gain.value = 80;
+
+ hiBubMod.connect(hiBubModGain);
+ hiBubModGain.connect(hiBub.frequency);
+
+ var hiBubGain = ctx.createGain();
+ hiBubGain.gain.setValueAtTime(0.001, t);
+ hiBubGain.gain.setValueAtTime(0.001, t + 1.0);
+ hiBubGain.gain.linearRampToValueAtTime(0.08, t + 1.3);
+ hiBubGain.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ hiBub.connect(hiBubGain);
+ hiBubGain.connect(masterGain);
+ hiBub.start(t + 1.0);
+ hiBub.stop(t + dur);
+ hiBubMod.start(t + 1.0);
+ hiBubMod.stop(t + dur);
+ nodes.push(hiBub, hiBubMod, hiBubModGain, hiBubGain);
+
+ cleanup(nodes, dur);
+ }
+
return {
init: init,
hit: hit,
@@ -388,6 +894,13 @@ window.SFX = (function () {
startAmbient: startAmbient,
stopAmbient: stopAmbient,
turnStart: turnStart,
- achievement: achievement
+ achievement: achievement,
+ torpedo: torpedo,
+ airstrike: airstrike,
+ sonarPing: sonarPing,
+ fogReveal: fogReveal,
+ weaponSelect: weaponSelect,
+ radarSweep: radarSweep,
+ shipSinking: shipSinking
};
})();
diff --git a/demos/battleships/js/weapons.js b/demos/battleships/js/weapons.js
new file mode 100644
index 0000000..5200e00
--- /dev/null
+++ b/demos/battleships/js/weapons.js
@@ -0,0 +1,132 @@
+(function () {
+ 'use strict';
+
+ var WEAPON_DEFS = {
+ torpedo: { name: 'Torpedo', icon: '\u{1F4A5}', maxUses: 2, needsDirection: true, areaSize: null },
+ airstrike: { name: 'Airstrike', icon: '\u{2708}\uFE0F', maxUses: 1, needsDirection: false, areaSize: 3 },
+ sonar: { name: 'Sonar Ping', icon: '\u{1F4E1}', maxUses: 2, needsDirection: false, areaSize: 3 }
+ };
+
+ function create() {
+ return {
+ torpedo: { uses: WEAPON_DEFS.torpedo.maxUses, maxUses: WEAPON_DEFS.torpedo.maxUses },
+ airstrike: { uses: WEAPON_DEFS.airstrike.maxUses, maxUses: WEAPON_DEFS.airstrike.maxUses },
+ sonar: { uses: WEAPON_DEFS.sonar.maxUses, maxUses: WEAPON_DEFS.sonar.maxUses },
+ active: null
+ };
+ }
+
+ function getAvailable(state) {
+ var result = [];
+ var types = ['torpedo', 'airstrike', 'sonar'];
+ for (var i = 0; i < types.length; i++) {
+ var type = types[i];
+ if (state[type].uses > 0) {
+ var def = WEAPON_DEFS[type];
+ result.push({
+ type: type,
+ name: def.name,
+ icon: def.icon,
+ uses: state[type].uses,
+ maxUses: state[type].maxUses
+ });
+ }
+ }
+ return result;
+ }
+
+ function activate(state, weaponType) {
+ if (!WEAPON_DEFS[weaponType]) return null;
+ if (state[weaponType].uses <= 0) return null;
+ state.active = weaponType;
+ var def = WEAPON_DEFS[weaponType];
+ return {
+ type: weaponType,
+ needsDirection: def.needsDirection,
+ areaSize: def.areaSize
+ };
+ }
+
+ function deactivate(state) {
+ state.active = null;
+ }
+
+ function isActive(state) {
+ return state.active !== null;
+ }
+
+ function getAreaCells(row, col, size) {
+ var cells = [];
+ var offset = Math.floor(size / 2);
+ var boardSize = Engine.BOARD_SIZE;
+ for (var r = row - offset; r <= row + offset; r++) {
+ for (var c = col - offset; c <= col + offset; c++) {
+ if (r >= 0 && r < boardSize && c >= 0 && c < boardSize) {
+ cells.push({ row: r, col: c });
+ }
+ }
+ }
+ return cells;
+ }
+
+ function getLineCells(row, col, direction) {
+ var cells = [];
+ var boardSize = Engine.BOARD_SIZE;
+ if (direction === 'horizontal') {
+ for (var c = 0; c < boardSize; c++) {
+ cells.push({ row: row, col: c });
+ }
+ } else {
+ for (var r = 0; r < boardSize; r++) {
+ cells.push({ row: r, col: col });
+ }
+ }
+ return cells;
+ }
+
+ function execute(state, board, ships, row, col, direction) {
+ var weaponType = state.active;
+ if (!weaponType) return null;
+ if (state[weaponType].uses <= 0) return null;
+
+ var result = { type: weaponType, cells: [] };
+
+ if (weaponType === 'torpedo') {
+ var lineCells = getLineCells(row, col, direction);
+ for (var i = 0; i < lineCells.length; i++) {
+ var lc = lineCells[i];
+ var shot = Engine.processShot(board, ships, lc.row, lc.col);
+ result.cells.push({ row: lc.row, col: lc.col, result: shot.result, shipName: shot.shipName, alreadyFired: shot.alreadyFired });
+ }
+ } else if (weaponType === 'airstrike') {
+ var areaCells = getAreaCells(row, col, 3);
+ for (var j = 0; j < areaCells.length; j++) {
+ var ac = areaCells[j];
+ var aShot = Engine.processShot(board, ships, ac.row, ac.col);
+ result.cells.push({ row: ac.row, col: ac.col, result: aShot.result, shipName: aShot.shipName, alreadyFired: aShot.alreadyFired });
+ }
+ } else if (weaponType === 'sonar') {
+ var sonarCells = getAreaCells(row, col, 3);
+ for (var k = 0; k < sonarCells.length; k++) {
+ var sc = sonarCells[k];
+ var cellValue = board[sc.row][sc.col];
+ var hasShip = cellValue === 'ship';
+ result.cells.push({ row: sc.row, col: sc.col, hasShip: hasShip });
+ }
+ }
+
+ state[weaponType].uses--;
+ state.active = null;
+
+ return result;
+ }
+
+ window.Weapons = {
+ create: create,
+ getAvailable: getAvailable,
+ activate: activate,
+ deactivate: deactivate,
+ isActive: isActive,
+ execute: execute
+ };
+})();
diff --git a/demos/battleships/styles/accessibility.css b/demos/battleships/styles/accessibility.css
index 6cbf613..59ef882 100644
--- a/demos/battleships/styles/accessibility.css
+++ b/demos/battleships/styles/accessibility.css
@@ -113,6 +113,36 @@ select:focus-visible,
background-size: auto;
}
+/* Sonar-revealed cells (empty): concentric ring pattern */
+.colorblind-mode .cell.sonar-revealed::after {
+ content: '';
+ position: absolute;
+ top: 50%; left: 50%;
+ width: 14px; height: 14px;
+ transform: translate(-50%, -50%);
+ border-radius: 50%;
+ border: 2px solid rgba(46, 204, 113, 0.7);
+ background: transparent;
+ box-shadow: 0 0 0 4px rgba(46, 204, 113, 0.2);
+ z-index: 3;
+ pointer-events: none;
+}
+
+/* Sonar-ship cells (ship detected): diamond marker */
+.colorblind-mode .cell.sonar-ship::after {
+ content: '';
+ position: absolute;
+ top: 50%; left: 50%;
+ width: 14px; height: 14px;
+ transform: translate(-50%, -50%) rotate(45deg);
+ border-radius: 0;
+ border: none;
+ background: rgba(46, 204, 113, 0.8);
+ box-shadow: 0 0 6px rgba(46, 204, 113, 0.5);
+ z-index: 3;
+ pointer-events: none;
+}
+
/* Ship cells: horizontal stripe pattern */
.colorblind-mode .cell.ship {
background-image:
@@ -317,3 +347,66 @@ select:focus-visible,
display: none !important;
}
}
+
+
+/* ============================================================
+ TOUCH-SPECIFIC FOCUS STYLES — Larger focus rings on touch
+ ============================================================ */
+
+@media (pointer: coarse) {
+ *:focus-visible {
+ outline-width: 4px;
+ outline-offset: 4px;
+ }
+
+ .cell:focus-visible {
+ outline-offset: -4px;
+ }
+}
+
+
+/* ============================================================
+ SAFE AREA INSETS — Settings panel on notched devices
+ ============================================================ */
+
+@supports (padding-bottom: env(safe-area-inset-bottom)) {
+ @media (max-width: 768px) {
+ #settings-panel {
+ padding-bottom: calc(1rem + env(safe-area-inset-bottom));
+ padding-left: calc(1.4rem + env(safe-area-inset-left));
+ padding-right: calc(1.4rem + env(safe-area-inset-right));
+ }
+ }
+}
+
+
+/* ============================================================
+ TOUCH TARGET SIZE — Accessibility toggles on touch devices
+ ============================================================ */
+
+@media (pointer: coarse) {
+ #settings-panel input[type="checkbox"] {
+ width: 24px;
+ height: 24px;
+ min-width: 44px;
+ min-height: 44px;
+ cursor: pointer;
+ }
+
+ #settings-panel select {
+ min-height: 44px;
+ padding: 0.5rem 2rem 0.5rem 0.8rem;
+ font-size: 1rem;
+ }
+
+ #settings-panel input[type="range"] {
+ min-height: 44px;
+ cursor: pointer;
+ }
+
+ /* Ensure adequate spacing between toggle rows */
+ #settings-panel > div[style*="display:flex"] {
+ min-height: 44px;
+ gap: 1rem;
+ }
+}
diff --git a/demos/battleships/styles/animations.css b/demos/battleships/styles/animations.css
new file mode 100644
index 0000000..ff0ef5f
--- /dev/null
+++ b/demos/battleships/styles/animations.css
@@ -0,0 +1,122 @@
+/* ============================================================
+ BATTLESHIPS — Enhanced Visual Animations
+ ============================================================
+ New CSS animations for torpedo hits, radar sweep, sinking,
+ airstrike, and sonar effects. Uses CSS variables from main.css.
+ ============================================================ */
+
+/* -- Torpedo Hit: bright white-to-cyan flash -- */
+@keyframes torpedoHit {
+ 0% { background: #ffffff; box-shadow: inset 0 0 20px rgba(255,255,255,0.9), 0 0 30px rgba(79,195,247,0.8); }
+ 40% { background: #4fc3f7; box-shadow: inset 0 0 14px rgba(79,195,247,0.6), 0 0 20px rgba(79,195,247,0.4); }
+ 100% { background: var(--signal-red); box-shadow: inset 0 0 8px rgba(231,76,60,0.3); }
+}
+
+.cell.torpedo-hit {
+ animation: torpedoHit 0.3s ease-out;
+ z-index: 2;
+}
+
+/* -- Radar Sweep Overlay: rotating conic gradient over enemy grid -- */
+@keyframes radarRotate {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.radar-sweep-overlay {
+ position: absolute;
+ inset: 0;
+ border-radius: var(--board-radius, 8px);
+ pointer-events: none;
+ z-index: 5;
+ overflow: hidden;
+}
+
+.radar-sweep-overlay::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 200%;
+ height: 200%;
+ transform-origin: center center;
+ background: conic-gradient(
+ from 0deg,
+ transparent 0deg,
+ rgba(46,204,113,0.15) 20deg,
+ rgba(46,204,113,0.06) 40deg,
+ transparent 60deg,
+ transparent 360deg
+ );
+ animation: radarRotate 2s linear infinite;
+ margin-top: -100%;
+ margin-left: -100%;
+}
+
+/* -- Sinking Ship: tilt, translate down, fade -- */
+@keyframes sinkingShip {
+ 0% { transform: rotate(0deg) translateY(0); opacity: 1; }
+ 30% { transform: rotate(3deg) translateY(2px); opacity: 0.9; }
+ 60% { transform: rotate(-2deg) translateY(6px); opacity: 0.6; }
+ 100% { transform: rotate(5deg) translateY(12px); opacity: 0.3; }
+}
+
+.cell.sinking-ship {
+ animation: sinkingShip 1s ease-in forwards;
+}
+
+/* -- Airstrike Target: red flash sequence before impact -- */
+@keyframes airstrikeFlash {
+ 0% { box-shadow: inset 0 0 0 0 rgba(231,76,60,0); }
+ 20% { box-shadow: inset 0 0 16px rgba(231,76,60,0.7); }
+ 40% { box-shadow: inset 0 0 4px rgba(231,76,60,0.2); }
+ 60% { box-shadow: inset 0 0 20px rgba(255,61,0,0.8); }
+ 80% { box-shadow: inset 0 0 6px rgba(231,76,60,0.3); }
+ 100% { box-shadow: inset 0 0 24px rgba(255,107,0,0.9), 0 0 12px rgba(255,107,0,0.4); }
+}
+
+.cell.airstrike-target {
+ animation: airstrikeFlash 0.5s ease-in-out;
+ z-index: 2;
+}
+
+/* -- Sonar Revealed: green pulse glow on reveal -- */
+@keyframes sonarReveal {
+ 0% { box-shadow: inset 0 0 0 0 rgba(46,204,113,0); }
+ 30% { box-shadow: inset 0 0 14px rgba(46,204,113,0.5), 0 0 10px rgba(46,204,113,0.3); }
+ 60% { box-shadow: inset 0 0 6px rgba(46,204,113,0.2), 0 0 4px rgba(46,204,113,0.15); }
+ 100% { box-shadow: inset 0 0 0 0 rgba(46,204,113,0); }
+}
+
+.cell.sonar-revealed {
+ animation: sonarReveal 0.8s ease-out;
+}
+
+/* -- Sonar Ship: detected ship outline pulse -- */
+@keyframes sonarShipPulse {
+ 0% { outline: 2px solid rgba(46,204,113,0); outline-offset: 0; }
+ 30% { outline: 2px solid rgba(46,204,113,0.7); outline-offset: -2px; }
+ 60% { outline: 2px solid rgba(46,204,113,0.3); outline-offset: -1px; }
+ 100% { outline: 2px solid rgba(46,204,113,0.5); outline-offset: -2px; }
+}
+
+.cell.sonar-ship {
+ animation: sonarShipPulse 1s ease-in-out;
+ outline: 2px solid rgba(46,204,113,0.5);
+ outline-offset: -2px;
+}
+
+/* ============================================================
+ Reduced Motion: disable animations for accessibility
+ ============================================================ */
+.reduced-motion .cell.torpedo-hit,
+.reduced-motion .cell.sinking-ship,
+.reduced-motion .cell.airstrike-target,
+.reduced-motion .cell.sonar-revealed,
+.reduced-motion .cell.sonar-ship {
+ animation: none;
+}
+
+.reduced-motion .radar-sweep-overlay::before {
+ animation: none;
+}
diff --git a/demos/battleships/styles/drag-placement.css b/demos/battleships/styles/drag-placement.css
new file mode 100644
index 0000000..62dacd9
--- /dev/null
+++ b/demos/battleships/styles/drag-placement.css
@@ -0,0 +1,90 @@
+/* ============================================================
+ DRAG-AND-DROP SHIP PLACEMENT — Visual Feedback
+ ============================================================ */
+
+/* -- Draggable ship options -- */
+.ship-option[draggable="true"] {
+ cursor: grab;
+ transition: all var(--transition-mid);
+}
+
+.ship-option[draggable="true"]:active {
+ cursor: grabbing;
+}
+
+.ship-option.dragging {
+ opacity: 0.35;
+ transform: scale(0.92);
+ filter: grayscale(0.3);
+ box-shadow: none;
+}
+
+/* -- Ghost element following cursor during drag -- */
+.drag-ghost {
+ position: fixed;
+ z-index: 10000;
+ pointer-events: none;
+ display: flex;
+ gap: 1px;
+ opacity: 0.85;
+ filter: drop-shadow(0 2px 8px rgba(46, 204, 113, 0.3));
+ transition: none;
+}
+
+.drag-ghost .drag-ghost-cell {
+ width: var(--cell-size, 40px);
+ height: var(--cell-size, 40px);
+ background: linear-gradient(145deg, rgba(74, 111, 165, 0.7) 0%, rgba(61, 93, 138, 0.7) 50%, rgba(74, 111, 165, 0.7) 100%);
+ border: 1px solid rgba(90, 138, 191, 0.5);
+ border-radius: 2px;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 0 rgba(0, 0, 0, 0.15);
+}
+
+.drag-ghost .drag-ghost-cell:first-child {
+ border-radius: 4px 2px 2px 4px;
+}
+
+.drag-ghost .drag-ghost-cell:last-child {
+ border-radius: 2px 4px 4px 2px;
+}
+
+.drag-ghost.vertical {
+ flex-direction: column;
+}
+
+.drag-ghost.vertical .drag-ghost-cell:first-child {
+ border-radius: 4px 4px 2px 2px;
+}
+
+.drag-ghost.vertical .drag-ghost-cell:last-child {
+ border-radius: 2px 2px 4px 4px;
+}
+
+/* -- Drop zone highlights on grid cells -- */
+.cell.drop-valid {
+ background: rgba(46, 204, 113, 0.35);
+ box-shadow: inset 0 0 12px rgba(46, 204, 113, 0.3);
+ border-color: rgba(46, 204, 113, 0.4);
+ z-index: 2;
+}
+
+.cell.drop-invalid {
+ background: rgba(231, 76, 60, 0.35);
+ box-shadow: inset 0 0 12px rgba(231, 76, 60, 0.3);
+ border-color: rgba(231, 76, 60, 0.4);
+ z-index: 2;
+}
+
+.cell.drop-hover {
+ background: rgba(46, 204, 113, 0.5);
+ box-shadow: inset 0 0 16px rgba(46, 204, 113, 0.4), 0 0 8px rgba(46, 204, 113, 0.2);
+ border-color: rgba(46, 204, 113, 0.5);
+ z-index: 3;
+}
+
+/* Override drop-hover when invalid */
+.cell.drop-invalid.drop-hover {
+ background: rgba(231, 76, 60, 0.5);
+ box-shadow: inset 0 0 16px rgba(231, 76, 60, 0.4), 0 0 8px rgba(231, 76, 60, 0.2);
+ border-color: rgba(231, 76, 60, 0.5);
+}
diff --git a/demos/battleships/styles/fog-of-war.css b/demos/battleships/styles/fog-of-war.css
new file mode 100644
index 0000000..5dd1a81
--- /dev/null
+++ b/demos/battleships/styles/fog-of-war.css
@@ -0,0 +1,77 @@
+/* ============================================================
+ FOG OF WAR — Visual overlay for the enemy grid
+ ============================================================ */
+
+/* -- Fog noise texture animation -- */
+@keyframes fogNoise {
+ 0% { background-position: 0% 0%; }
+ 25% { background-position: 50% 30%; }
+ 50% { background-position: 100% 60%; }
+ 75% { background-position: 30% 100%; }
+ 100% { background-position: 0% 0%; }
+}
+
+/* -- Fog lift animation -- */
+@keyframes fogReveal {
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 40% {
+ opacity: 0.4;
+ transform: scale(1.06);
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(1);
+ }
+}
+
+/* -- Fogged cell overlay -- */
+.cell.fogged {
+ cursor: crosshair;
+}
+
+.cell.fogged::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ background:
+ rgba(6, 14, 24, 0.92);
+ background-image:
+ repeating-conic-gradient(
+ rgba(255, 255, 255, 0.012) 0%,
+ transparent 0.8%,
+ transparent 1.6%,
+ rgba(119, 141, 169, 0.015) 2.4%
+ );
+ background-size: 60px 60px;
+ animation: fogNoise 6s linear infinite;
+ pointer-events: none;
+ opacity: 1;
+}
+
+/* Show the cell is clickable on hover */
+.cell.fogged:hover::before {
+ background-color: rgba(6, 14, 24, 0.82);
+}
+
+/* -- Fog revealing animation -- */
+.cell.fog-revealing::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ background: rgba(6, 14, 24, 0.92);
+ pointer-events: none;
+ animation: fogReveal 0.5s ease-out forwards;
+}
+
+/* Reduced motion support */
+.reduced-motion .cell.fogged::before {
+ animation: none;
+}
+.reduced-motion .cell.fog-revealing::before {
+ animation: none;
+}
diff --git a/demos/battleships/styles/main.css b/demos/battleships/styles/main.css
index 7ef5473..46a5122 100644
--- a/demos/battleships/styles/main.css
+++ b/demos/battleships/styles/main.css
@@ -1342,3 +1342,4 @@ select:focus-visible,
outline: 3px solid var(--signal-green);
outline-offset: -3px;
}
+
diff --git a/demos/battleships/styles/mobile.css b/demos/battleships/styles/mobile.css
new file mode 100644
index 0000000..c596e5c
--- /dev/null
+++ b/demos/battleships/styles/mobile.css
@@ -0,0 +1,393 @@
+/* ============================================================
+ BATTLESHIPS — Mobile & Touch Styles
+ Responsive grids, touch-friendly controls, bottom-anchored
+ layout, long-press feedback, landscape tablet, safe areas
+ ============================================================ */
+
+/* ============================================================
+ 1. TOUCH INTERACTION STYLES
+ Prevent double-tap zoom, disable callout, prevent scroll
+ interference during gameplay
+ ============================================================ */
+
+.grid {
+ touch-action: manipulation;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+/* During battle phase, prevent all touch gestures on grids */
+.game-container.battle-phase .grid {
+ touch-action: none;
+}
+
+.cell {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+#placement-panel {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+/* ============================================================
+ 2. TOUCH-FRIENDLY CONTROLS — pointer: coarse
+ Increase tap targets to minimum 44px, add spacing
+ ============================================================ */
+
+@media (pointer: coarse) {
+ /* Buttons get larger tap targets */
+ button,
+ .sound-toggle,
+ #rotate-btn,
+ #auto-place-btn,
+ #play-again-btn,
+ #start-game-btn,
+ #pause-btn,
+ #settings-btn,
+ #resume-btn {
+ min-height: 44px;
+ min-width: 44px;
+ padding: 0.5rem 1rem;
+ }
+
+ /* Ship options need larger touch area */
+ .ship-option {
+ min-height: 44px;
+ padding: 0.5rem 1rem;
+ }
+
+ /* Mode cards need adequate tap target */
+ .mode-card {
+ min-height: 44px;
+ }
+
+ /* Ensure spacing between interactive elements */
+ .top-controls {
+ gap: 0.5rem;
+ }
+
+ #placement-panel {
+ gap: 0.5rem 0.6rem;
+ }
+
+ /* Larger select dropdowns */
+ .control-group select,
+ #difficulty-select {
+ min-height: 44px;
+ padding: 0.5rem 2rem 0.5rem 0.8rem;
+ font-size: 1rem;
+ }
+
+ /* Cells: ensure adequate spacing and size */
+ .cell {
+ min-width: 28px;
+ min-height: 28px;
+ }
+}
+
+/* ============================================================
+ 3. RESPONSIVE GRID SIZING — Small screens
+ ============================================================ */
+
+@media (max-width: 480px) {
+ :root {
+ --cell-size: 28px;
+ --label-size: 18px;
+ }
+
+ .grid {
+ max-width: calc(var(--label-size) + 10 * var(--cell-size) + 2px);
+ overflow: visible;
+ }
+
+ .boards-container {
+ padding: 0.8rem 0.4rem;
+ gap: 1rem;
+ }
+
+ header h1 {
+ font-size: 1.3rem;
+ letter-spacing: 0.1em;
+ }
+
+ header .subtitle {
+ font-size: 0.7rem;
+ }
+
+ header {
+ padding: 1rem 0.8rem 0.6rem;
+ }
+
+ #status-message {
+ font-size: 0.85rem;
+ padding: 0.45rem 0.8rem;
+ min-height: 2.2rem;
+ }
+
+ #placement-panel {
+ padding: 0.6rem;
+ gap: 0.4rem;
+ }
+
+ .ship-option {
+ font-size: 0.72rem;
+ padding: 0.3rem 0.5rem;
+ }
+
+ .placement-instructions {
+ font-size: 0.7rem;
+ }
+
+ .bottom-panels {
+ padding: 0.6rem 0.4rem;
+ gap: 0.8rem;
+ }
+
+ .fleet-status,
+ .combat-log {
+ padding: 0.7rem 0.8rem;
+ min-width: unset;
+ }
+
+ .game-over-box {
+ padding: 1.5rem 1rem;
+ }
+
+ #game-over-message {
+ font-size: 1.3rem;
+ }
+
+ .stat-row {
+ font-size: 0.8rem;
+ }
+
+ .stats-panel {
+ width: 96%;
+ }
+}
+
+@media (max-width: 360px) {
+ :root {
+ --cell-size: 24px;
+ --label-size: 16px;
+ }
+
+ .label {
+ font-size: 0.55rem;
+ }
+
+ header h1 {
+ font-size: 1.1rem;
+ }
+
+ .boards-container {
+ padding: 0.5rem 0.2rem;
+ }
+
+ .ship-option {
+ font-size: 0.68rem;
+ padding: 0.25rem 0.4rem;
+ }
+
+ #rotate-btn,
+ #auto-place-btn {
+ font-size: 0.75rem;
+ padding: 0.35rem 0.6rem;
+ }
+}
+
+/* ============================================================
+ 4. BOTTOM-ANCHORED LAYOUT — Mobile
+ Weapon panel and key controls fixed to bottom on small screens
+ ============================================================ */
+
+@media (max-width: 768px) {
+ .mobile-bottom-bar {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 60;
+ background: linear-gradient(180deg, rgba(6, 14, 24, 0.9) 0%, rgba(6, 14, 24, 0.98) 100%);
+ border-top: 1px solid var(--glass-border);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ padding: 0.6rem 0.8rem;
+ padding-bottom: calc(0.6rem + env(safe-area-inset-bottom, 0px));
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ }
+
+ /* When bottom bar is present, add padding so content isn't hidden */
+ .game-container.has-bottom-bar {
+ padding-bottom: 80px;
+ }
+
+ /* Stack boards vertically */
+ .boards-container {
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.8rem 0.5rem;
+ }
+
+ /* Bottom panels stack vertically */
+ .bottom-panels {
+ flex-direction: column;
+ align-items: center;
+ gap: 0.8rem;
+ padding: 0.6rem;
+ }
+
+ .fleet-status,
+ .combat-log {
+ max-width: 100%;
+ width: 96%;
+ }
+
+ /* Compact top controls */
+ .top-controls {
+ gap: 0.4rem;
+ padding: 0.4rem 0.6rem;
+ }
+}
+
+/* ============================================================
+ 5. LONG-PRESS VISUAL FEEDBACK
+ ============================================================ */
+
+.long-press-active {
+ transform: scale(0.95);
+ transition: transform 0.15s ease;
+}
+
+.long-press-active .cell {
+ box-shadow: inset 0 0 12px rgba(46, 204, 113, 0.4), 0 0 8px rgba(46, 204, 113, 0.3);
+}
+
+@keyframes touchRipple {
+ 0% {
+ transform: translate(-50%, -50%) scale(0);
+ opacity: 0.5;
+ }
+ 100% {
+ transform: translate(-50%, -50%) scale(4);
+ opacity: 0;
+ }
+}
+
+.touch-ripple {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: rgba(46, 204, 113, 0.3);
+ pointer-events: none;
+ z-index: 10;
+ animation: touchRipple 0.6s ease-out forwards;
+}
+
+/* ============================================================
+ 6. LANDSCAPE TABLET LAYOUT
+ Side-by-side grids with reduced gap, compact bottom panels
+ ============================================================ */
+
+@media (min-width: 768px) and (orientation: landscape) and (max-width: 1024px) {
+ :root {
+ --cell-size: 32px;
+ --label-size: 22px;
+ }
+
+ .boards-container {
+ flex-direction: row;
+ flex-wrap: nowrap;
+ gap: 1.5rem;
+ padding: 0.8rem;
+ }
+
+ .bottom-panels {
+ flex-direction: row;
+ flex-wrap: nowrap;
+ gap: 1rem;
+ padding: 0.5rem 0.8rem;
+ }
+
+ .fleet-status,
+ .combat-log {
+ min-width: 160px;
+ max-width: none;
+ flex: 1;
+ }
+
+ .combat-log {
+ max-height: 180px;
+ }
+
+ header {
+ padding: 0.8rem 1rem 0.5rem;
+ }
+
+ header h1 {
+ font-size: 1.6rem;
+ }
+
+ #status-message {
+ margin: 0.4rem auto;
+ padding: 0.5rem 1rem;
+ font-size: 0.95rem;
+ }
+
+ #placement-panel {
+ margin: 0.4rem auto;
+ padding: 0.7rem 1rem;
+ }
+
+ .stats-panel {
+ max-width: 90%;
+ }
+}
+
+/* ============================================================
+ 7. SAFE AREA SUPPORT — Notched devices
+ ============================================================ */
+
+@supports (padding-bottom: env(safe-area-inset-bottom)) {
+ .mobile-bottom-bar {
+ padding-bottom: calc(0.6rem + env(safe-area-inset-bottom));
+ }
+
+ @media (max-width: 768px) {
+ .game-container {
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
+ }
+
+ #game-over-overlay .game-over-box {
+ padding-bottom: calc(2rem + env(safe-area-inset-bottom));
+ }
+ }
+}
+
+/* ============================================================
+ REDUCED MOTION — Disable touch animations
+ ============================================================ */
+
+@media (prefers-reduced-motion: reduce) {
+ .long-press-active {
+ transform: none;
+ transition: none;
+ }
+
+ .touch-ripple {
+ animation: none;
+ display: none;
+ }
+}
diff --git a/demos/battleships/styles/weapons.css b/demos/battleships/styles/weapons.css
new file mode 100644
index 0000000..2dc1e60
--- /dev/null
+++ b/demos/battleships/styles/weapons.css
@@ -0,0 +1,241 @@
+/* ============================================================
+ WEAPONS — Special Weapons System Styles
+ ============================================================ */
+
+/* -- Weapons Panel -- */
+.weapons-panel {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.8rem;
+ margin: 0.6rem auto;
+ padding: 0.6rem 1rem;
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ border-radius: 8px;
+ max-width: 500px;
+ width: 92%;
+ backdrop-filter: blur(var(--glass-blur));
+ box-shadow: var(--shadow-subtle);
+ animation: fadeIn 0.4s ease-out;
+ position: relative;
+}
+
+.weapons-panel::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, var(--glass-highlight), transparent);
+}
+
+/* -- Weapon Button -- */
+.weapon-btn {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.5rem 0.8rem;
+ background: rgba(30, 58, 95, 0.6);
+ border: 1px solid var(--steel-dark);
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all var(--transition-mid);
+ user-select: none;
+ position: relative;
+ overflow: hidden;
+ font-family: inherit;
+ color: var(--text-primary);
+ min-width: 80px;
+}
+
+.weapon-btn::before {
+ content: '';
+ position: absolute;
+ top: 0; left: -100%;
+ width: 200%; height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent);
+ transition: left 0.5s ease;
+}
+
+.weapon-btn:hover::before {
+ left: 100%;
+}
+
+.weapon-btn:hover {
+ background: rgba(36, 80, 110, 0.7);
+ border-color: var(--steel-mid);
+}
+
+.weapon-btn:active {
+ transform: scale(0.95);
+}
+
+/* -- Active Weapon -- */
+.weapon-btn.active {
+ background: linear-gradient(135deg, rgba(46, 204, 113, 0.2), rgba(39, 174, 96, 0.3));
+ border-color: var(--signal-green);
+ box-shadow: 0 0 12px var(--signal-green-glow), inset 0 0 8px var(--signal-green-glow);
+ animation: pulseGlow 2s ease-in-out infinite;
+}
+
+/* -- Exhausted Weapon -- */
+.weapon-btn.exhausted {
+ opacity: 0.35;
+ pointer-events: none;
+ filter: grayscale(0.6);
+ cursor: not-allowed;
+}
+
+/* -- Weapon Icon -- */
+.weapon-btn .weapon-icon {
+ font-size: 1.4rem;
+ line-height: 1;
+}
+
+/* -- Weapon Name -- */
+.weapon-btn .weapon-name {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-muted);
+ font-weight: 600;
+}
+
+/* -- Weapon Uses Badge -- */
+.weapon-btn .weapon-uses {
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ min-width: 16px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.6rem;
+ font-weight: 700;
+ background: var(--ocean-mid);
+ border: 1px solid var(--glass-border);
+ border-radius: 8px;
+ color: var(--text-primary);
+ padding: 0 4px;
+}
+
+.weapon-btn.active .weapon-uses {
+ background: var(--signal-green-dark);
+ border-color: var(--signal-green);
+ color: var(--ocean-abyss);
+}
+
+/* -- Direction Picker Overlay -- */
+.direction-picker {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(6, 14, 24, 0.7);
+ backdrop-filter: blur(6px);
+ animation: fadeIn 0.2s ease-out;
+}
+
+.direction-picker-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ padding: 1.5rem 2rem;
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ border-radius: 10px;
+ box-shadow: var(--shadow-heavy);
+ backdrop-filter: blur(16px);
+ animation: fadeInScale 0.3s ease-out;
+}
+
+.direction-picker-box h3 {
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--text-muted);
+ font-weight: 700;
+}
+
+.direction-picker-buttons {
+ display: flex;
+ gap: 1rem;
+}
+
+.direction-btn {
+ padding: 0.6rem 1.4rem;
+ background: rgba(44, 62, 80, 0.7);
+ color: var(--text-primary);
+ border: 1px solid var(--steel-light);
+ border-radius: 6px;
+ font-size: 0.9rem;
+ font-weight: 600;
+ font-family: inherit;
+ cursor: pointer;
+ transition: all var(--transition-mid);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.direction-btn:hover {
+ background: var(--steel-light);
+ color: var(--text-primary);
+ box-shadow: var(--shadow-glow-blue);
+}
+
+.direction-btn:active {
+ transform: scale(0.95);
+}
+
+/* -- Sonar reveal indicator -- */
+.cell.sonar-revealed {
+ box-shadow: inset 0 0 12px var(--signal-green-glow), 0 0 8px var(--signal-green-glow);
+ border-color: rgba(46, 204, 113, 0.4);
+}
+
+.cell.sonar-revealed::after {
+ content: '\u2022';
+ position: absolute;
+ top: 50%; left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 1.2rem;
+ color: var(--signal-green);
+ z-index: 3;
+ pointer-events: none;
+ text-shadow: 0 0 6px var(--signal-green-glow);
+}
+
+.cell.sonar-revealed:not(.sonar-ship) {
+ box-shadow: inset 0 0 8px rgba(119, 141, 169, 0.15);
+ border-color: rgba(119, 141, 169, 0.3);
+}
+
+/* -- Responsive -- */
+@media (max-width: 520px) {
+ .weapons-panel {
+ gap: 0.5rem;
+ padding: 0.5rem 0.6rem;
+ }
+
+ .weapon-btn {
+ min-width: 65px;
+ padding: 0.4rem 0.5rem;
+ }
+
+ .weapon-btn .weapon-icon {
+ font-size: 1.1rem;
+ }
+
+ .weapon-btn .weapon-name {
+ font-size: 0.58rem;
+ }
+
+ .direction-picker-box {
+ padding: 1rem 1.2rem;
+ }
+}
diff --git a/docs/reddit-reply-graduated-discipline.md b/docs/reddit-reply-graduated-discipline.md
new file mode 100644
index 0000000..2af4ec6
--- /dev/null
+++ b/docs/reddit-reply-graduated-discipline.md
@@ -0,0 +1,13 @@
+# Graduated Discipline
+
+Yes — corrections are logged, but per-mission, not in a persistent cross-mission store.
+
+The escalation ladder has three levels:
+
+1. **Signal** — first occurrence, admiral references the relevant standing order in a coordination message.
+2. **Standing Order Remedy** — repeated or moderate impact. Apply the formal remedy and log it in the quarterdeck report.
+3. **Damage Control** — mission-threatening. Invoke a full procedure (replace agent, partial rollback, abort, or escalate to human).
+
+Levels aren't skipped unless the issue is immediately critical.
+
+At mission end the Captain's Log captures decisions, failure modes, and reusable patterns. The partial rollback procedure also feeds the failure mode back as a constraint when re-executing the task. So the tuning signal exists — it just lives in structured logs rather than an automated feedback loop across missions. A natural next step would be tracking correction frequency per anti-pattern and using that to adjust defaults, but that's not built yet.
diff --git a/skills/nelson/SKILL.md b/skills/nelson/SKILL.md
index c93d189..23d0602 100644
--- a/skills/nelson/SKILL.md
+++ b/skills/nelson/SKILL.md
@@ -29,6 +29,7 @@ You MUST read `references/admiralty-templates.md` and use the sailing-orders tem
- Do not exceed 10 squadron-level agents (admiral, captains, red-cell navigator). Crew are additional.
- Assign each captain a ship name from `references/crew-roles.md` matching task weight (frigate for general, destroyer for high-risk, patrol vessel for small, flagship for critical-path, submarine for research).
- Captain decides crew composition per ship using the crew-or-direct decision tree in `references/crew-roles.md`.
+- Captains may also deploy Royal Marines during execution for short-lived sorties — see `references/royal-marines.md`.
You MUST read `references/squadron-composition.md` for selection rules.
You MUST read `references/crew-roles.md` for ship naming and crew composition.
@@ -40,7 +41,7 @@ You MUST consult `references/standing-orders.md` before forming the squadron.
- Assign owner for each task and explicit dependencies.
- Assign file ownership when implementation touches code.
- Keep one task in progress per agent unless the mission explicitly requires multitasking.
-- For each captain's task, include a ship manifest. If crew are mustered, list crew roles with sub-tasks and sequence. If the captain implements directly (0 crew), note "Captain implements directly."
+- For each captain's task, include a ship manifest. If crew are mustered, list crew roles with sub-tasks and sequence. If the captain implements directly (0 crew), note "Captain implements directly." If the captain anticipates needing marine support, note marine capacity in the ship manifest (max 2).
You MUST read `references/admiralty-templates.md` for the battle plan and ship manifest template.
You MUST consult `references/standing-orders.md` when assigning files or if scope is unclear.
@@ -57,6 +58,7 @@ You MUST consult `references/standing-orders.md` when assigning files or if scop
- Update progress by task state: `pending`, `in_progress`, `completed`.
- Identify blockers and choose a concrete next action.
- Confirm each crew member has active sub-tasks; flag idle crew or role mismatches.
+- Check for active marine deployments; verify marines have returned and outputs are incorporated.
- Track burn against token/time budget.
- Re-scope early when a task drifts from mission metric.
- When a mission encounters difficulties, you MUST consult `references/damage-control.md` for recovery and escalation procedures.
@@ -77,6 +79,7 @@ You MUST use `references/commendations.md` for recognition signals and graduated
- Agent idle with unverified outputs.
- Before final synthesis.
- For crewed tasks, verify crew outputs align with role boundaries (consult `references/crew-roles.md` and `references/standing-orders.md` if role violations are detected).
+- Marine deployments follow station-tier rules in `references/royal-marines.md`. Station 2+ marine deployments require admiral approval.
You MUST consult `references/standing-orders.md` if tasks lack a tier or red-cell is assigned implementation work.
diff --git a/skills/nelson/references/action-stations.md b/skills/nelson/references/action-stations.md
index 54e46d4..45039ee 100644
--- a/skills/nelson/references/action-stations.md
+++ b/skills/nelson/references/action-stations.md
@@ -93,3 +93,11 @@ Run this checklist for Station 1+ tasks.
- What is the fastest safe rollback?
- What dependency could invalidate this plan?
- What assumption is least certain?
+
+## Marine Deployments
+
+Marine deployments inherit the parent ship's station tier:
+
+- **Station 0-1:** Captain deploys at discretion. No admiral approval required.
+- **Station 2:** Captain must signal admiral and receive approval before deploying marines.
+- **Station 3:** Marine deployment is not permitted. All Trafalgar-tier work requires explicit Admiralty (human) confirmation.
diff --git a/skills/nelson/references/admiralty-templates.md b/skills/nelson/references/admiralty-templates.md
index 4b60981..53430b8 100644
--- a/skills/nelson/references/admiralty-templates.md
+++ b/skills/nelson/references/admiralty-templates.md
@@ -10,4 +10,5 @@ Consult the specific template for the current workflow step.
| Run Quarterdeck Rhythm | `admiralty-templates/quarterdeck-report.md` |
| Set Action Stations (red-cell review) | `admiralty-templates/red-cell-review.md` |
| Brief crew for dispatch | `admiralty-templates/crew-briefing.md` |
+| Deploy marines from a ship | `admiralty-templates/marine-deployment-brief.md` |
| Stand Down And Log Action | `admiralty-templates/captains-log.md` |
diff --git a/skills/nelson/references/admiralty-templates/crew-briefing.md b/skills/nelson/references/admiralty-templates/crew-briefing.md
index 10da9d2..2eafbd5 100644
--- a/skills/nelson/references/admiralty-templates/crew-briefing.md
+++ b/skills/nelson/references/admiralty-templates/crew-briefing.md
@@ -14,11 +14,28 @@ Deliverable: [what you must produce]
Action Station: [0-3] — [Patrol / Caution / Action / Trafalgar]
File Ownership: [files you own — no other agent should edit these]
Dependencies: [tasks that must complete before yours / tasks waiting on yours]
+Marine Capacity: [0-2, from ship manifest — omit line if 0]
Standing Orders:
- Do NOT implement work outside your assigned task scope
- Do NOT edit files not assigned to you
- Report blockers to admiral immediately with options and one recommendation
- When done, report: deliverable, validation evidence, failure modes, rollback note
+- You may deploy Royal Marines (short-lived sub-agents) for focused sorties.
+ Deploy by calling the Task tool directly (NOT via bash/CLI — use the built-in Task tool).
+ Recce Marine: Task tool with subagent_type="Explore" (read-only recon).
+ Assault Marine / Sapper: Task tool with subagent_type="general-purpose".
+ Include a deployment brief in the Task prompt (template below).
+ Station 2+ marine deployments require admiral approval first.
+ Max 2 marines at a time. Marines cannot deploy marines.
+Marine Deployment Brief (include in marine's Task prompt):
+ == MARINE DEPLOYMENT BRIEF ==
+ Ship: [your ship name]
+ Detachment: [Recce Marine / Assault Marine / Sapper]
+ Objective: [single clear sentence]
+ Scope: [what to do, and explicitly what NOT to do]
+ Report back: [what findings/outputs to return]
+ Constraints: Do NOT modify files outside scope. Do NOT spawn sub-agents.
+ == END BRIEF ==
== END BRIEFING ==
```
@@ -28,4 +45,5 @@ Standing Orders:
- **Ship** — From the ship manifest in the battle plan. Gives the teammate identity and signals task weight (frigate, destroyer, etc.).
- **File Ownership** — Critical for preventing merge conflicts when multiple agents work in parallel. If no files are assigned, note "No file ownership — research/analysis only."
- **Dependencies** — List both blocking (what must finish first) and blocked-by (what waits on this task). If none, note "Independent — no dependencies."
-- **Standing Orders** — Keep these to 4-5 lines. Project-specific standing orders can be appended here.
+- **Marine Capacity** — From the ship manifest. Tells the captain how many marines they may deploy (max 2). Omit if 0.
+- **Standing Orders** — Keep these to 4-5 lines. Project-specific standing orders can be appended here. The marine standing order tells captains they CAN deploy marines and where to find the rules — without this, captains have no knowledge of marines.
diff --git a/skills/nelson/references/admiralty-templates/marine-deployment-brief.md b/skills/nelson/references/admiralty-templates/marine-deployment-brief.md
new file mode 100644
index 0000000..f42fb4a
--- /dev/null
+++ b/skills/nelson/references/admiralty-templates/marine-deployment-brief.md
@@ -0,0 +1,15 @@
+# Marine Deployment Brief Template
+
+```text
+== MARINE DEPLOYMENT BRIEF ==
+Ship: [parent ship name]
+Detachment: [Recce Marine / Assault Marine / Sapper]
+Objective: [single clear sentence]
+Scope: [what to do, and explicitly what NOT to do]
+Report back: [what findings/outputs to return]
+Constraints:
+- Do NOT modify files outside objective scope
+- Do NOT spawn sub-agents
+- Report findings to captain, do not act beyond objective
+== END BRIEF ==
+```
diff --git a/skills/nelson/references/admiralty-templates/ship-manifest.md b/skills/nelson/references/admiralty-templates/ship-manifest.md
index 698b78d..061d1f1 100644
--- a/skills/nelson/references/admiralty-templates/ship-manifest.md
+++ b/skills/nelson/references/admiralty-templates/ship-manifest.md
@@ -8,6 +8,7 @@ Ship:
- Crew manifest:
- [Role Abbr]: [Sub-task description]
- [Role Abbr]: [Sub-task description]
+- Marine capacity: [0-2, or "as needed"]
- Sub-task sequence:
1. [Sub-task] (dependencies: none)
2. [Sub-task] (dependencies: 1)
diff --git a/skills/nelson/references/crew-roles.md b/skills/nelson/references/crew-roles.md
index 99fb7c4..9063ad5 100644
--- a/skills/nelson/references/crew-roles.md
+++ b/skills/nelson/references/crew-roles.md
@@ -74,3 +74,9 @@ The following standing orders apply specifically to crew operations:
- `standing-orders/all-hands-on-deck.md` — Do not crew roles the task does not need (too many crew).
- `standing-orders/skeleton-crew.md` — Do not spawn a single crew member for an atomic task (too few crew).
- `standing-orders/pressed-crew.md` — Do not assign crew work outside their designated role (wrong crew).
+
+## Royal Marines
+
+Marines are NOT crew. They are short-lived sub-agents a captain deploys for discrete objectives outside the crew's task scope. See `references/royal-marines.md` for deployment rules and specialisations.
+
+Key distinction: Crew subdivide the ship's deliverable. Marines execute independent sorties in support of the ship's task.
diff --git a/skills/nelson/references/royal-marines.md b/skills/nelson/references/royal-marines.md
new file mode 100644
index 0000000..0fbb480
--- /dev/null
+++ b/skills/nelson/references/royal-marines.md
@@ -0,0 +1,53 @@
+# Royal Marines
+
+Royal Marines are short-lived sub-agents a captain deploys for focused, independent objectives in service of the ship's task. They are doctrinally distinct from crew: crew subdivide the ship's deliverable, marines execute discrete sorties and return.
+
+## Deploy-or-Escalate Decision
+
+Choose the first condition that matches.
+
+1. Quick recon of unfamiliar area → **Recce Marine**
+2. Targeted fix or small implementation to unblock ship → **Assault Marine**
+3. Quick config/build/infra task → **Sapper**
+4. Sustained work, own deliverable, needs file ownership → **NOT a marine.** Request a new ship from the admiral.
+5. Work that subdivides the ship's main deliverable → **NOT a marine.** Crew the role instead.
+
+## Marine Specialisations
+
+| Type | Function | subagent_type | Use case |
+|---|---|---|---|
+| Recce Marine | Reconnaissance & intel gathering | Explore (read-only) | Scout unfamiliar code, gather findings |
+| Assault Marine | Direct action, targeted changes | general-purpose | Small fix, unblock a dependency |
+| Sapper | Engineering support | general-purpose | Quick config, build, infra task |
+
+### Read-Only Specialisation
+
+Recce Marines use the `Explore` subagent type. They cannot modify files. They report findings to the captain, who decides how to act on them.
+
+## Deployment Rules
+
+- **Max 2 marines per ship at any time.** If the task needs more, it is crew work or a new ship.
+- **Marines cannot deploy marines.** No recursion permitted.
+- **Marines report only to their deploying captain.** They do not communicate with crew or other ships.
+- **Captain must verify marine output** before incorporating it into the ship's deliverable.
+- **Marines do not get ship names.** Identify them as: `RM Detachment, HMS [Ship] — [objective]`.
+
+## Action Station Interaction
+
+Marine deployments inherit the parent ship's station tier:
+
+- **Station 0-1:** Captain deploys at discretion. No admiral approval required.
+- **Station 2:** Captain must signal admiral and receive approval before deploying marines.
+- **Station 3:** Marine deployment is not permitted. All Trafalgar-tier work requires explicit Admiralty (human) confirmation.
+
+## Recovery
+
+Marine recovery is simple. No separate damage-control procedure is needed.
+
+- If a marine is stuck or unresponsive, captain **abandons the deployment**.
+- Captain either redeploys a fresh marine or handles the objective directly.
+- If the same marine objective fails twice, captain **escalates to admiral**.
+
+## Deployment Template
+
+When deploying a marine, use the briefing template at `admiralty-templates/marine-deployment-brief.md`.
diff --git a/skills/nelson/references/standing-orders.md b/skills/nelson/references/standing-orders.md
index a4378f5..a856598 100644
--- a/skills/nelson/references/standing-orders.md
+++ b/skills/nelson/references/standing-orders.md
@@ -15,3 +15,4 @@ Consult the specific standing order that matches the situation.
| Crewing every role regardless of task needs | `standing-orders/all-hands-on-deck.md` |
| Spawning one crew member for an atomic task | `standing-orders/skeleton-crew.md` |
| Assigning crew work outside their role | `standing-orders/pressed-crew.md` |
+| Captain deploying marines for crew work or sustained tasks | `standing-orders/battalion-ashore.md` |
diff --git a/skills/nelson/references/standing-orders/battalion-ashore.md b/skills/nelson/references/standing-orders/battalion-ashore.md
new file mode 100644
index 0000000..0c07e4e
--- /dev/null
+++ b/skills/nelson/references/standing-orders/battalion-ashore.md
@@ -0,0 +1,18 @@
+# Battalion Ashore
+
+**Rule:** Do not deploy marines for work that belongs to the ship's crew or warrants a new ship.
+
+## Symptoms
+
+- Captain deploys marines constantly instead of using mustered crew.
+- Marine objectives expand beyond single sorties.
+- Marines editing files outside the ship's ownership.
+- More marines deployed than crew mustered.
+
+## Remedy
+
+- Use **crew** for sub-tasks of the ship's deliverable.
+- **Escalate to admiral** for sustained independent work that needs its own ship.
+- Marines are for **focused sorties only** — quick recon, targeted fixes, one-shot tasks.
+
+See `references/royal-marines.md` for the deploy-or-escalate decision tree.