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' });
|
||||
}
|
||||
|
||||
/** 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,26 +889,78 @@ class PuzzleScene extends Phaser.Scene {
|
|||
|
||||
const releasedPieceId = this._heldPiece.data.id;
|
||||
|
||||
if (trySnap) {
|
||||
this._doSnap();
|
||||
}
|
||||
if (this._boxSelectedIds) {
|
||||
// Box selection release: snap and reset each distinct group independently
|
||||
const processedGroups = new Set();
|
||||
|
||||
// Reset depth
|
||||
const peers = this._groupManager.getPeersOf(releasedPieceId);
|
||||
peers.forEach(id => this._pieceObjects.get(id).setDepth(0));
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 });
|
||||
// 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);
|
||||
});
|
||||
NetworkManager.sendRelease(
|
||||
releasedPieceId,
|
||||
positions,
|
||||
this._groupManager.serialize()
|
||||
);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue