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:
parent
5b1c8214a6
commit
e71dab9c45
|
|
@ -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,6 +889,57 @@ class PuzzleScene extends Phaser.Scene {
|
||||||
|
|
||||||
const releasedPieceId = this._heldPiece.data.id;
|
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) {
|
if (trySnap) {
|
||||||
this._doSnap();
|
this._doSnap();
|
||||||
}
|
}
|
||||||
|
|
@ -801,6 +961,7 @@ class PuzzleScene extends Phaser.Scene {
|
||||||
this._groupManager.serialize()
|
this._groupManager.serialize()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this._glowGraphics.clear();
|
this._glowGraphics.clear();
|
||||||
this._heldPiece = null;
|
this._heldPiece = null;
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue