first commit
This commit is contained in:
commit
9460041867
|
|
@ -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 |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Start a simple HTTP server on port 8000
|
||||
python3 -m http.server 8000
|
||||
Loading…
Reference in New Issue