first commit

This commit is contained in:
Brian Fertig 2026-04-04 18:51:09 -06:00
commit 9460041867
22 changed files with 2036 additions and 0 deletions

45
CLAUDE.md Normal file
View File

@ -0,0 +1,45 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
iPuzzle is a browser-based multiplayer puzzle game built with **Phaser 3.9** and vanilla **ES6 JavaScript**. No package manager or build step — the game runs directly in a browser.
## Running the Game
Open `index.html` in a browser. Because Phaser loads assets via XHR, you need a local HTTP server (not `file://`):
```bash
python3 -m http.server 8080
# then open http://localhost:8080
```
## Architecture
The game is organized into Phaser scenes:
- **MainMenuScene** — entry point; "New Puzzle" and "Join Puzzle" buttons
- **NewPuzzleScene** — image picker (`/assets/images/puzzles/`, 16×9 images) and piece-count selector (40 / 60 / 100); generates puzzle piece geometry before transitioning
- **PuzzleScene** — core gameplay; owns the piece objects, interaction logic, room code, and completion state
### Key design constraints
- **No build tool / no npm** — all Phaser and game code is loaded via `<script>` tags or ES6 modules without a bundler.
- **Multiplayer-ready state** — puzzle state (piece positions, locked pairs, room code) must be kept in `localStorage` so it can later be synced to other players. Design state updates to be serializable and event-driven.
- **Room code** — a random 4-character uppercase-alphanumeric code is generated when a new puzzle starts. All state must be keyed to this code to support future multi-room play.
### Puzzle piece generation
Pieces are procedurally generated to look like interlocking puzzle pieces. The generator takes the source image and piece count, divides the image into a grid, and renders each piece with tab/blank connectors on each edge. Connector geometry must be consistent (piece A's tab must match piece B's blank) so that adjacency detection and auto-lock work correctly.
### Interaction model
- Click to pick up a piece; click again to release.
- Held piece always renders on top (highest `depth`/z-order).
- While a piece is held, check all edges against their matching neighbors — highlight matching edges with a glow effect when close.
- On release, if within snap distance of a matching neighbor, lock the pieces together (they move as a unit thereafter).
## Asset conventions
- Puzzle images: `/assets/images/puzzles/` — 16×9 aspect ratio PNG/JPG files.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

22
assets/puzzles.json Normal file
View File

@ -0,0 +1,22 @@
[
{
"key": "puzzle_growing_ea",
"path": "assets/images/puzzles/growing_ea.png",
"label": "Growing EA"
},
{
"key": "puzzle_lone_ship",
"path": "assets/images/puzzles/lone_ship_in_the_seas.png",
"label": "Lone Ship"
},
{
"key": "puzzle_misty",
"path": "assets/images/puzzles/misty_exploration",
"label": "Misty Exploration"
},
{
"key": "puzzle_mandrill",
"path": "assets/images/puzzles/thoughtful_mandrill",
"label": "Thoughtful Mandrill"
}
]

51
index.html Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iPuzzle</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; background: #0d0d1a; overflow: hidden; }
body { display: flex; justify-content: center; align-items: center; }
#game-container canvas {
display: block;
/* Scale the 1920×1080 canvas to fit the viewport at 16:9.
- width: fills viewport width, height = 56.25% of that (9/16)
- max-height: 100vh prevents overflow on portrait/square displays;
max-width: 177.78vh (16/9 * 100) clamps width on tall displays. */
width: 100vw;
height: 56.25vw;
max-height: 100vh;
max-width: 177.78vh;
}
</style>
</head>
<body>
<div id="game-container"></div>
<!-- Phaser 3.9 -->
<script src="https://cdn.jsdelivr.net/npm/phaser@3.9.0/dist/phaser.min.js"></script>
<!-- Utilities (no dependencies) -->
<script src="js/puzzle/ConnectorGeometry.js"></script>
<script src="js/puzzle/PuzzleGenerator.js"></script>
<script src="js/puzzle/PieceRenderer.js"></script>
<script src="js/puzzle/PieceObject.js"></script>
<script src="js/puzzle/GroupManager.js"></script>
<script src="js/puzzle/SnapDetector.js"></script>
<script src="js/puzzle/RoomCode.js"></script>
<!-- State -->
<script src="js/state/PuzzleState.js"></script>
<script src="js/state/StorageManager.js"></script>
<!-- Scenes -->
<script src="js/scenes/MainMenuScene.js"></script>
<script src="js/scenes/NewPuzzleScene.js"></script>
<script src="js/scenes/PuzzleScene.js"></script>
<!-- Boot (last — references all scenes) -->
<script src="js/main.js"></script>
</body>
</html>

13
js/main.js Normal file
View File

@ -0,0 +1,13 @@
/* global Phaser, MainMenuScene, NewPuzzleScene, PuzzleScene */
const config = {
type: Phaser.AUTO,
width: 1920,
height: 1080,
backgroundColor: '#12121e',
parent: 'game-container',
scene: [MainMenuScene, NewPuzzleScene, PuzzleScene]
};
// eslint-disable-next-line no-unused-vars
const game = new Phaser.Game(config); // eslint-disable-line no-unused-vars

View File

@ -0,0 +1,147 @@
/**
* ConnectorGeometry.js
*
* Draws a closed canvas-2D path for a single puzzle piece.
* The piece's grid cell occupies [tabSize, tabSize] to [tabSize+pieceW, tabSize+pieceH]
* within an off-screen canvas of size (pieceW+2*tabSize) × (pieceH+2*tabSize).
*
* Tab/blank bumps occupy the middle third of each internal edge.
* tabSize should be >= pieceW * 0.3 to give the tab room to protrude.
*/
/**
* Add a connector bump between two points in canvas space.
* @param {CanvasRenderingContext2D} ctx
* @param {number} x0,y0 - start of edge segment
* @param {number} x1,y1 - end of edge segment
* @param {number} dir - +1 = tab (protrudes to left of travel), -1 = blank (indents)
*
* "Left of travel" in canvas coords:
* Travelling right (+X): left = -Y (tab protrudes upward on canvas)
* Travelling down (+Y): left = +X
* etc.
* We want the tab to protrude *outward* from the piece, so callers pass the
* edge direction and we compute the perpendicular accordingly.
*/
function _addConnectorBump(ctx, x0, y0, x1, y1, dir) {
// Edge vector
const ex = x1 - x0;
const ey = y1 - y0;
const L = Math.sqrt(ex * ex + ey * ey);
// Unit edge and perpendicular (perpendicular points "outward" when dir=+1)
const ux = ex / L;
const uy = ey / L;
// Outward perpendicular = rotate edge 90° clockwise: (uy, -ux)
const px = uy * dir;
const py = -ux * dir;
// Bump parameters as fractions of edge length
const H = L * 0.28; // bump height
const R = L * 0.10; // knob half-width
// Bump occupies centre third: t ∈ [1/3, 2/3]
// Points along the edge
const bx0 = x0 + ux * (L / 3);
const by0 = y0 + uy * (L / 3);
const bx1 = x0 + ux * (L * 2 / 3);
const by1 = y0 + uy * (L * 2 / 3);
const bmx = x0 + ux * (L / 2); // midpoint of edge
const bmy = y0 + uy * (L / 2);
// Base of knob (at H * 0.55 along perpendicular)
const kbx = bmx + px * H * 0.55;
const kby = bmy + py * H * 0.55;
// Tip of knob (at H along perpendicular)
const ktx = bmx + px * H;
const kty = bmy + py * H;
// Draw: lead-in from bump start → left knob base → knob arc → right knob base → lead-out
// Lead-in cubic
ctx.bezierCurveTo(
bx0 + ux * L * 0.04 + px * H * 0.2, by0 + uy * L * 0.04 + py * H * 0.2, // cp1
kbx - ux * R * 1.2, kby - uy * R * 1.2, // cp2
kbx - ux * R, kby - uy * R // end
);
// Knob left arc
ctx.bezierCurveTo(
ktx - ux * R, kty - uy * R, // cp1
ktx - ux * R, kty - uy * R, // cp2 (same → straight-ish)
ktx, kty // end (tip)
);
// Knob right arc
ctx.bezierCurveTo(
ktx + ux * R, kty + uy * R, // cp1
ktx + ux * R, kty + uy * R, // cp2
kbx + ux * R, kby + uy * R // end
);
// Lead-out cubic
ctx.bezierCurveTo(
kbx + ux * R * 1.2, kby + uy * R * 1.2,
bx1 - ux * L * 0.04 + px * H * 0.2, by1 - uy * L * 0.04 + py * H * 0.2,
bx1, by1
);
}
/**
* Build the full closed clip path for a single piece.
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} gridCol
* @param {number} gridRow
* @param {number} cols - total columns in grid
* @param {number} rows - total rows in grid
* @param {number} pieceW - width of one grid cell in pixels
* @param {number} pieceH - height of one grid cell in pixels
* @param {{ top, right, bottom, left }} edges - 'flat'|'tab'|'blank' per side
* @param {number} tabSize - padding around cell in canvas (= max(pieceW,pieceH)*0.3 or similar)
*/
function buildPiecePath(ctx, gridCol, gridRow, cols, rows, pieceW, pieceH, edges, tabSize) {
const x0 = tabSize; // left edge of cell on canvas
const y0 = tabSize; // top edge of cell on canvas
const x1 = tabSize + pieceW; // right edge
const y1 = tabSize + pieceH; // bottom edge
ctx.beginPath();
ctx.moveTo(x0, y0);
// TOP edge: left → right
if (edges.top === 'flat') {
ctx.lineTo(x1, y0);
} else {
// Tab protrudes upward (−Y), blank indents downward (+Y)
// "outward" for top edge = −Y → dir = +1 gives perpendicular toward −Y (correct for tab)
ctx.lineTo(x0 + (x1 - x0) / 3, y0);
_addConnectorBump(ctx, x0, y0, x1, y0, edges.top === 'tab' ? -1 : +1);
ctx.lineTo(x1, y0);
}
// RIGHT edge: top → bottom
if (edges.right === 'flat') {
ctx.lineTo(x1, y1);
} else {
ctx.lineTo(x1, y0 + (y1 - y0) / 3);
_addConnectorBump(ctx, x1, y0, x1, y1, edges.right === 'tab' ? +1 : -1);
ctx.lineTo(x1, y1);
}
// BOTTOM edge: right → left
if (edges.bottom === 'flat') {
ctx.lineTo(x0, y1);
} else {
ctx.lineTo(x1 - (x1 - x0) / 3, y1);
_addConnectorBump(ctx, x1, y1, x0, y1, edges.bottom === 'tab' ? -1 : +1);
ctx.lineTo(x0, y1);
}
// LEFT edge: bottom → top
if (edges.left === 'flat') {
ctx.lineTo(x0, y0);
} else {
ctx.lineTo(x0, y1 - (y1 - y0) / 3);
_addConnectorBump(ctx, x0, y1, x0, y0, edges.left === 'tab' ? +1 : -1);
ctx.lineTo(x0, y0);
}
ctx.closePath();
}

82
js/puzzle/GroupManager.js Normal file
View File

@ -0,0 +1,82 @@
class GroupManager {
constructor() {
this.groups = new Map(); // groupId -> Set<pieceId>
this.pieceToGroup = new Map(); // pieceId -> groupId
this._nextId = 1;
}
createSingletonGroup(pieceId) {
const gid = this._nextId++;
this.groups.set(gid, new Set([pieceId]));
this.pieceToGroup.set(pieceId, gid);
return gid;
}
getGroupId(pieceId) {
return this.pieceToGroup.get(pieceId) ?? null;
}
/** Returns the Set<pieceId> for the group containing pieceId, or null. */
getGroup(pieceId) {
const gid = this.getGroupId(pieceId);
if (gid === null) return null;
return this.groups.get(gid) ?? null;
}
/** Returns all piece IDs in the same group as pieceId (including itself). */
getPeersOf(pieceId) {
return this.getGroup(pieceId) ?? new Set([pieceId]);
}
/**
* Merge the groups containing pieceIdA and pieceIdB into a single new group.
* @returns {number} the new group ID
*/
merge(pieceIdA, pieceIdB) {
const gidA = this.getGroupId(pieceIdA);
const gidB = this.getGroupId(pieceIdB);
if (gidA === null || gidB === null) return gidA ?? gidB;
if (gidA === gidB) return gidA; // already in the same group
const newGid = this._nextId++;
const members = new Set([...this.groups.get(gidA), ...this.groups.get(gidB)]);
this.groups.delete(gidA);
this.groups.delete(gidB);
this.groups.set(newGid, members);
members.forEach(id => this.pieceToGroup.set(id, newGid));
return newGid;
}
/** Total number of distinct locked groups. */
get groupCount() {
return this.groups.size;
}
/** Total number of tracked pieces. */
get pieceCount() {
return this.pieceToGroup.size;
}
/**
* Serialize to a plain array of arrays for localStorage.
* @returns {number[][]}
*/
serialize() {
return Array.from(this.groups.values()).map(set => Array.from(set));
}
/**
* Rebuild from serialized data.
* @param {number[][]} groupsList
*/
static deserialize(groupsList) {
const gm = new GroupManager();
groupsList.forEach(members => {
const gid = gm._nextId++;
const set = new Set(members);
gm.groups.set(gid, set);
members.forEach(id => gm.pieceToGroup.set(id, gid));
});
return gm;
}
}

46
js/puzzle/PieceObject.js Normal file
View File

@ -0,0 +1,46 @@
/* global Phaser */
class PieceObject {
/**
* @param {Phaser.Scene} scene
* @param {object} pieceData - PieceData from PuzzleGenerator
* @param {number} pieceW - width of grid cell
* @param {number} pieceH - height of grid cell
* @param {number} tabSize - canvas padding used by PieceRenderer
* @param {number} canvasW - full canvas width (pieceW + 2*tabSize)
* @param {number} canvasH - full canvas height (pieceH + 2*tabSize)
*/
constructor(scene, pieceData, pieceW, pieceH, tabSize, canvasW, canvasH) {
this.data = pieceData;
this.pieceW = pieceW;
this.pieceH = pieceH;
this.tabSize = tabSize;
this.image = scene.add.image(pieceData.x, pieceData.y, `piece_${pieceData.id}`);
// Anchor at the cell centre (not the canvas centre).
// The cell starts at (tabSize, tabSize) in the canvas, so cell-centre is at
// (tabSize + pieceW/2, tabSize + pieceH/2) within the canvas.
this.image.setOrigin(
(tabSize + pieceW / 2) / canvasW,
(tabSize + pieceH / 2) / canvasH
);
this.image.setInteractive();
this.image.setDepth(0);
}
setPosition(x, y) {
this.data.x = x;
this.data.y = y;
this.image.setPosition(x, y);
}
setDepth(d) {
this.image.setDepth(d);
}
destroy() {
this.image.destroy();
}
}

View File

@ -0,0 +1,87 @@
/* global buildPiecePath */
class PieceRenderer {
/**
* Render all puzzle pieces as Phaser textures via off-screen canvas clipping.
* In Phaser 3.9, TextureManager emits 'addtexture' with the key as argument
* (not 'addtexture-KEY' per-texture events that arrived in later versions).
*
* @param {Phaser.Scene} scene
* @param {HTMLImageElement} sourceImage
* @param {PieceData[]} pieceDataArray
* @param {number} cols
* @param {number} rows
* @param {number} imageW
* @param {number} imageH
* @param {function(number, number)} onProgress
* @returns {Promise<{ pieceW, pieceH, tabSize, canvasW, canvasH }>}
*/
static renderAll(scene, sourceImage, pieceDataArray, cols, rows, imageW, imageH, onProgress) {
const pieceW = imageW / cols;
const pieceH = imageH / rows;
const tabSize = Math.max(pieceW, pieceH) * 0.32;
const canvasW = Math.ceil(pieceW + 2 * tabSize);
const canvasH = Math.ceil(pieceH + 2 * tabSize);
const total = pieceDataArray.length;
const dims = { pieceW, pieceH, tabSize, canvasW, canvasH };
// Build a set of expected keys so the listener ignores unrelated textures
const pending = new Set(pieceDataArray.map(p => `piece_${p.id}`));
let done = 0;
return new Promise(resolve => {
// Single listener for all pieces — Phaser 3.9 emits 'addtexture' with key arg
const onAdded = (key) => {
if (!pending.has(key)) return;
pending.delete(key);
done++;
if (onProgress) onProgress(done, total);
if (done === total) {
scene.textures.off('addtexture', onAdded);
resolve(dims);
}
};
scene.textures.on('addtexture', onAdded);
// Kick off all canvas renders + addBase64 calls
pieceDataArray.forEach(piece => {
const canvas = document.createElement('canvas');
canvas.width = canvasW;
canvas.height = canvasH;
const ctx = canvas.getContext('2d');
// Clip path
ctx.save();
buildPiecePath(ctx, piece.gridCol, piece.gridRow, cols, rows,
pieceW, pieceH, piece.edges, tabSize);
ctx.clip();
// Draw the source region that corresponds to the full canvas extent,
// including the tab zones that overlap neighbouring cells.
// srcX/srcY may be slightly negative for pieces on the top/left border;
// the canvas spec clips out-of-bounds source regions automatically.
ctx.drawImage(
sourceImage,
piece.gridCol * pieceW - tabSize, piece.gridRow * pieceH - tabSize,
canvasW, canvasH,
0, 0,
canvasW, canvasH
);
ctx.restore();
// Subtle edge shading
ctx.save();
buildPiecePath(ctx, piece.gridCol, piece.gridRow, cols, rows,
pieceW, pieceH, piece.edges, tabSize);
ctx.clip();
ctx.strokeStyle = 'rgba(0,0,0,0.35)';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
const key = `piece_${piece.id}`;
if (scene.textures.exists(key)) scene.textures.remove(key);
scene.textures.addBase64(key, canvas.toDataURL('image/png'));
});
});
}
}

View File

@ -0,0 +1,53 @@
// Grid dimensions for each piece count (cols × rows)
const GRID_SIZES = {
20: { cols: 5, rows: 4 },
40: { cols: 8, rows: 5 },
60: { cols: 10, rows: 6 },
100: { cols: 10, rows: 10 }
};
class PuzzleGenerator {
/**
* Generate puzzle piece data for the given piece count.
* @param {number} pieceCount — 40, 60, or 100
* @returns {{ cols, rows, pieces: PieceData[], horizConnectors: string[][], vertConnectors: string[][] }}
*
* horizConnectors[r][c] = 'tab'|'blank' → right edge of cell [r][c]
* (left edge of [r][c+1] is the opposite)
* vertConnectors[r][c] = 'tab'|'blank' → bottom edge of cell [r][c]
* (top edge of [r+1][c] is the opposite)
*/
static generate(pieceCount) {
const { cols, rows } = GRID_SIZES[pieceCount] || GRID_SIZES[40];
// Build connector tables
const horizConnectors = Array.from({ length: rows }, () =>
Array.from({ length: cols - 1 }, () => Math.random() < 0.5 ? 'tab' : 'blank')
);
const vertConnectors = Array.from({ length: rows - 1 }, () =>
Array.from({ length: cols }, () => Math.random() < 0.5 ? 'tab' : 'blank')
);
// Build PieceData array
const pieces = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
pieces.push({
id: r * cols + c,
gridRow: r,
gridCol: c,
edges: {
top: r === 0 ? 'flat' : (vertConnectors[r - 1][c] === 'tab' ? 'blank' : 'tab'),
bottom: r === rows - 1 ? 'flat' : vertConnectors[r][c],
left: c === 0 ? 'flat' : (horizConnectors[r][c - 1] === 'tab' ? 'blank' : 'tab'),
right: c === cols - 1 ? 'flat' : horizConnectors[r][c]
},
x: 0,
y: 0
});
}
}
return { cols, rows, pieces, horizConnectors, vertConnectors };
}
}

11
js/puzzle/RoomCode.js Normal file
View File

@ -0,0 +1,11 @@
const ROOM_CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
function generateRoomCode() {
return Array.from({ length: 4 }, () =>
ROOM_CODE_CHARS[Math.floor(Math.random() * ROOM_CODE_CHARS.length)]
).join('');
}
function getRoomCodeFromURL() {
return new URLSearchParams(window.location.search).get('room');
}

123
js/puzzle/SnapDetector.js Normal file
View File

@ -0,0 +1,123 @@
class SnapDetector {
/**
* Check all pieces in the held group against their grid neighbours.
*
* @param {PieceObject} heldPieceObj
* @param {Map<number, PieceObject>} allPieceObjects pieceId → PieceObject
* @param {GroupManager} groupManager
* @param {number} cols
* @param {number} pieceW
* @param {number} pieceH
* @param {number} snapRadius — distance threshold for snapping
* @returns {Array<{heldId, neighborId, shiftX, shiftY, dist}>} sorted closest-first
*/
static check(heldPieceObj, allPieceObjects, groupManager, cols, pieceW, pieceH, snapRadius) {
return SnapDetector._scan(
heldPieceObj, allPieceObjects, groupManager, cols, pieceW, pieceH, snapRadius
);
}
/**
* Same scan but with a larger radius, returning edge segment world coords for glow rendering.
*
* @returns {Array<{x0,y0,x1,y1}>} — line segments in world space to draw the glow along
*/
static getGlowSegments(heldPieceObj, allPieceObjects, groupManager, cols, pieceW, pieceH, glowRadius) {
const candidates = SnapDetector._scan(
heldPieceObj, allPieceObjects, groupManager, cols, pieceW, pieceH, glowRadius
);
return candidates.map(({ heldId, neighborId }) => {
const hp = allPieceObjects.get(heldId).data;
const np = allPieceObjects.get(neighborId).data;
return SnapDetector._sharedEdgeSegment(hp, np, pieceW, pieceH);
});
}
// ─────────────────────────────────────────────────────────────────────
// Internal helpers
// ─────────────────────────────────────────────────────────────────────
static _scan(heldPieceObj, allPieceObjects, groupManager, cols, pieceW, pieceH, radius) {
const results = [];
const heldGroupId = groupManager.getGroupId(heldPieceObj.data.id);
const heldGroup = groupManager.getPeersOf(heldPieceObj.data.id);
const DIRS = [
{ dc: 0, dr: -1 }, // top
{ dc: 1, dr: 0 }, // right
{ dc: 0, dr: 1 }, // bottom
{ dc: -1, dr: 0 }, // left
];
heldGroup.forEach(heldId => {
const hp = allPieceObjects.get(heldId);
if (!hp) return;
const hd = hp.data;
DIRS.forEach(({ dc, dr }) => {
const nRow = hd.gridRow + dr;
const nCol = hd.gridCol + dc;
if (nRow < 0 || nCol < 0) return;
const neighborId = nRow * cols + nCol;
const np = allPieceObjects.get(neighborId);
if (!np) return;
if (groupManager.getGroupId(neighborId) === heldGroupId) return;
// Ideal position of the held piece if it were snapped to the neighbor
const idealHeldX = np.data.x - dc * pieceW;
const idealHeldY = np.data.y - dr * pieceH;
const dist = Math.hypot(hd.x - idealHeldX, hd.y - idealHeldY);
if (dist < radius) {
results.push({
heldId,
neighborId,
shiftX: idealHeldX - hd.x,
shiftY: idealHeldY - hd.y,
dist
});
}
});
});
return results.sort((a, b) => a.dist - b.dist);
}
/**
* Compute the world-space line segment for the shared edge between two adjacent pieces.
* Used to position the glow effect on the correct edge.
*/
static _sharedEdgeSegment(hp, np, pieceW, pieceH) {
const dc = np.gridCol - hp.gridCol;
const dr = np.gridRow - hp.gridRow;
if (dc === 1) {
// hp is left of np — right edge of hp
return {
x0: hp.x + pieceW / 2, y0: hp.y - pieceH / 2,
x1: hp.x + pieceW / 2, y1: hp.y + pieceH / 2
};
} else if (dc === -1) {
// hp is right of np — left edge of hp
return {
x0: hp.x - pieceW / 2, y0: hp.y - pieceH / 2,
x1: hp.x - pieceW / 2, y1: hp.y + pieceH / 2
};
} else if (dr === 1) {
// hp is above np — bottom edge of hp
return {
x0: hp.x - pieceW / 2, y0: hp.y + pieceH / 2,
x1: hp.x + pieceW / 2, y1: hp.y + pieceH / 2
};
} else {
// hp is below np — top edge of hp
return {
x0: hp.x - pieceW / 2, y0: hp.y - pieceH / 2,
x1: hp.x + pieceW / 2, y1: hp.y - pieceH / 2
};
}
}
}

169
js/scenes/MainMenuScene.js Normal file
View File

@ -0,0 +1,169 @@
/* global Phaser */
class MainMenuScene extends Phaser.Scene {
constructor() {
super({ key: 'MainMenuScene' });
}
create() {
const { width, height } = this.sys.game.config;
// Background
const bg = this.add.graphics();
bg.fillStyle(0x1a1a2e, 1);
bg.fillRect(0, 0, width, height);
bg.fillStyle(0x16213e, 1);
bg.fillRect(0, height * 0.5, width, height * 0.5);
// Title
this.add.text(width / 2, height * 0.22, 'iPuzzle', {
fontFamily: 'Georgia, serif',
fontSize: Math.round(height * 0.083) + 'px',
color: '#e0e0ff',
stroke: '#4444aa',
strokeThickness: 6
}).setOrigin(0.5);
this.add.text(width / 2, height * 0.36, 'Put the pieces together', {
fontFamily: 'Georgia, serif',
fontSize: Math.round(height * 0.026) + 'px',
color: '#8888bb'
}).setOrigin(0.5);
this._buildDomUI();
}
// ─── DOM UI ──────────────────────────────────────────────────────────
_buildDomUI() {
this._uiLayer = document.createElement('div');
Object.assign(this._uiLayer.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '100vw',
height: '56.25vw',
maxHeight: '100vh',
maxWidth: '177.78vh',
pointerEvents: 'none',
zIndex: '10',
});
document.body.appendChild(this._uiLayer);
const newBtn = this._makeDomBtn('New Puzzle', '#1a3366', '#2255aa', '#66aaff', () => {
this.scene.start('NewPuzzleScene');
});
Object.assign(newBtn.style, {
top: '55%',
left: '50%',
transform: 'translateX(-50%)',
width: '18%',
});
this._uiLayer.appendChild(newBtn);
const joinBtn = this._makeDomBtn('Join Puzzle', '#1a3322', '#225533', '#44aa66', () => {
this._showComingSoon();
});
Object.assign(joinBtn.style, {
top: '70%',
left: '50%',
transform: 'translateX(-50%)',
width: '18%',
});
this._uiLayer.appendChild(joinBtn);
// Version credit — bottom right
const credit = document.createElement('div');
Object.assign(credit.style, {
position: 'absolute',
bottom: '1%',
right: '0.6%',
color: '#333355',
fontSize: '1.2vmin',
fontFamily: 'Arial, sans-serif',
});
credit.textContent = 'iPuzzle';
this._uiLayer.appendChild(credit);
this.events.once('shutdown', () => this._destroyDomUI());
}
_showComingSoon() {
if (this._comingSoonEl) return;
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'absolute',
inset: '0',
background: 'rgba(0,0,0,0.6)',
pointerEvents: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
const panel = document.createElement('div');
Object.assign(panel.style, {
background: '#1a1a3e',
border: '2px solid #4444aa',
borderRadius: '6px',
padding: '3vmin 4vmin',
textAlign: 'center',
display: 'flex',
flexDirection:'column',
gap: '1.2vmin',
});
const msg = document.createElement('div');
Object.assign(msg.style, { color: '#aaccff', fontSize: '2.4vmin', fontFamily: 'Arial, sans-serif' });
msg.textContent = 'Multiplayer Coming Soon!';
const sub = document.createElement('div');
Object.assign(sub.style, { color: '#667799', fontSize: '1.5vmin', fontFamily: 'Arial, sans-serif' });
sub.textContent = 'Click anywhere to dismiss';
panel.appendChild(msg);
panel.appendChild(sub);
overlay.appendChild(panel);
this._uiLayer.appendChild(overlay);
this._comingSoonEl = overlay;
overlay.addEventListener('click', () => {
overlay.remove();
this._comingSoonEl = null;
});
}
_makeDomBtn(label, bgNormal, bgHover, borderColor, onClick) {
const btn = document.createElement('button');
btn.textContent = label;
Object.assign(btn.style, {
position: 'absolute',
padding: '1.2vmin 2vmin',
background: bgNormal,
color: '#ddeeff',
border: `2px solid ${borderColor}`,
borderRadius: '4px',
fontSize: '2vmin',
fontFamily: 'Arial, sans-serif',
cursor: 'pointer',
pointerEvents: 'auto',
whiteSpace: 'nowrap',
textAlign: 'center',
});
btn.addEventListener('mouseenter', () => { btn.style.background = bgHover; });
btn.addEventListener('mouseleave', () => { btn.style.background = bgNormal; });
btn.addEventListener('mousedown', () => { btn.style.transform = (btn.style.transform || '') + ' scale(0.97)'; });
btn.addEventListener('mouseup', () => { btn.style.transform = btn.style.transform.replace(' scale(0.97)', ''); });
btn.addEventListener('click', onClick);
return btn;
}
_destroyDomUI() {
if (this._uiLayer && this._uiLayer.parentNode) {
this._uiLayer.parentNode.removeChild(this._uiLayer);
}
this._uiLayer = null;
}
}

341
js/scenes/NewPuzzleScene.js Normal file
View File

@ -0,0 +1,341 @@
/* global Phaser, generateRoomCode */
const DIFFICULTIES = [
{ pieces: 20, label: '20 Pieces' },
{ pieces: 40, label: '40 Pieces' },
{ pieces: 60, label: '60 Pieces' },
{ pieces: 100, label: '100 Pieces' }
];
class NewPuzzleScene extends Phaser.Scene {
constructor() {
super({ key: 'NewPuzzleScene' });
}
preload() {
// Load the puzzle list JSON; images are loaded dynamically in create()
this.load.json('puzzle_list', 'assets/puzzles.json');
}
create() {
this.selectedImageIdx = null;
this.selectedPieces = null;
const W = this.sys.game.config.width;
const H = this.sys.game.config.height;
// Background
this.add.graphics().fillStyle(0x1a1a2e, 1).fillRect(0, 0, W, H);
// Title — fixed position (canvas is always 1920×1080)
this.add.text(960, -30, 'Choose Your Puzzle', {
fontFamily: 'Georgia, serif',
fontSize: '58px',
color: '#e8f4ff',
stroke: '#1a4a88',
strokeThickness: 6,
shadow: { offsetX: 0, offsetY: 0, color: '#3388ff', blur: 28, fill: true, stroke: true }
}).setOrigin(0.5, 0.5);
// Decorative separator below the title
const sep = this.add.graphics();
// Faint full-width rule
sep.lineStyle(1, 0x223355, 0.7);
sep.lineBetween(77, -81, 1843, -81);
// Bright centre segment
sep.lineStyle(2, 0x3377bb, 1);
sep.lineBetween(653, -81, 1267, -81);
// Diamond accent at the midpoint
sep.fillStyle(0x66aaff, 1);
sep.fillTriangle(960, 100, 966, 105, 960, 110);
sep.fillTriangle(960, 100, 954, 105, 960, 110);
// Read puzzle list from cache, then load any images not yet in the texture cache
this._puzzleImages = this.cache.json.get('puzzle_list');
const toLoad = this._puzzleImages.filter(img => !this.textures.exists(img.key));
if (toLoad.length > 0) {
toLoad.forEach(img => this.load.image(img.key, img.path));
this.load.once('complete', () => this._buildDomUI());
this.load.start();
} else {
this._buildDomUI();
}
}
// ─── DOM UI ──────────────────────────────────────────────────────────
_buildDomUI() {
this._uiLayer = document.createElement('div');
Object.assign(this._uiLayer.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '100vw',
height: '56.25vw',
maxHeight: '100vh',
maxWidth: '177.78vh',
pointerEvents: 'none',
zIndex: '10',
});
document.body.appendChild(this._uiLayer);
this._buildPuzzleGrid(this._uiLayer);
this._buildControlsBar(this._uiLayer);
this.events.once('shutdown', () => this._destroyDomUI());
}
_buildPuzzleGrid(uiLayer) {
// Scrollable area: sits below the Phaser title (top 10%) and above the controls bar (bottom 28%)
const scrollArea = document.createElement('div');
Object.assign(scrollArea.style, {
position: 'absolute',
top: '10%',
left: '5%',
width: '90%',
height: '62%',
overflowY: 'auto',
overflowX: 'hidden',
pointerEvents: 'auto',
});
const grid = document.createElement('div');
Object.assign(grid.style, {
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1.5vmin',
padding: '1vmin',
});
this._cardEls = this._puzzleImages.map((img, i) => {
const card = document.createElement('div');
Object.assign(card.style, {
cursor: 'pointer',
border: '2px solid transparent',
borderRadius: '6px',
overflow: 'hidden',
transition: 'border-color 0.15s, box-shadow 0.15s',
background: '#111128',
});
const thumb = document.createElement('img');
thumb.src = img.path;
Object.assign(thumb.style, {
display: 'block',
width: '100%',
aspectRatio: '16 / 9',
objectFit: 'cover',
});
const label = document.createElement('div');
Object.assign(label.style, {
padding: '0.5vmin 0.8vmin',
color: '#8899cc',
fontSize: '1.5vmin',
fontFamily: 'Arial, sans-serif',
textAlign: 'center',
});
label.textContent = img.label;
card.appendChild(thumb);
card.appendChild(label);
card.addEventListener('mouseenter', () => {
if (this.selectedImageIdx !== i) {
card.style.borderColor = '#334466';
}
});
card.addEventListener('mouseleave', () => {
if (this.selectedImageIdx !== i) {
card.style.borderColor = 'transparent';
card.style.boxShadow = '';
}
});
card.addEventListener('click', () => this._selectImage(i));
grid.appendChild(card);
return card;
});
scrollArea.appendChild(grid);
uiLayer.appendChild(scrollArea);
}
_buildControlsBar(uiLayer) {
const bar = document.createElement('div');
Object.assign(bar.style, {
position: 'absolute',
bottom: '0',
left: '0',
width: '100%',
height: '28%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5vmin',
borderTop: '1px solid #222244',
background: 'rgba(10, 10, 25, 0.7)',
pointerEvents: 'auto',
});
// "Difficulty" label
const diffLabel = document.createElement('div');
Object.assign(diffLabel.style, {
color: '#8899cc',
fontSize: '1.8vmin',
fontFamily: 'Arial, sans-serif',
});
diffLabel.textContent = 'Difficulty';
// Difficulty buttons row
const diffRow = document.createElement('div');
Object.assign(diffRow.style, {
display: 'flex',
gap: '2vmin',
});
this._diffBtnEls = DIFFICULTIES.map((diff, i) => {
const btn = document.createElement('button');
btn.textContent = diff.label;
Object.assign(btn.style, {
padding: '0.7vmin 2.5vmin',
background: '#111133',
color: '#ccddff',
border: '1px solid #222255',
borderRadius: '4px',
fontSize: '1.6vmin',
fontFamily: 'Arial, sans-serif',
cursor: 'pointer',
transition: 'background 0.1s, border-color 0.1s',
});
btn.addEventListener('mouseenter', () => {
if (this.selectedPieces !== diff.pieces) btn.style.background = '#1a3366';
});
btn.addEventListener('mouseleave', () => {
if (this.selectedPieces !== diff.pieces) btn.style.background = '#111133';
});
btn.addEventListener('click', () => this._selectDifficulty(i, diff.pieces));
diffRow.appendChild(btn);
return { el: btn, pieces: diff.pieces };
});
// Action buttons row (Back left, Start right)
const actionRow = document.createElement('div');
Object.assign(actionRow.style, {
display: 'flex',
justifyContent: 'center',
gap: '4vmin',
width: '55%',
});
const backBtn = this._makeDomBtn('← Back', '#221133', '#332244', '#443366');
backBtn.style.color = '#8899cc';
backBtn.style.flex = '1';
backBtn.addEventListener('click', () => this.scene.start('MainMenuScene'));
this._startBtnEl = this._makeDomBtn('Start Puzzle →', '#111111', '#111111', '#222222');
Object.assign(this._startBtnEl.style, {
flex: '1',
color: '#445544',
cursor: 'not-allowed',
});
this._startBtnEl.disabled = true;
this._startBtnEl._bgNormal = '#111111';
this._startBtnEl._bgHover = '#111111';
this._startBtnEl.addEventListener('click', () => this._startPuzzle());
actionRow.appendChild(backBtn);
actionRow.appendChild(this._startBtnEl);
bar.appendChild(diffLabel);
bar.appendChild(diffRow);
bar.appendChild(actionRow);
uiLayer.appendChild(bar);
}
// ─── Selection ───────────────────────────────────────────────────────
_selectImage(idx) {
this.selectedImageIdx = idx;
this._cardEls.forEach((card, i) => {
const sel = i === idx;
card.style.borderColor = sel ? '#44aaff' : 'transparent';
card.style.boxShadow = sel ? '0 0 10px #44aaff88' : '';
});
this._refreshStartButton();
}
_selectDifficulty(idx, pieces) {
this.selectedPieces = pieces;
this._diffBtnEls.forEach(({ el, pieces: p }) => {
const sel = p === pieces;
el.style.background = sel ? '#224488' : '#111133';
el.style.borderColor = sel ? '#44aaff' : '#222255';
});
this._refreshStartButton();
}
_refreshStartButton() {
if (!this._startBtnEl) return;
const ready = this.selectedImageIdx !== null && this.selectedPieces !== null;
this._startBtnEl.disabled = !ready;
this._startBtnEl._bgNormal = ready ? '#115511' : '#111111';
this._startBtnEl._bgHover = ready ? '#227722' : '#111111';
Object.assign(this._startBtnEl.style, {
background: this._startBtnEl._bgNormal,
borderColor: ready ? '#44cc44' : '#222222',
color: ready ? '#aaffaa' : '#445544',
cursor: ready ? 'pointer' : 'not-allowed',
});
}
// ─── Helpers ─────────────────────────────────────────────────────────
_makeDomBtn(label, bgNormal, bgHover, borderColor) {
const btn = document.createElement('button');
btn.textContent = label;
btn._bgNormal = bgNormal;
btn._bgHover = bgHover;
Object.assign(btn.style, {
padding: '0.8vmin 0',
background: bgNormal,
color: '#ddeeff',
border: `1px solid ${borderColor}`,
borderRadius: '4px',
fontSize: '1.7vmin',
fontFamily: 'Arial, sans-serif',
cursor: 'pointer',
whiteSpace: 'nowrap',
textAlign: 'center',
});
btn.addEventListener('mouseenter', () => { if (!btn.disabled) btn.style.background = btn._bgHover; });
btn.addEventListener('mouseleave', () => { if (!btn.disabled) btn.style.background = btn._bgNormal; });
return btn;
}
_destroyDomUI() {
if (this._uiLayer && this._uiLayer.parentNode) {
this._uiLayer.parentNode.removeChild(this._uiLayer);
}
this._uiLayer = null;
}
// ─── Start puzzle ────────────────────────────────────────────────────
_startPuzzle() {
if (this.selectedImageIdx === null || this.selectedPieces === null) return;
const img = this._puzzleImages[this.selectedImageIdx];
this.scene.start('PuzzleScene', {
imageKey: img.key,
imagePath: img.path,
pieceCount: this.selectedPieces,
roomCode: generateRoomCode()
});
}
}

745
js/scenes/PuzzleScene.js Normal file
View File

@ -0,0 +1,745 @@
/* global Phaser, PuzzleGenerator, PieceRenderer, PieceObject, GroupManager,
SnapDetector, PuzzleState, StorageManager, getRoomCodeFromURL */
const SNAP_RADIUS_FACTOR = 0.35; // fraction of pieceW
const GLOW_RADIUS_FACTOR = 1.4; // fraction of pieceW
const DRAG_THRESHOLD = 6; // pixels before treating as drag (not a second click)
class PuzzleScene extends Phaser.Scene {
constructor() {
super({ key: 'PuzzleScene' });
}
// ─── Phaser lifecycle ────────────────────────────────────────────────
init(data) {
this.cfg = data || {};
// Check if we should restore from a URL room code
const urlCode = getRoomCodeFromURL();
if (urlCode && !this.cfg.roomCode) {
this.cfg.roomCode = urlCode;
this.cfg._restore = true;
}
}
preload() {
// If restoring from storage, the image may not be in the cache
if (this.cfg._restore) {
const saved = StorageManager.load(this.cfg.roomCode);
if (saved) {
this.cfg = Object.assign(this.cfg, saved);
if (!this.textures.exists(saved.imageKey)) {
this.load.image(saved.imageKey, saved.imagePath);
}
}
}
}
create() {
const { width, height } = this.sys.game.config;
// Show loading state until pieces are rendered
this._loadBar = this._createLoadBar(width, height);
const saved = this.cfg._restore ? StorageManager.load(this.cfg.roomCode) : null;
// Generate puzzle data
const gen = PuzzleGenerator.generate(this.cfg.pieceCount);
this._cols = gen.cols;
this._rows = gen.rows;
this._pieces = saved ? saved.pieces : gen.pieces;
const sourceImg = this.textures.get(this.cfg.imageKey).getSourceImage();
const imageW = sourceImg.naturalWidth || sourceImg.width;
const imageH = sourceImg.naturalHeight || sourceImg.height;
PieceRenderer.renderAll(
this, sourceImg, this._pieces, this._cols, this._rows, imageW, imageH,
(done, total) => this._updateLoadBar(done, total)
).then(({ pieceW, pieceH, tabSize, canvasW, canvasH }) => {
this._pieceW = pieceW;
this._pieceH = pieceH;
this._tabSize = tabSize;
this._canvasW = canvasW;
this._canvasH = canvasH;
this._destroyLoadBar();
this._buildScene(saved);
});
}
update() {
if (!this._ready) return;
// Camera pan (right-click drag, or left-click drag on empty space)
const ptr = this.input.activePointer;
if (this._isPanning && ptr.isDown && !this._heldPiece) {
const cam = this.cameras.main;
const pdx = (ptr.x - this._panLastX) / cam.zoom;
const pdy = (ptr.y - this._panLastY) / cam.zoom;
cam.scrollX -= pdx;
cam.scrollY -= pdy;
this._panLastX = ptr.x;
this._panLastY = ptr.y;
}
if (this._heldPiece) {
const cam = this.cameras.main;
const dx = (ptr.x - this._lastPtrX) / cam.zoom;
const dy = (ptr.y - this._lastPtrY) / cam.zoom;
this._lastPtrX = ptr.x;
this._lastPtrY = ptr.y;
if (dx !== 0 || dy !== 0) {
this._dragDistance += Math.abs(dx) + Math.abs(dy);
this._moveHeldGroup(dx, dy);
this._drawGlow();
}
}
}
// ─── Scene construction ──────────────────────────────────────────────
_buildScene(saved) {
const { width, height } = this.sys.game.config;
// Glow graphics (above pieces, below UI)
this._glowGraphics = this.add.graphics().setDepth(500);
// Create PieceObjects
this._pieceObjects = new Map(); // pieceId → PieceObject
this._pieces.forEach(pd => {
const po = new PieceObject(
this, pd,
this._pieceW, this._pieceH, this._tabSize, this._canvasW, this._canvasH
);
this._pieceObjects.set(pd.id, po);
});
// GroupManager
if (saved && saved.groups) {
this._groupManager = GroupManager.deserialize(saved.groups);
} else {
this._groupManager = new GroupManager();
this._pieces.forEach(pd => this._groupManager.createSingletonGroup(pd.id));
}
// Calculate world bounds and set up camera
const worldBounds = this._calcWorldBounds();
this._worldW = worldBounds.w;
this._worldH = worldBounds.h;
if (!saved) {
this._shufflePieces(this._worldW, this._worldH);
} else {
// Sync visual positions for restored puzzles
this._pieces.forEach(pd => {
this._pieceObjects.get(pd.id).setPosition(pd.x, pd.y);
});
}
this._setupCamera(this._worldW, this._worldH);
// Interaction state
this._heldPiece = null;
this._lastPtrX = 0;
this._lastPtrY = 0;
this._dragDistance = 0;
this._justPickedUp = false; // true during the pointerup that completes the pickup click
this._isPanning = false;
this._panLastX = 0;
this._panLastY = 0;
this._ready = true;
// Input
this.input.on('gameobjectdown', this._onPieceDown, this);
this.input.on('pointerdown', this._onPointerDown, this);
this.input.on('pointerup', this._onPointerUp, this);
// Right-click also starts pan (only when no piece is held)
this.input.on('pointerdown', (ptr) => {
if (ptr.rightButtonDown() && !this._heldPiece) {
this._isPanning = true;
this._panLastX = ptr.x;
this._panLastY = ptr.y;
}
});
// Stop panning on any button release
this.input.on('pointerup', () => {
this._isPanning = false;
});
// Disable context menu on the game canvas
this.sys.game.canvas.addEventListener('contextmenu', e => e.preventDefault());
// Mouse wheel zoom (DOM event — Phaser 3.9 doesn't have input 'wheel')
this._wheelHandler = (e) => {
e.preventDefault();
if (this._completed || !this._minZoom) return;
const cam = this.cameras.main;
const oldZoom = cam.zoom;
const ptr = this.input.activePointer;
// Zoom in or out by 10%
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(this._minZoom, Math.min(2.0, oldZoom * factor));
if (newZoom === oldZoom) return;
// Zoom toward cursor: adjust scroll so the world point under the
// cursor stays fixed. Phaser 3.9 camera zooms around its centre
// (width/2, height/2), so the screen→world formula is:
// worldX = (screenX - originX) / zoom + scrollX + originX
// Solving for newScrollX:
const originX = cam.width / 2;
const originY = cam.height / 2;
cam.scrollX += (ptr.x - originX) * (1 / oldZoom - 1 / newZoom);
cam.scrollY += (ptr.y - originY) * (1 / oldZoom - 1 / newZoom);
cam.setZoom(newZoom);
};
// Use document-level listener — the canvas fills the viewport so all
// wheel events on the page are for the game.
document.addEventListener('wheel', this._wheelHandler, { passive: false });
// DOM UI (immune to camera zoom)
this._buildDomUI();
// Check already complete on restore
if (saved && saved.completed) {
this._showCompletion(false);
}
}
_shufflePieces(worldW, worldH) {
const margin = Math.max(this._canvasW, this._canvasH) * 0.5;
const safeX0 = margin;
const safeX1 = worldW - margin;
const safeY0 = margin;
const safeY1 = worldH - margin;
const safeW = safeX1 - safeX0;
const safeH = safeY1 - safeY0;
// Zone-based placement: divide world into a grid of zones
const n = this._pieces.length;
const zoneCols = Math.ceil(Math.sqrt(n * (safeW / safeH)));
const zoneRows = Math.ceil(n / zoneCols);
const zoneW = safeW / zoneCols;
const zoneH = safeH / zoneRows;
// Shuffle index array
const indices = Array.from({ length: n }, (_, i) => i);
for (let i = n - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
this._pieces.forEach((pd, i) => {
const zone = indices[i];
const zc = zone % zoneCols;
const zr = Math.floor(zone / zoneCols);
const cx = safeX0 + zc * zoneW + zoneW / 2;
const cy = safeY0 + zr * zoneH + zoneH / 2;
const jitterX = (Math.random() - 0.5) * zoneW * 0.3;
const jitterY = (Math.random() - 0.5) * zoneH * 0.3;
this._pieceObjects.get(pd.id).setPosition(
Math.max(safeX0, Math.min(safeX1, cx + jitterX)),
Math.max(safeY0, Math.min(safeY1, cy + jitterY))
);
});
}
// ─── Camera & world ──────────────────────────────────────────────────
_calcWorldBounds() {
const n = this._pieces.length;
const cellW = this._canvasW * 1.1; // slight padding between cells
const cellH = this._canvasH * 1.1;
const aspect = 16 / 9;
const cols = Math.ceil(Math.sqrt(n * aspect));
const rows = Math.ceil(n / cols);
return {
w: Math.max(1920, cols * cellW),
h: Math.max(1080, rows * cellH)
};
}
_setupCamera(worldW, worldH) {
const { width, height } = this.sys.game.config;
const cam = this.cameras.main;
// Zoom to fit the entire world on screen
this._minZoom = Math.min(width / worldW, height / worldH);
cam.setZoom(this._minZoom);
// Centre camera on the world.
// In Phaser 3.9 the camera centre in world space is:
// centreX = scrollX + originX + (width/2 - originX)/zoom
// which simplifies (originX = width/2) to: centreX = scrollX + width/2
// So to centre on worldW/2: scrollX = worldW/2 - width/2
cam.scrollX = worldW / 2 - width / 2;
cam.scrollY = worldH / 2 - height / 2;
}
// ─── Input handlers ──────────────────────────────────────────────────
//
// Phaser fires gameobjectdown BEFORE the scene-level pointerdown.
// We use _justPickedUp to mark the pointerup that completes the first
// click (pickup) so we don't immediately release again.
_onPointerDown(ptr) {
if (ptr.rightButtonDown()) return; // handled by pan logic
this._lastPtrX = ptr.x;
this._lastPtrY = ptr.y;
this._dragDistance = 0;
// Left-click on empty space (no piece held) → start camera pan
if (!this._heldPiece) {
this._isPanning = true;
this._panLastX = ptr.x;
this._panLastY = ptr.y;
}
}
_onPieceDown(ptr, gameObject) {
if (!this._ready) return;
const clickedPO = this._getPieceObjectForImage(gameObject);
if (!clickedPO) return;
if (this._heldPiece === null) {
// First click: pick up
this._pickUp(clickedPO);
this._justPickedUp = true; // suppress release on the coming pointerup
} else {
const heldGroupId = this._groupManager.getGroupId(this._heldPiece.data.id);
const clickedGroupId = this._groupManager.getGroupId(clickedPO.data.id);
if (heldGroupId === clickedGroupId) {
// Second click on the held group — release will happen in _onPointerUp
this._justPickedUp = false;
} else {
// Clicked a different piece — swap (no snap on old piece)
this._release(false);
this._pickUp(clickedPO);
this._justPickedUp = true;
}
}
}
_onPointerUp() {
if (!this._ready || !this._heldPiece) return;
if (this._justPickedUp && this._dragDistance < DRAG_THRESHOLD) {
// This pointerup is the tail of the pickup click — don't release
this._justPickedUp = false;
return;
}
this._justPickedUp = false;
this._release(true);
}
_getPieceObjectForImage(gameObject) {
for (const po of this._pieceObjects.values()) {
if (po.image === gameObject) return po;
}
return null;
}
// ─── Pick-up / release ───────────────────────────────────────────────
_pickUp(pieceObj) {
this._heldPiece = pieceObj;
this._dragDistance = 0;
// Seed last pointer position so the first update() frame has zero delta
const ptr = this.input.activePointer;
this._lastPtrX = ptr.x;
this._lastPtrY = ptr.y;
const peers = this._groupManager.getPeersOf(pieceObj.data.id);
let depthOffset = 0;
peers.forEach(id => {
this._pieceObjects.get(id).setDepth(600 + depthOffset);
depthOffset++;
});
}
_release(trySnap) {
if (!this._heldPiece) return;
if (trySnap) {
this._doSnap();
}
// Reset depth
const peers = this._groupManager.getPeersOf(this._heldPiece.data.id);
peers.forEach(id => this._pieceObjects.get(id).setDepth(0));
this._glowGraphics.clear();
this._heldPiece = null;
this._saveState();
this._checkCompletion();
}
_moveHeldGroup(dx, dy) {
const peers = this._groupManager.getPeersOf(this._heldPiece.data.id);
peers.forEach(id => {
const po = this._pieceObjects.get(id);
po.setPosition(po.data.x + dx, po.data.y + dy);
});
}
// ─── Snap ────────────────────────────────────────────────────────────
_doSnap() {
const snapRadius = this._pieceW * SNAP_RADIUS_FACTOR;
let snapped = true;
// Cascade: keep snapping until no more candidates (handles multi-neighbor drops)
while (snapped) {
snapped = false;
const candidates = SnapDetector.check(
this._heldPiece, this._pieceObjects,
this._groupManager, this._cols,
this._pieceW, this._pieceH, snapRadius
);
if (candidates.length === 0) break;
const best = candidates[0];
const { shiftX, shiftY, heldId, neighborId } = best;
// Shift the entire held group
const peers = this._groupManager.getPeersOf(heldId);
peers.forEach(id => {
const po = this._pieceObjects.get(id);
po.setPosition(po.data.x + shiftX, po.data.y + shiftY);
});
// Merge groups
this._groupManager.merge(heldId, neighborId);
snapped = true;
// Update heldPiece reference so getPeersOf returns the enlarged group
// (heldPiece pointer stays the same object, groupManager now returns merged group)
}
}
// ─── Glow ────────────────────────────────────────────────────────────
_drawGlow() {
this._glowGraphics.clear();
if (!this._heldPiece) return;
const glowRadius = this._pieceW * GLOW_RADIUS_FACTOR;
const segments = SnapDetector.getGlowSegments(
this._heldPiece, this._pieceObjects,
this._groupManager, this._cols,
this._pieceW, this._pieceH, glowRadius
);
segments.forEach(({ x0, y0, x1, y1 }) => {
// Three passes: outer, mid, inner glow
[[10, 0x00ccff, 0.10], [6, 0x00eeff, 0.28], [3, 0xaaffff, 0.65]].forEach(([lw, color, alpha]) => {
this._glowGraphics.lineStyle(lw, color, alpha);
this._glowGraphics.beginPath();
this._glowGraphics.moveTo(x0, y0);
this._glowGraphics.lineTo(x1, y1);
this._glowGraphics.strokePath();
});
});
}
// ─── Completion ──────────────────────────────────────────────────────
_checkCompletion() {
if (this._completed) return;
const gm = this._groupManager;
if (gm.groupCount === 1 && gm.pieceCount === this._pieces.length) {
this._completed = true;
this._showCompletion(true);
}
}
_showCompletion(animate) {
const { width, height } = this.sys.game.config;
const cam = this.cameras.main;
// Solved puzzle centre in world space
const solvedCX = this._worldW / 2;
const solvedCY = this._worldH / 2;
const originX = solvedCX - (this._cols * this._pieceW) / 2 + this._pieceW / 2;
const originY = solvedCY - (this._rows * this._pieceH) / 2 + this._pieceH / 2;
if (animate) {
// Zoom camera back to 1:1 and centre on the solved puzzle
this.tweens.add({
targets: cam,
zoom: 1.0,
scrollX: solvedCX - width / 2,
scrollY: solvedCY - height / 2,
duration: 800,
ease: 'Power2'
});
// Tween all pieces to their solved positions
this._pieces.forEach(pd => {
const po = this._pieceObjects.get(pd.id);
const solvedX = originX + pd.gridCol * this._pieceW;
const solvedY = originY + pd.gridRow * this._pieceH;
this.tweens.add({
targets: po.image,
x: solvedX,
y: solvedY,
duration: 600,
delay: 300, // wait for zoom to start first
ease: 'Power2',
onUpdate: () => {
pd.x = po.image.x;
pd.y = po.image.y;
}
});
po.setDepth(0);
});
// Launch confetti after tween
this.time.delayedCall(1100, () => this._launchConfetti(width, height));
} else {
// Snap camera to 1:1 immediately for restored puzzles
cam.setZoom(1.0);
cam.scrollX = solvedCX - width / 2;
cam.scrollY = solvedCY - height / 2;
}
// DOM completion overlay
this.time.delayedCall(animate ? 1300 : 0, () => this._showDomCompletion());
}
// ─── DOM UI ──────────────────────────────────────────────────────────
_buildDomUI() {
// Overlay div that mirrors the canvas CSS dimensions exactly.
// pointer-events:none lets clicks pass through to the canvas everywhere
// except on the interactive children.
this._uiLayer = document.createElement('div');
Object.assign(this._uiLayer.style, {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '100vw',
height: '56.25vw',
maxHeight: '100vh',
maxWidth: '177.78vh',
pointerEvents: 'none',
zIndex: '10',
fontFamily: 'Arial, sans-serif',
});
document.body.appendChild(this._uiLayer);
// Room code — bottom-right
const roomEl = document.createElement('div');
Object.assign(roomEl.style, {
position: 'absolute',
bottom: '1%',
right: '0.8%',
color: '#445577',
fontSize: '1.3vmin',
fontFamily: 'monospace',
});
roomEl.textContent = `Room: ${this.cfg.roomCode}`;
this._uiLayer.appendChild(roomEl);
// Back button — top-left
const backBtn = this._makeDomBtn('← Menu', () => {
this._saveState();
this.scene.start('MainMenuScene');
});
Object.assign(backBtn.style, { top: '1%', left: '0.5%' });
this._uiLayer.appendChild(backBtn);
// Clean up DOM when the scene shuts down
this.events.once('shutdown', () => this._destroyDomUI());
}
_showDomCompletion() {
// Full-screen overlay — blocks clicks from reaching the canvas
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'absolute',
inset: '0',
background: 'rgba(0,0,0,0.55)',
pointerEvents: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5vmin',
});
const title = document.createElement('div');
Object.assign(title.style, {
color: '#ffffaa',
fontSize: '5.5vmin',
fontFamily: 'Georgia, serif',
textShadow: '0 0 8px #886600, 0 2px 0 #886600',
fontWeight: 'bold',
});
title.textContent = 'Puzzle Complete!';
const roomLabel = document.createElement('div');
Object.assign(roomLabel.style, {
color: '#aaccff',
fontSize: '2.2vmin',
});
roomLabel.textContent = `Room: ${this.cfg.roomCode}`;
const btnRow = document.createElement('div');
Object.assign(btnRow.style, {
display: 'flex',
gap: '2vmin',
marginTop: '1vmin',
});
const newBtn = this._makeDomBtn('New Puzzle', () => this.scene.start('NewPuzzleScene'));
const menuBtn = this._makeDomBtn('Main Menu', () => this.scene.start('MainMenuScene'));
// In the flex row, buttons don't need absolute positioning
newBtn.style.position = 'static';
menuBtn.style.position = 'static';
btnRow.appendChild(newBtn);
btnRow.appendChild(menuBtn);
overlay.appendChild(title);
overlay.appendChild(roomLabel);
overlay.appendChild(btnRow);
this._uiLayer.appendChild(overlay);
}
_makeDomBtn(label, onClick) {
const btn = document.createElement('button');
btn.textContent = label;
Object.assign(btn.style, {
position: 'absolute',
padding: '0.8vmin 2vmin',
background: '#1a2a4a',
color: '#ddeeff',
border: '1px solid #4477bb',
borderRadius: '4px',
fontSize: '1.6vmin',
fontFamily: 'Arial, sans-serif',
cursor: 'pointer',
pointerEvents: 'auto',
whiteSpace: 'nowrap',
});
// Hover effect
btn.addEventListener('mouseenter', () => { btn.style.background = '#223355'; btn.style.borderColor = '#66aaff'; });
btn.addEventListener('mouseleave', () => { btn.style.background = '#1a2a4a'; btn.style.borderColor = '#4477bb'; });
btn.addEventListener('click', onClick);
return btn;
}
_destroyDomUI() {
if (this._uiLayer && this._uiLayer.parentNode) {
this._uiLayer.parentNode.removeChild(this._uiLayer);
}
this._uiLayer = null;
document.removeEventListener('wheel', this._wheelHandler);
}
_launchConfetti(width, height) {
const colors = [0xff4444, 0x44ff44, 0x4444ff, 0xffff44, 0xff44ff, 0x44ffff, 0xffffff];
// Generate tiny solid-color textures for each confetti color
const keys = colors.map((color, i) => {
const key = `confetti_${i}`;
if (!this.textures.exists(key)) {
const gfx = this.make.graphics({ x: 0, y: 0 }, false);
gfx.fillStyle(color, 1);
gfx.fillRect(0, 0, 8, 8);
gfx.generateTexture(key, 8, 8);
gfx.destroy();
}
return key;
});
// Create one emitter per color
keys.forEach(key => {
const manager = this.add.particles(key);
manager.setDepth(1003);
const emitter = manager.createEmitter({
x: { min: 0, max: width },
y: -10,
speedY: { min: 80, max: 220 },
speedX: { min: -60, max: 60 },
rotate: { min: 0, max: 360 },
scale: { start: 1, end: 0.3 },
alpha: { start: 1, end: 0 },
lifespan: 2200,
quantity: 2,
frequency: 50,
gravityY: 60
});
this.time.delayedCall(2500, () => { emitter.on = false; });
});
}
// ─── Persistence ─────────────────────────────────────────────────────
_saveState() {
const state = new PuzzleState({
imageKey: this.cfg.imageKey,
imagePath: this.cfg.imagePath,
pieceCount: this.cfg.pieceCount,
roomCode: this.cfg.roomCode,
cols: this._cols,
rows: this._rows,
pieces: this._pieces,
groups: this._groupManager.serialize(),
completed: this._completed || false
});
StorageManager.save(state);
}
// ─── Loading bar ─────────────────────────────────────────────────────
_createLoadBar(width, height) {
const barW = 400, barH = 24;
const bx = width / 2 - barW / 2;
const by = height / 2 - barH / 2;
const bg = this.add.graphics().setScrollFactor(0);
bg.fillStyle(0x111133, 1);
bg.lineStyle(1, 0x333366, 1);
bg.fillRect(bx, by, barW, barH);
bg.strokeRect(bx, by, barW, barH);
const fill = this.add.graphics().setScrollFactor(0);
const label = this.add.text(width / 2, height / 2 - 30, 'Generating pieces…', {
fontFamily: 'Arial, sans-serif',
fontSize: '18px',
color: '#8899cc'
}).setOrigin(0.5).setScrollFactor(0);
return { bg, fill, label, bx, by, barW, barH };
}
_updateLoadBar(done, total) {
if (!this._loadBar) return;
const { fill, bx, by, barW, barH } = this._loadBar;
const pct = done / total;
fill.clear();
fill.fillStyle(0x3366cc, 1);
fill.fillRect(bx + 2, by + 2, (barW - 4) * pct, barH - 4);
}
_destroyLoadBar() {
if (!this._loadBar) return;
const { bg, fill, label } = this._loadBar;
bg.destroy(); fill.destroy(); label.destroy();
this._loadBar = null;
}
}

38
js/state/PuzzleState.js Normal file
View File

@ -0,0 +1,38 @@
class PuzzleState {
constructor({ imageKey, imagePath, pieceCount, roomCode, cols, rows, pieces, groups, completed }) {
this.imageKey = imageKey;
this.imagePath = imagePath;
this.pieceCount = pieceCount;
this.roomCode = roomCode;
this.cols = cols;
this.rows = rows;
this.pieces = pieces; // PieceData[]
this.groups = groups; // number[][] — each inner array is one group's piece IDs
this.completed = completed || false;
}
serialize() {
return {
imageKey: this.imageKey,
imagePath: this.imagePath,
pieceCount: this.pieceCount,
roomCode: this.roomCode,
cols: this.cols,
rows: this.rows,
pieces: this.pieces.map(p => ({
id: p.id,
gridRow: p.gridRow,
gridCol: p.gridCol,
edges: p.edges,
x: p.x,
y: p.y
})),
groups: this.groups,
completed: this.completed
};
}
static deserialize(obj) {
return new PuzzleState(obj);
}
}

View File

@ -0,0 +1,30 @@
/* global PuzzleState */
class StorageManager {
static _key(roomCode) {
return `ipuzzle_${roomCode}`;
}
static save(state) {
try {
localStorage.setItem(StorageManager._key(state.roomCode), JSON.stringify(state.serialize()));
} catch (e) {
console.warn('iPuzzle: localStorage save failed', e);
}
}
static load(roomCode) {
try {
const raw = localStorage.getItem(StorageManager._key(roomCode));
if (!raw) return null;
return PuzzleState.deserialize(JSON.parse(raw));
} catch (e) {
console.warn('iPuzzle: localStorage load failed', e);
return null;
}
}
static remove(roomCode) {
localStorage.removeItem(StorageManager._key(roomCode));
}
}

29
software.md Normal file
View File

@ -0,0 +1,29 @@
# iPuzzle - A multiplayer puzzle game
## Overview
I want to build a game in phaser 3.9 where players put together puzzles. The initial game will need:
- A Main Menu
- A "New Puzzle" Button
- A "Join Puzzle" Button
- A New Puzzle Scene
- players select which puzzle to complete from 16x9 images stored in /assets/images/puzzles
- players can select to split the puzzle into 40, 60 or 100 pieces
- Once the player has finished choosing their puzzle, the image will need to be divided into the correct number of pieces in shapes that look like puzzle pieces and fit together correctly.
- A Puzzle Scene
- The puzzle pieces from the new puzzle scene are shuffled and moved into random places on the screen.
- The player can click on a piece to pick it up, and click it a second time to release it.
- If a picked up piece is moved next to a piece that it fits with, the corresponding edge begins to glow.
- If a picked up piece is released within a short distance of a matching piece then they automatically lock together.
- Whenever a player has a piece picked up it should be on top of all other pieces.
- When the full puzzle is completed, display rewarding graphics and text.
- The Puzzle Scene will need to create a random 4 character code made with upper-case letters and numbers. This code will be used later to allow other players to join the game and work together.
- As you craft how this all works, keep the mid-term goal of multiplayer in mind, we will want to build that in later.
## Framework
- Phaser 3.9
- JavaScript
- ES6 standards -- Do NOT require a package manager to play this game
- Multiplayer capable framework
- Puzzle status, location, etc. need to be saved in local storage

4
start_web.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
# Start a simple HTTP server on port 8000
python3 -m http.server 8000