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' }); 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 ──────────────────────────────────────────────── // ─── Phaser lifecycle ────────────────────────────────────────────────
init(data) { init(data) {
@ -172,8 +183,28 @@ class PuzzleScene extends Phaser.Scene {
this._playNextTrack(); this._playNextTrack();
} }
// Camera pan (right-click drag, or left-click drag on empty space)
const ptr = this.input.activePointer; 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) { if (this._isPanning && ptr.isDown && !this._heldPiece) {
const cam = this.cameras.main; const cam = this.cameras.main;
const pdx = (ptr.x - this._panLastX) / cam.zoom; const pdx = (ptr.x - this._panLastX) / cam.zoom;
@ -279,6 +310,12 @@ class PuzzleScene extends Phaser.Scene {
this._panLastY = 0; this._panLastY = 0;
this._ready = true; 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 // Input
this.input.on('gameobjectdown', this._onPieceDown, this); this.input.on('gameobjectdown', this._onPieceDown, this);
this.input.on('pointerdown', this._onPointerDown, this); this.input.on('pointerdown', this._onPointerDown, this);
@ -692,6 +729,13 @@ class PuzzleScene extends Phaser.Scene {
this._lastPtrY = ptr.y; this._lastPtrY = ptr.y;
this._dragDistance = 0; 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 // Left-click on empty space (no piece held) → start camera pan
if (!this._heldPiece) { if (!this._heldPiece) {
this._isPanning = true; this._isPanning = true;
@ -728,6 +772,14 @@ class PuzzleScene extends Phaser.Scene {
} }
_onPointerUp() { _onPointerUp() {
// Finalize box selection
if (this._isBoxSelecting) {
this._isBoxSelecting = false;
this._boxGraphics.clear();
this._finalizeBoxSelection();
return;
}
if (!this._ready || !this._heldPiece) return; if (!this._ready || !this._heldPiece) return;
if (this._justPickedUp && this._dragDistance < DRAG_THRESHOLD) { if (this._justPickedUp && this._dragDistance < DRAG_THRESHOLD) {
@ -747,6 +799,63 @@ class PuzzleScene extends Phaser.Scene {
return null; 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 ─────────────────────────────────────────────── // ─── Pick-up / release ───────────────────────────────────────────────
_pickUp(pieceObj) { _pickUp(pieceObj) {
@ -780,26 +889,78 @@ class PuzzleScene extends Phaser.Scene {
const releasedPieceId = this._heldPiece.data.id; const releasedPieceId = this._heldPiece.data.id;
if (trySnap) { if (this._boxSelectedIds) {
this._doSnap(); // Box selection release: snap and reset each distinct group independently
} const processedGroups = new Set();
// Reset depth if (trySnap) {
const peers = this._groupManager.getPeersOf(releasedPieceId); // Collect distinct groups from the selection
peers.forEach(id => this._pieceObjects.get(id).setDepth(0)); const groupAnchors = [];
this._boxSelectedIds.forEach(id => {
const groupId = this._groupManager.getGroupId(id);
if (!processedGroups.has(groupId)) {
processedGroups.add(groupId);
groupAnchors.push(id);
}
});
// Broadcast release with final positions and group state // Snap each group independently
if (this._isNetworked) { groupAnchors.forEach(anchorId => {
const positions = []; const fakePiece = this._pieceObjects.get(anchorId);
peers.forEach(id => { const saved = this._heldPiece;
const po = this._pieceObjects.get(id); this._heldPiece = fakePiece;
positions.push({ id, x: po.data.x, y: po.data.y }); this._doSnap();
this._heldPiece = saved;
});
}
// Reset depth for all selected pieces
this._boxSelectedIds.forEach(id => {
this._pieceObjects.get(id).setDepth(0);
}); });
NetworkManager.sendRelease(
releasedPieceId, // Broadcast release for each distinct group
positions, if (this._isNetworked) {
this._groupManager.serialize() 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();
}
// Reset depth
const peers = this._groupManager.getPeersOf(releasedPieceId);
peers.forEach(id => this._pieceObjects.get(id).setDepth(0));
// Broadcast release with final positions and group state
if (this._isNetworked) {
const positions = [];
peers.forEach(id => {
const po = this._pieceObjects.get(id);
positions.push({ id, x: po.data.x, y: po.data.y });
});
NetworkManager.sendRelease(
releasedPieceId,
positions,
this._groupManager.serialize()
);
}
} }
this._glowGraphics.clear(); this._glowGraphics.clear();
@ -813,8 +974,9 @@ class PuzzleScene extends Phaser.Scene {
} }
_moveHeldGroup(dx, dy) { _moveHeldGroup(dx, dy) {
const peers = this._groupManager.getPeersOf(this._heldPiece.data.id); // If box-selected, move all selected pieces; otherwise just the held group
peers.forEach(id => { const ids = this._boxSelectedIds || this._groupManager.getPeersOf(this._heldPiece.data.id);
ids.forEach(id => {
const po = this._pieceObjects.get(id); const po = this._pieceObjects.get(id);
po.setPosition(po.data.x + dx, po.data.y + dy); po.setPosition(po.data.x + dx, po.data.y + dy);
}); });
@ -884,7 +1046,7 @@ class PuzzleScene extends Phaser.Scene {
_drawGlow() { _drawGlow() {
this._glowGraphics.clear(); this._glowGraphics.clear();
if (!this._heldPiece) return; if (!this._heldPiece || this._boxSelectedIds) return;
const glowRadius = this._pieceW * GLOW_RADIUS_FACTOR; const glowRadius = this._pieceW * GLOW_RADIUS_FACTOR;
const segments = SnapDetector.getGlowSegments( const segments = SnapDetector.getGlowSegments(