feat: add box selection with Ctrl+drag for multi-piece manipulation

- Implement screen-to-world coordinate conversion to support camera zoom/pan
- Add Ctrl+drag box selection that highlights area and selects all pieces within
- Support snapping and releasing multiple groups independently during box release
- Track selected pieces via `_boxSelectedIds` set, including their group peers
- Adjust depth, network broadcasting, and glow rendering for multi-selection context
This commit is contained in:
Brian Fertig 2026-04-05 12:17:45 -06:00
parent 5b1c8214a6
commit e71dab9c45
1 changed files with 183 additions and 21 deletions

View File

@ -25,6 +25,17 @@ class PuzzleScene extends Phaser.Scene {
super({ key: 'PuzzleScene' });
}
/** Convert screen coordinates to world coordinates, accounting for zoom origin. */
_screenToWorld(screenX, screenY) {
const cam = this.cameras.main;
const ox = cam.width / 2;
const oy = cam.height / 2;
return {
x: (screenX - ox) / cam.zoom + cam.scrollX + ox,
y: (screenY - oy) / cam.zoom + cam.scrollY + oy,
};
}
// ─── Phaser lifecycle ────────────────────────────────────────────────
init(data) {
@ -172,8 +183,28 @@ class PuzzleScene extends Phaser.Scene {
this._playNextTrack();
}
// Camera pan (right-click drag, or left-click drag on empty space)
const ptr = this.input.activePointer;
// Box selection drawing (CTRL+drag)
if (this._isBoxSelecting && ptr.isDown) {
const curWorld = this._screenToWorld(ptr.x, ptr.y);
const sx = this._boxStartWorld.x;
const sy = this._boxStartWorld.y;
const ex = curWorld.x;
const ey = curWorld.y;
const rx = Math.min(sx, ex);
const ry = Math.min(sy, ey);
const rw = Math.abs(ex - sx);
const rh = Math.abs(ey - sy);
this._boxGraphics.clear();
this._boxGraphics.lineStyle(2, 0x44aaff, 0.8);
this._boxGraphics.fillStyle(0x44aaff, 0.12);
this._boxGraphics.fillRect(rx, ry, rw, rh);
this._boxGraphics.strokeRect(rx, ry, rw, rh);
}
// Camera pan (right-click drag, or left-click drag on empty space)
if (this._isPanning && ptr.isDown && !this._heldPiece) {
const cam = this.cameras.main;
const pdx = (ptr.x - this._panLastX) / cam.zoom;
@ -279,6 +310,12 @@ class PuzzleScene extends Phaser.Scene {
this._panLastY = 0;
this._ready = true;
// Box selection state (CTRL+drag)
this._isBoxSelecting = false;
this._boxStartWorld = null; // { x, y } in world coords
this._boxGraphics = this.add.graphics().setDepth(510).setScrollFactor(1);
this._boxSelectedIds = null; // Set<pieceId> when multi-holding
// Input
this.input.on('gameobjectdown', this._onPieceDown, this);
this.input.on('pointerdown', this._onPointerDown, this);
@ -692,6 +729,13 @@ class PuzzleScene extends Phaser.Scene {
this._lastPtrY = ptr.y;
this._dragDistance = 0;
// CTRL + left-click on empty space → start box selection
if (ptr.event && ptr.event.ctrlKey && !this._heldPiece) {
this._isBoxSelecting = true;
this._boxStartWorld = this._screenToWorld(ptr.x, ptr.y);
return;
}
// Left-click on empty space (no piece held) → start camera pan
if (!this._heldPiece) {
this._isPanning = true;
@ -728,6 +772,14 @@ class PuzzleScene extends Phaser.Scene {
}
_onPointerUp() {
// Finalize box selection
if (this._isBoxSelecting) {
this._isBoxSelecting = false;
this._boxGraphics.clear();
this._finalizeBoxSelection();
return;
}
if (!this._ready || !this._heldPiece) return;
if (this._justPickedUp && this._dragDistance < DRAG_THRESHOLD) {
@ -747,6 +799,63 @@ class PuzzleScene extends Phaser.Scene {
return null;
}
// ─── Box selection ───────────────────────────────────────────────────
_finalizeBoxSelection() {
const ptr = this.input.activePointer;
const endWorld = this._screenToWorld(ptr.x, ptr.y);
const sx = this._boxStartWorld.x;
const sy = this._boxStartWorld.y;
const minX = Math.min(sx, endWorld.x);
const minY = Math.min(sy, endWorld.y);
const maxX = Math.max(sx, endWorld.x);
const maxY = Math.max(sy, endWorld.y);
// Too small a box — ignore
if ((maxX - minX) < 5 && (maxY - minY) < 5) return;
// Find all pieces whose centers fall within the box
const selectedIds = new Set();
this._pieceObjects.forEach((po, id) => {
if (this._remoteClaims.has(id)) return; // skip pieces held by other players
const px = po.data.x;
const py = po.data.y;
if (px >= minX && px <= maxX && py >= minY && py <= maxY) {
// Add this piece and all its group peers
const peers = this._groupManager.getPeersOf(id);
peers.forEach(pid => selectedIds.add(pid));
}
});
if (selectedIds.size === 0) return;
// Pick the first piece as the anchor for the held-piece system
const anchorId = selectedIds.values().next().value;
const anchorPO = this._pieceObjects.get(anchorId);
this._boxSelectedIds = selectedIds;
this._heldPiece = anchorPO;
this._dragDistance = 0;
this._justPickedUp = true;
this.sound.play('sfx_grab', { volume: 0.3 });
const ptrNow = this.input.activePointer;
this._lastPtrX = ptrNow.x;
this._lastPtrY = ptrNow.y;
// Raise depth of all selected pieces
let depthOffset = 0;
selectedIds.forEach(id => {
this._pieceObjects.get(id).setDepth(600 + depthOffset);
depthOffset++;
});
// Network: claim the anchor piece
if (this._isNetworked) {
NetworkManager.claimPiece(anchorPO.data.id);
}
}
// ─── Pick-up / release ───────────────────────────────────────────────
_pickUp(pieceObj) {
@ -780,6 +889,57 @@ class PuzzleScene extends Phaser.Scene {
const releasedPieceId = this._heldPiece.data.id;
if (this._boxSelectedIds) {
// Box selection release: snap and reset each distinct group independently
const processedGroups = new Set();
if (trySnap) {
// Collect distinct groups from the selection
const groupAnchors = [];
this._boxSelectedIds.forEach(id => {
const groupId = this._groupManager.getGroupId(id);
if (!processedGroups.has(groupId)) {
processedGroups.add(groupId);
groupAnchors.push(id);
}
});
// Snap each group independently
groupAnchors.forEach(anchorId => {
const fakePiece = this._pieceObjects.get(anchorId);
const saved = this._heldPiece;
this._heldPiece = fakePiece;
this._doSnap();
this._heldPiece = saved;
});
}
// Reset depth for all selected pieces
this._boxSelectedIds.forEach(id => {
this._pieceObjects.get(id).setDepth(0);
});
// Broadcast release for each distinct group
if (this._isNetworked) {
const sentGroups = new Set();
this._boxSelectedIds.forEach(id => {
const groupId = this._groupManager.getGroupId(id);
if (!sentGroups.has(groupId)) {
sentGroups.add(groupId);
const peers = this._groupManager.getPeersOf(id);
const positions = [];
peers.forEach(pid => {
const po = this._pieceObjects.get(pid);
positions.push({ id: pid, x: po.data.x, y: po.data.y });
});
NetworkManager.sendRelease(id, positions, this._groupManager.serialize());
}
});
}
this._boxSelectedIds = null;
} else {
// Normal single-piece/group release
if (trySnap) {
this._doSnap();
}
@ -801,6 +961,7 @@ class PuzzleScene extends Phaser.Scene {
this._groupManager.serialize()
);
}
}
this._glowGraphics.clear();
this._heldPiece = null;
@ -813,8 +974,9 @@ class PuzzleScene extends Phaser.Scene {
}
_moveHeldGroup(dx, dy) {
const peers = this._groupManager.getPeersOf(this._heldPiece.data.id);
peers.forEach(id => {
// If box-selected, move all selected pieces; otherwise just the held group
const ids = this._boxSelectedIds || this._groupManager.getPeersOf(this._heldPiece.data.id);
ids.forEach(id => {
const po = this._pieceObjects.get(id);
po.setPosition(po.data.x + dx, po.data.y + dy);
});
@ -884,7 +1046,7 @@ class PuzzleScene extends Phaser.Scene {
_drawGlow() {
this._glowGraphics.clear();
if (!this._heldPiece) return;
if (!this._heldPiece || this._boxSelectedIds) return;
const glowRadius = this._pieceW * GLOW_RADIUS_FACTOR;
const segments = SnapDetector.getGlowSegments(