diff --git a/public/loading-texture.png b/public/loading-texture.png new file mode 100644 index 0000000..9b1a8ab Binary files /dev/null and b/public/loading-texture.png differ diff --git a/src/index.css b/src/index.css index 146428d..9ff2b44 100644 --- a/src/index.css +++ b/src/index.css @@ -14,3 +14,21 @@ body { code { font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; } + +.selectBox { + border: 1px solid #55aaff; + background-color: rgba(75, 160, 255, 0.3); + position: fixed; +} + +.text-shadow { + text-shadow: + 0px 0px 5px black, + 0px 0px 5px black, + 0px 0px 5px black, + 0px 0px 5px black, + 0px 0px 5px black, + 0px 0px 5px black, + 0px 0px 5px black, + 0px 0px 5px black; +} diff --git a/src/lib/SelectionHelper.ts b/src/lib/SelectionHelper.ts new file mode 100644 index 0000000..e705e22 --- /dev/null +++ b/src/lib/SelectionHelper.ts @@ -0,0 +1,95 @@ +import { Vector2, WebGLRenderer } from 'three'; + +class SelectionHelper { + private element; + private startPoint; + private pointTopLeft; + private pointBottomRight; + public isDown; + public enabled; + public onPointerDown; + onPointerMove: (event: any) => void; + onPointerUp: () => void; + + constructor(private renderer: WebGLRenderer, cssClassName: string) { + this.element = document.createElement('div'); + this.element.classList.add(cssClassName); + this.element.style.pointerEvents = 'none'; + + this.renderer = renderer; + + this.startPoint = new Vector2(); + this.pointTopLeft = new Vector2(); + this.pointBottomRight = new Vector2(); + + this.isDown = false; + this.enabled = true; + + this.onPointerDown = function (event) { + if (this.enabled === false) return; + + this.isDown = true; + this.onSelectStart(event); + }.bind(this); + + this.onPointerMove = function (event) { + if (this.enabled === false) return; + + if (this.isDown) { + this.onSelectMove(event); + } + }.bind(this); + + this.onPointerUp = function () { + if (this.enabled === false) return; + + this.isDown = false; + this.onSelectOver(); + }.bind(this); + + // this.renderer.domElement.addEventListener('pointerdown', this.onPointerDown); + // this.renderer.domElement.addEventListener('pointermove', this.onPointerMove); + // this.renderer.domElement.addEventListener('pointerup', this.onPointerUp); + } + + dispose() { + // this.renderer.domElement.removeEventListener('pointerdown', this.onPointerDown); + // this.renderer.domElement.removeEventListener('pointermove', this.onPointerMove); + // this.renderer.domElement.removeEventListener('pointerup', this.onPointerUp); + } + + onSelectStart(event) { + this.element.style.display = 'none'; + this.renderer.domElement.parentElement.appendChild(this.element); + + this.element.style.left = event.clientX + 'px'; + this.element.style.top = event.clientY + 'px'; + this.element.style.width = '0px'; + this.element.style.height = '0px'; + + this.startPoint.x = event.clientX; + this.startPoint.y = event.clientY; + } + + onSelectMove(event) { + this.element.style.display = 'block'; + + this.pointBottomRight.x = Math.max(this.startPoint.x, event.clientX); + this.pointBottomRight.y = Math.max(this.startPoint.y, event.clientY); + this.pointTopLeft.x = Math.min(this.startPoint.x, event.clientX); + this.pointTopLeft.y = Math.min(this.startPoint.y, event.clientY); + + this.element.style.left = this.pointTopLeft.x + 'px'; + this.element.style.top = this.pointTopLeft.y + 'px'; + this.element.style.width = this.pointBottomRight.x - this.pointTopLeft.x + 'px'; + this.element.style.height = this.pointBottomRight.y - this.pointTopLeft.y + 'px'; + } + + onSelectOver() { + if (this.element.parentElement) { + this.element.parentElement.removeChild(this.element); + } + } +} + +export { SelectionHelper }; diff --git a/src/lib/card.ts b/src/lib/card.ts index eabc152..ea70a98 100644 --- a/src/lib/card.ts +++ b/src/lib/card.ts @@ -4,7 +4,6 @@ import { splitProps } from 'solid-js'; import { BoxGeometry, Color, - ImageBitmapLoader, LinearFilter, Mesh, MeshStandardMaterial, @@ -15,29 +14,61 @@ import { Vector3, } from 'three'; import { Card, CARD_HEIGHT, CARD_STACK_OFFSET, CARD_THICKNESS, CARD_WIDTH } from './constants'; -import { cardsById, getProjectionVec, scene, textureLoader, textureLoaderWorker } from './globals'; +import { + cardBackTexture, + cardLoadingTexture, + cardsById, + getProjectionVec, + scene, + textureLoader, + textureLoaderWorker, +} from './globals'; import { counters } from './ui/counterDialog'; import { cleanupFromNode } from './utils'; -let cardBackTexture: Texture; let alphaMap: Texture; const blackMat = new MeshStandardMaterial({ color: 0x000000 }); -const bitmapLoader = new ImageBitmapLoader(); -bitmapLoader.setOptions({ imageOrientation: 'flipY' }); +let currentSlide = 0; +let totalSlides = 6; +let xSlides = 3; +let ySlides = 2; +let ticks = 0; +let interval = 1 / 7; + +export function updateTextureAnimation(delta: number) { + ticks += delta; + if (ticks < interval) return; + ticks %= interval; + if (!cardLoadingTexture) return; + let x = (currentSlide % xSlides) / xSlides; + let y = ((currentSlide / xSlides) | 0) / ySlides; + cardLoadingTexture.offset.y = y; + cardLoadingTexture.offset.x = x; + currentSlide++; + currentSlide = currentSlide % totalSlides; +} export function createCardGeometry(card: Card, cache?: Map) { const geometry = new BoxGeometry(CARD_WIDTH, CARD_HEIGHT, CARD_THICKNESS); - cardBackTexture = cardBackTexture || textureLoader.load('/arcane-table-back.webp'); - cardBackTexture.colorSpace = SRGBColorSpace; let cardBackMat = new MeshStandardMaterial({ map: cardBackTexture }); - let { mesh: _, modifiers, ...shared } = card; - cardBackMat.transparent = true; + let loadingMat = new MeshStandardMaterial({ map: cardLoadingTexture, alphaMap }); + loadingMat.transparent = true; + + let { mesh: _, modifiers, ...shared } = card; + alphaMap = alphaMap || textureLoader.load(`/alphaMap.webp`); - const mesh = new Mesh(geometry, [blackMat, blackMat, blackMat, blackMat, blackMat, cardBackMat]); + const mesh = new Mesh(geometry, [ + blackMat.clone(), + blackMat.clone(), + blackMat.clone(), + blackMat.clone(), + loadingMat.clone(), + cardBackMat.clone(), + ]); setCardData(mesh, 'isInteractive', true); setCardData(mesh, 'card', shared); setCardData(mesh, 'id', card.id); @@ -60,7 +91,7 @@ export function createCardGeometry(card: Card, cache?: Map) export async function loadCardTextures( card: Card, - cache: Map> + cache: Map> = new Map() ) { const [front, back] = card.mesh.userData.card_face_urls; @@ -87,7 +118,7 @@ export async function loadCardTextures( let frontPromise = cache.get(front)!; frontPromise.then(mat => { - card.mesh.material[4] = mat; + card.mesh.material[4] = mat.clone(); }); if (back) { @@ -115,7 +146,7 @@ export async function loadCardTextures( let backPromise = cache.get(back)!; backPromise.then(mat => { - card.mesh.userData.cardBack = mat; + card.mesh.userData.cardBack = mat.clone(); }); await backPromise; } @@ -161,6 +192,7 @@ export function cloneCard(card: Card, newId: string): Card { updateModifiers(newCard); newCard.detail.search = card.detail.search ?? getSearchLine(newCard.detail); cardsById.set(newCard.id, newCard); + loadCardTextures(newCard); return newCard; } diff --git a/src/lib/cardArea.ts b/src/lib/cardArea.ts index 9cbdac8..4a842ac 100644 --- a/src/lib/cardArea.ts +++ b/src/lib/cardArea.ts @@ -14,7 +14,15 @@ import { } from 'three'; import { animateObject } from './animations'; import { getSerializableCard, setCardData } from './card'; -import { Card, CARD_HEIGHT, CARD_STACK_OFFSET, CARD_THICKNESS, CardZone } from './constants'; +import { + Card, + CARD_HEIGHT, + CARD_STACK_OFFSET, + CARD_THICKNESS, + CARD_ZONE_COLOR, + CardZone, + ZONE_OUTLINE_COLOR, +} from './constants'; import { cardsById, zonesById } from './globals'; import { cleanupMesh, getGlobalRotation } from './utils'; @@ -27,15 +35,19 @@ export class CardArea implements CardZone<{ positionArray?: [number, number, num constructor(public zone: string, public id: string = nanoid()) { let geometry = new BoxGeometry(200, 100, CARD_THICKNESS / 2); - let material = new MeshStandardMaterial({ color: 0x2b2d3a }); //#9d9eae // 1e2029 + let material = new MeshStandardMaterial({ color: CARD_ZONE_COLOR }); //#9d9eae // 1e2029 this.mesh = new Mesh(geometry, material); this.mesh.userData.zone = zone; this.mesh.userData.zoneId = id; + this.mesh.userData.id = id; this.cards = []; this.mesh.position.setY(-50); this.mesh.receiveShadow = true; let edges = new EdgesGeometry(geometry); - let lineSegments = new LineSegments(edges, new LineBasicMaterial({ color: 0xffffff })); + let lineSegments = new LineSegments( + edges, + new LineBasicMaterial({ color: ZONE_OUTLINE_COLOR }) + ); lineSegments.userData.isOrnament = true; lineSegments.position.setZ(0.125); this.mesh.add(lineSegments); diff --git a/src/lib/cardGrid.ts b/src/lib/cardGrid.ts index e4efbd3..cb5f72d 100644 --- a/src/lib/cardGrid.ts +++ b/src/lib/cardGrid.ts @@ -37,6 +37,7 @@ export class CardGrid implements CardZone { zonesById.set(this.id, this); this.mesh.userData.isInteractive = true; this.mesh.userData.zone = zone; + this.mesh.userData.id = id; this.mesh.rotateX(Math.PI * 0.25); this.mesh.position.copy(POSITION); this.scrollContainer = new Group(); @@ -289,12 +290,10 @@ export class CardGrid implements CardZone { if (this.cards.length < 1) { setHoverSignal(); } - console.log(peekFilterText()); if (this.filteredCards) { this.filteredCards = this.filteredCards.filter(card => card.id !== cardMesh.userData.id); } if (!this.filteredCards?.length) { - console.log(this.filteredCards); setPeekFilterText(''); this.filterCards(); } diff --git a/src/lib/cardStack.ts b/src/lib/cardStack.ts index 3d17406..0f1775c 100644 --- a/src/lib/cardStack.ts +++ b/src/lib/cardStack.ts @@ -14,7 +14,14 @@ import { } from 'three'; import { animateObject } from './animations'; import { cleanupCard, getSerializableCard, setCardData } from './card'; -import { Card, CARD_HEIGHT, CARD_THICKNESS, CARD_WIDTH, CardZone } from './constants'; +import { + Card, + CARD_HEIGHT, + CARD_THICKNESS, + CARD_WIDTH, + CardZone, + ZONE_OUTLINE_COLOR, +} from './constants'; import { cardsById, setHoverSignal, zonesById } from './globals'; import { cleanupMesh, getGlobalRotation } from './utils'; @@ -29,7 +36,10 @@ export class CardStack implements CardZone { let geometry = new BoxGeometry(CARD_WIDTH, CARD_HEIGHT, CARD_THICKNESS); let material = new MeshStandardMaterial({ color: 0x000000 }); let edges = new EdgesGeometry(geometry); - let lineSegments = new LineSegments(edges, new LineBasicMaterial({ color: 0xffffff })); + let lineSegments = new LineSegments( + edges, + new LineBasicMaterial({ color: ZONE_OUTLINE_COLOR }) + ); lineSegments.scale.set(1.1, 1.1, 1); lineSegments.userData.isOrnament = true; material.opacity = 0; @@ -38,6 +48,7 @@ export class CardStack implements CardZone { this.mesh.add(lineSegments); this.mesh.userData.zone = zone; this.mesh.userData.zoneId = id; + this.mesh.userData.id = id; createRoot(destroy => { this.destroyReactivity = destroy; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 301433b..936399b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -5,6 +5,9 @@ export const CARD_HEIGHT = 88 / 4; export const CARD_THICKNESS = 0.5 / 4; export const CARD_STACK_OFFSET = 2; +export const ZONE_OUTLINE_COLOR = 0xffffff; +export const CARD_ZONE_COLOR = 0x1a1533; + export interface Card { mesh: Mesh; id: string; diff --git a/src/lib/deck.ts b/src/lib/deck.ts index 8243333..6b23a08 100644 --- a/src/lib/deck.ts +++ b/src/lib/deck.ts @@ -25,6 +25,7 @@ export class Deck implements CardZone<{ location: 'top' | 'bottom' }> { this.mesh.position.set(70, -55, cards.length * 0.125 + 2.5); this.mesh.userData.isInteractive = true; this.mesh.userData.zone = 'deck'; + this.mesh.userData.id = id; this.zone = 'deck'; cards.forEach((card, i) => { diff --git a/src/lib/globals.ts b/src/lib/globals.ts index b468d59..105e78c 100644 --- a/src/lib/globals.ts +++ b/src/lib/globals.ts @@ -27,6 +27,7 @@ import { Card, CARD_WIDTH, CardZone } from './constants'; import type { PlayArea } from './playArea'; import TextureLoaderWorker from './textureLoaderWorker?worker'; import { cleanupFromNode, getFocusCameraPositionRelativeTo } from './utils'; +import { Selection } from './selection'; export function expect(test: boolean, message: string, ...supplemental: any) { if (!test) { @@ -69,6 +70,10 @@ export const colorHashLight = new ColorHash({ lightness: 0.7 }); export const colorHashDark = new ColorHash({ lightness: 0.2 }); export const [selectedDeckIndex, setSelectedDeckIndex] = createSignal(undefined); export let textureLoaderWorker; +export let selection: Selection; + +export let cardLoadingTexture: THREE.Texture; +export let cardBackTexture: THREE.Texture; export function doXTimes(x: number, callback, delay = 100): Promise { if (x < 1) return Promise.resolve(); @@ -116,6 +121,14 @@ export function init({ gameId }) { loadingManager.onProgress = function (item, loaded, total) { console.log(item, loaded, total); }; + + cardBackTexture = textureLoader.load(`/arcane-table-back.webp`); + cardBackTexture.colorSpace = THREE.SRGBColorSpace; + + cardLoadingTexture = textureLoader.load(`/loading-texture.png`); + cardLoadingTexture.repeat.setX(1 / 3); + cardLoadingTexture.repeat.setY(1 / 2); + THREE.Cache.enabled = true; camera = new PerspectiveCamera(50, window.innerWidth / window.innerHeight, 1, 2000); @@ -146,13 +159,12 @@ export function init({ gameId }) { scene.add(arrowHelper); - // let helper = new CameraHelper(focusCamera); - // scene.add(helper); + selection = new Selection(renderer, camera, scene); focusRayCaster = new Raycaster(); const tableGeometry = new BoxGeometry(200, 200, 5); - const tableMaterial = new MeshStandardMaterial({ color: 0xdeb887 }); + const tableMaterial = new MeshStandardMaterial({ color: 0x2c1b4e }); table = new Mesh(tableGeometry, tableMaterial); table.receiveShadow = true; table.userData.zone = 'battlefield'; @@ -197,6 +209,8 @@ export function cleanup() { if (!renderer) return; + selection.destroy(); + renderer.domElement.remove(); renderer.dispose(); @@ -239,6 +253,7 @@ export function onConcede(clientId?: string) { setIsSpectating(false); setIsIntitialized(false); orbitControls?.dispose(); + selection.destroy(); } } diff --git a/src/lib/hand.ts b/src/lib/hand.ts index fa90174..28df9cb 100644 --- a/src/lib/hand.ts +++ b/src/lib/hand.ts @@ -24,6 +24,7 @@ export class Hand implements CardZone { this.mesh.userData.zone = 'hand'; this.mesh.rotateX(Math.PI * 0.25); this.mesh.position.set(0, -120, 40); + this.mesh.userData.id = id; this.mesh.userData.restingPosition = this.mesh.position.clone(); this.zone = 'hand'; @@ -176,9 +177,9 @@ export class Hand implements CardZone { destroy() { this.cards.forEach(card => { card.mesh.removeEventListener('mousein', this.cardMouseIn); - card.mesh.removeEventListener('mouseout',this.cardMouseOut); + card.mesh.removeEventListener('mouseout', this.cardMouseOut); cardsById.delete(card.id); - }) + }); zonesById.delete(this.id); this.destroyReactivity(); this.cards = []; diff --git a/src/lib/playArea.ts b/src/lib/playArea.ts index d8c7ff6..7a3a060 100644 --- a/src/lib/playArea.ts +++ b/src/lib/playArea.ts @@ -14,13 +14,7 @@ import { CardGrid } from './cardGrid'; import { CardStack } from './cardStack'; import { Card, CARD_HEIGHT, CARD_WIDTH, CardZone, SerializableCard } from './constants'; import { Deck, loadCardList, loadDeckList } from './deck'; -import { - cardsById, - doXTimes, - focusCamera, - provider, - zonesById -} from './globals'; +import { cardsById, doXTimes, focusCamera, provider, zonesById } from './globals'; import { Hand } from './hand'; import { transferCard } from './transferCard'; import { getFocusCameraPositionRelativeTo } from './utils'; @@ -469,7 +463,7 @@ export class PlayArea { new Promise(resolve => setTimeout(() => { loadCardTextures(card, cache).then(resolve); - }, i * 10) + }, i * 20) ) ) ); diff --git a/src/lib/selection.ts b/src/lib/selection.ts new file mode 100644 index 0000000..eae59eb --- /dev/null +++ b/src/lib/selection.ts @@ -0,0 +1,189 @@ +import { createRoot } from 'solid-js'; +import { createStore, SetStoreFunction } from 'solid-js/store'; +import { Mesh, Object3D, PerspectiveCamera, Scene, Vector3, WebGLRenderer } from 'three'; +import { SelectionBox } from 'three/addons/interactive/SelectionBox'; +import { SelectionHelper } from './SelectionHelper'; + +const SELECTED_EMISSIVE_COLOR = 0x4ba0ff; + +export class Selection { + selectedItems!: Object3D[]; + private _setSelectedItems!: SetStoreFunction; + helper: SelectionHelper; + selectionBox: SelectionBox; + isDown: boolean; + justSelected: boolean; + selectionSet: Set; + + constructor(renderer: WebGLRenderer, camera: PerspectiveCamera, scene: Scene) { + this.selectionBox = new SelectionBox(camera, scene); + this.helper = new SelectionHelper(renderer, 'selectBox'); + this.helper.enabled = false; + this.isDown = false; + this.justSelected = false; + this.selectionSet = new Set(); + createRoot(() => { + [this.selectedItems, this._setSelectedItems] = createStore([]); + }); + } + + get enabled() { + return this.helper.enabled; + } + + onClick(event: PointerEvent, target?: Object3D): boolean { + if (this.justSelected) { + this.justSelected = false; + return true; + } + if (this.enabled) return false; + if (target && isSelectable(target)) { + if (event.ctrlKey || event.metaKey) { + this.toggleSelection(target); + return true; + } else if (event.shiftKey) { + setMeshEmissivity(target, SELECTED_EMISSIVE_COLOR); + this.addSelectedItems([target]); + return true; + } + } + this.clearSelection(); + return false; + } + + toggleSelection(object: Object3D) { + if (this.selectionSet.has(object)) { + setMeshEmissivity(object, 0x000000); + this.selectionSet.delete(object); + } else { + this.selectionSet.add(object); + setMeshEmissivity(object, SELECTED_EMISSIVE_COLOR); + } + this._setSelectedItems(this.selectionSet.values().toArray()); + } + + startRectangleSelection(event: PointerEvent) { + this.justSelected = false; + this.isDown = true; + this.helper.enabled = true; + this.helper.onPointerDown(event); + if (event.metaKey || event.ctrlKey || event.shiftKey) { + } else { + this.clearSelection(); + } + this.selectionBox.collection = []; + + this.selectionBox.startPoint.copy( + new Vector3( + (event.clientX / window.innerWidth) * 2 - 1, + -(event.clientY / window.innerHeight) * 2 + 1, + 0.5 + ) + ); + } + + onMove(event: PointerEvent) { + this.helper.onPointerMove(event); + if (this.helper.isDown) { + for (let i = 0; i < this.selectionBox.collection.length; i++) { + if (!this.selectionSet.has(this.selectionBox.collection[i])) { + setMeshEmissivity(this.selectionBox.collection[i], 0x000000); + } + } + + this.selectionBox.endPoint.copy( + new Vector3( + (event.clientX / window.innerWidth) * 2 - 1, + -(event.clientY / window.innerHeight) * 2 + 1, + 0.5 + ) + ); + + const allSelected = new Set(this.selectionBox.select().filter(isSelectable)); + + if (event.metaKey || event.ctrlKey) { + let intersection = this.selectionSet.intersection(allSelected); + intersection.forEach(item => setMeshEmissivity(item, 0x000000)); + let difference = this.selectionSet.symmetricDifference(allSelected); + difference.forEach(item => setMeshEmissivity(item, SELECTED_EMISSIVE_COLOR)); + } else { + for (const selected of allSelected) { + setMeshEmissivity(selected, SELECTED_EMISSIVE_COLOR); + } + } + } + } + + completeRectangleSelection(event: PointerEvent) { + this.helper.onPointerUp(); + if (this.helper.enabled && this.isDown) { + this.justSelected = true; + this.selectionBox.endPoint.copy( + new Vector3( + (event.clientX / window.innerWidth) * 2 - 1, + -(event.clientY / window.innerHeight) * 2 + 1, + 0.5 + ) + ); + for (let i = 0; i < this.selectionBox.collection.length; i++) { + if (!this.selectionSet.has(this.selectionBox.collection[i])) { + setMeshEmissivity(this.selectionBox.collection[i], 0x000000); + } + } + let allSelected = new Set(this.selectionBox.select().filter(isSelectable)); + + if (event.metaKey || event.ctrlKey) { + let exclusions = this.selectionSet.intersection(allSelected); + exclusions.forEach(item => { + setMeshEmissivity(item, 0x000000); + this.selectionSet.delete(item); + }); + allSelected = allSelected.difference(exclusions); + } else if (event.shiftKey) { + } else { + this.clearSelection(); + } + + this.addSelectedItems(allSelected); + + for (const selected of allSelected) { + setMeshEmissivity(selected, SELECTED_EMISSIVE_COLOR); + } + } + this.isDown = false; + this.helper.enabled = false; + } + + addSelectedItems(items: Mesh[] | Set) { + items.forEach(item => this.selectionSet.add(item)); + this._setSelectedItems(this.selectionSet.values().toArray()); + } + + private clearSelectionHighlight() { + for (const item of this.selectionSet) { + setMeshEmissivity(item, 0x000000); + } + } + + clearSelection() { + this.clearSelectionHighlight(); + this._setSelectedItems([]); + this.selectionSet.clear(); + } + + destroy() {} +} + +function setMeshEmissivity(mesh: Mesh, color: number) { + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (let i = 0; i < materials.length; i++) { + const material = materials[i]; + if (material.emissive) { + material.emissive.set(color); + } + } +} + +function isSelectable(mesh: Mesh) { + return mesh.userData.isInteractive && mesh.userData.location !== 'deck'; +} diff --git a/src/lib/shortcuts/command-palette.tsx b/src/lib/shortcuts/command-palette.tsx index 10b938c..6e15d1f 100644 --- a/src/lib/shortcuts/command-palette.tsx +++ b/src/lib/shortcuts/command-palette.tsx @@ -27,7 +27,10 @@ export default function CommandPalette(props: { playArea: PlayArea }) { onMount(() => { function listener(event) { - if ((event.metaKey || event.ctrlKey) && event.code === 'Space') { + let mod = event.metaKey || event.ctrlKey; + let space = event.code === 'Space'; + let k = event.key === 'k'; + if (mod && (space || k)) { event.preventDefault(); setIsOpen(open => !open); if (isOpen() && inputRef) { diff --git a/src/lib/shortcuts/hotkeys.ts b/src/lib/shortcuts/hotkeys.ts new file mode 100644 index 0000000..f777212 --- /dev/null +++ b/src/lib/shortcuts/hotkeys.ts @@ -0,0 +1,109 @@ +import hotkeys from 'hotkeys-js'; +import { createEffect, onMount } from 'solid-js'; +import { selection, cardsById, zonesById, playAreas, provider, hoverSignal } from '../globals'; +import { transferCard } from '../transferCard'; +import { drawCards, searchDeck } from './commands/deck'; +import { untapAll } from './commands/field'; + +export function HotKeys() { + const cardMesh = () => hoverSignal()?.mesh; + const playArea = playAreas[provider?.awareness?.clientID]; + const cards = () => { + let items = selection.selectedItems; + if (items.length) return items.map(item => cardsById.get(item.userData.id)); + if (!cardMesh()) return []; + + return [cardsById.get(cardMesh().userData.id)]; + }; + createEffect(() => { + if (selection.selectedItems.length) { + hotkeys.setScope(selection.selectedItems[0].userData.location); + } + }); + + onMount(() => { + hotkeys('shift+u', function () { + untapAll(playArea); + }); + + hotkeys('d', function () { + drawCards(playArea, 1); + }); + + hotkeys('ctrl+d,command+d', function (e) { + e.preventDefault(); + cards().map(card => { + const previousZone = zonesById.get(card.mesh.userData.zoneId); + transferCard(card, previousZone, playArea.graveyardZone); + }); + selection.clearSelection(); + }); + + hotkeys('e', function (e) { + e.preventDefault(); + cards().map(card => { + const previousZone = zonesById.get(card.mesh.userData.zoneId); + transferCard(card, previousZone, playArea.exileZone); + }); + selection.clearSelection(); + }); + + hotkeys('b', function (e) { + e.preventDefault(); + cards().map(card => { + const previousZone = zonesById.get(card.mesh.userData.zoneId); + transferCard(card, previousZone, playArea.battlefieldZone); + }); + }); + + hotkeys('p', function (e) { + e.preventDefault(); + cards().map(card => { + const previousZone = zonesById.get(card.mesh.userData.zoneId); + transferCard(card, previousZone, playArea.peekZone); + }); + selection.clearSelection(); + }); + + hotkeys('s', function (e) { + e.preventDefault(); + searchDeck(playArea); + }); + + hotkeys('escape', 'peek', function (e) { + e.preventDefault(); + playArea.dismissFromZone(playArea.peekZone); + }); + + hotkeys('escape', 'tokenSearch', function (e) { + e.preventDefault(); + playArea.dismissFromZone(playArea.tokenSearchZone); + }); + + hotkeys('escape', 'reveal', function (e) { + e.preventDefault(); + playArea.dismissFromZone(playArea.revealZone); + }); + + hotkeys('t', 'battlefield', function (e) { + e.preventDefault(); + cards().forEach(card => playArea.tap(card.mesh)); + }); + + hotkeys('c', 'battlefield', function (e) { + e.preventDefault(); + cards().forEach(card => playArea.clone(card?.mesh.userData.id)); + }); + + hotkeys('f', 'battlefield', function (e) { + console.log('f'); + e.preventDefault(); + cards().forEach(card => playArea.flip(card.mesh)); + }); + + return () => { + hotkeys.unbind(); + }; + }); + return null; +} diff --git a/src/lib/ui/cardBattlefieldMenu.tsx b/src/lib/ui/cardBattlefieldMenu.tsx index cb3348b..890bd8e 100644 --- a/src/lib/ui/cardBattlefieldMenu.tsx +++ b/src/lib/ui/cardBattlefieldMenu.tsx @@ -1,4 +1,4 @@ -import { Component, createSignal, For, onMount, Show } from 'solid-js'; +import { Component, createSignal, For, Show } from 'solid-js'; import { Mesh } from 'three'; import { Button } from '~/components/ui/button'; import { @@ -20,104 +20,118 @@ import { NumberFieldInput, } from '~/components/ui/number-field'; import NumberFieldMenuItem from '~/components/ui/number-field-menu-item'; -import { cardsById, doXTimes } from '../globals'; +import { cardsById, doXTimes, selection } from '../globals'; import { PlayArea } from '../playArea'; import { counters, setIsCounterDialogOpen } from './counterDialog'; import MoveMenu from './moveMenu'; -import hotkeys from 'hotkeys-js'; const CardBattlefieldMenu: Component<{ playArea: PlayArea; cardMesh?: Mesh }> = props => { let card = () => cardsById.get(props.cardMesh?.userData.id)!; + let meshes = () => + selection.selectedItems.length > 0 ? selection.selectedItems : [props.cardMesh]; + let cardText = () => { + let count = selection.selectedItems.length; + if (count > 1) return `${count} cards`; + return `1 card`; + }; return ( - - - Actions - - { - props.playArea.flip(props.cardMesh); - }}> - FlipF - - - Counters - - - - +
+
{cardText()} selected
+ + + Actions + + { + meshes().forEach(mesh => { + props.playArea.flip(mesh); + }); + }}> + FlipF + + + Counters + + + + - - - - - {counter => { - return ( - -
-
{counter.name}
- - { - let card = cardsById.get(props.cardMesh?.userData.id)!; - props.playArea.modifyCard(card, modifiers => ({ - ...modifiers, - counters: { - ...modifiers.counters, - [counter.id]: parseInt(value.replace(/\,/g, ''), 10), - }, - })); - }}> -
- - - -
-
-
-
- ); - }} -
- 0}> - - - setIsCounterDialogOpen(true)}> - Create New Counter - -
-
- - props.playArea.clone(props.cardMesh?.userData.id)}> - CloneC - - -
Clone {props.cardMesh?.userData?.card?.detail?.name}
-
consider using counters
- - doXTimes(count, () => props.playArea.clone(props.cardMesh?.userData.id), 10) - } - /> -
-
-
-
- - - -
+ + + + + {counter => { + return ( + +
+
{counter.name}
+ + { + let card = cardsById.get(props.cardMesh?.userData.id)!; + props.playArea.modifyCard(card, modifiers => ({ + ...modifiers, + counters: { + ...modifiers.counters, + [counter.id]: parseInt(value.replace(/\,/g, ''), 10), + }, + })); + }}> +
+ + + +
+
+
+
+ ); + }} +
+ 0}> + + + setIsCounterDialogOpen(true)}> + Create New Counter + + + + + props.playArea.clone(props.cardMesh?.userData.id)}> + CloneC + + +
Clone {cardText()}
+
consider using counters
+ + doXTimes(count, () => props.playArea.clone(props.cardMesh?.userData.id), 10) + } + /> +
+
+ + + + { + selection.clearSelection(); + }} + cards={meshes().map(mesh => cardsById.get(mesh?.userData.id))} + fromZone={props.playArea.battlefieldZone} + playArea={props.playArea} + /> + + +
); }; diff --git a/src/lib/ui/moveMenu.tsx b/src/lib/ui/moveMenu.tsx index 559e329..825d9da 100644 --- a/src/lib/ui/moveMenu.tsx +++ b/src/lib/ui/moveMenu.tsx @@ -11,6 +11,7 @@ interface Props { fromZone: CardZone; playArea: PlayArea; text: string; + onComplete?(): void; } const MoveMenu: Component = props => { @@ -19,6 +20,7 @@ const MoveMenu: Component = props => { doXTimes(cards.length, () => { transferCard(cards.shift()!, props.fromZone, zone, { addOptions }); }); + props.onComplete?.(); } function moveToFaceDown(zone: CardZone, addOptions?: T) { @@ -27,6 +29,7 @@ const MoveMenu: Component = props => { let card = cards.shift()!; transferCard(card, props.fromZone, zone, { addOptions, userData: { isFlipped: true } }); }); + props.onComplete?.(); } return ( diff --git a/src/lib/ui/overlay.tsx b/src/lib/ui/overlay.tsx index eac76bc..9f0a467 100644 --- a/src/lib/ui/overlay.tsx +++ b/src/lib/ui/overlay.tsx @@ -1,3 +1,5 @@ +import { Dialog } from '@kobalte/core/dialog'; +import hotkeys from 'hotkeys-js'; import { createEffect, createMemo, @@ -9,8 +11,14 @@ import { Switch, type Component, } from 'solid-js'; - -import { Mesh } from 'three'; +import { Button } from '~/components/ui/button'; +import { + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTrigger, +} from '~/components/ui/dialog'; import { Menubar, MenubarItem, MenubarMenu } from '../../components/ui/menubar'; import { cardsById, @@ -21,8 +29,13 @@ import { playAreas, players, provider, + selection, zonesById, } from '../globals'; +import CommandPalette from '../shortcuts/command-palette'; +import { drawCards, searchDeck } from '../shortcuts/commands/deck'; +import { untapAll } from '../shortcuts/commands/field'; +import { transferCard } from '../transferCard'; import CardBattlefieldMenu from './cardBattlefieldMenu'; import CounterDialog from './counterDialog'; import DeckMenu from './deckMenu'; @@ -33,27 +46,13 @@ import PeekMenu from './peekMenu'; import { LocalPlayer, NetworkPlayer } from './playerMenu'; import RevealMenu from './revealMenu'; import TokenSearchMenu from './tokenMenu'; -import { Dialog } from '@kobalte/core/dialog'; -import { - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTrigger, -} from '~/components/ui/dialog'; -import { Button } from '~/components/ui/button'; -import CommandPalette from '../shortcuts/command-palette'; -import hotkeys from 'hotkeys-js'; -import { untapAll } from '../shortcuts/commands/field'; -import { drawCards, searchDeck } from '../shortcuts/commands/deck'; -import { transferCard } from '../transferCard'; const Overlay: Component = () => { let userData = () => hoverSignal()?.mesh?.userData; let [isLogVisible, setIsLogVisible] = createSignal(false); const isPublic = () => userData()?.isPublic; const isOwner = () => userData()?.clientId === provider?.awareness?.clientID; - const location = createMemo(() => userData()?.location); + const location = () => userData()?.location; const cardMesh = () => hoverSignal()?.mesh; const tether = () => hoverSignal()?.tether; const playArea = playAreas[provider?.awareness?.clientID]; @@ -64,6 +63,14 @@ const Overlay: Component = () => { return { right: `0px`, top: `0` }; }; + const cards = () => { + let items = selection.selectedItems; + if (items.length) return items.map(item => cardsById.get(item.userData.id)); + if (!cardMesh()) return []; + + return [cardsById.get(cardMesh().userData.id)]; + }; + let currentPlayer = () => players().find(player => player.id === provider?.awareness?.clientID); let [container, setContainer] = createSignal(); @@ -77,86 +84,6 @@ const Overlay: Component = () => { if (!parent) return; parent.appendChild(focusRenderer.domElement); }); - onMount(() => { - hotkeys('shift+u', function () { - untapAll(playArea); - }); - - hotkeys('d', function () { - drawCards(playArea, 1); - }); - - hotkeys('ctrl+d,command+d', function (e) { - e.preventDefault(); - if (!cardMesh()) return; - const card = cardsById.get(cardMesh().userData.id); - const previousZone = zonesById.get(card.mesh.userData.zoneId); - transferCard(card, previousZone, playArea.graveyardZone); - }); - - hotkeys('e', function (e) { - e.preventDefault(); - if (!cardMesh()) return; - const card = cardsById.get(cardMesh().userData.id); - const previousZone = zonesById.get(card.mesh.userData.zoneId); - transferCard(card, previousZone, playArea.exileZone); - }); - - hotkeys('b', function (e) { - e.preventDefault(); - if (!cardMesh()) return; - const card = cardsById.get(cardMesh().userData.id); - const previousZone = zonesById.get(card.mesh.userData.zoneId); - transferCard(card, previousZone, playArea.battlefieldZone); - }); - - hotkeys('p', function (e) { - e.preventDefault(); - if (!cardMesh()) return; - const card = cardsById.get(cardMesh().userData.id); - const previousZone = zonesById.get(card.mesh.userData.zoneId); - transferCard(card, previousZone, playArea.peekZone); - }); - - hotkeys('s', function (e) { - e.preventDefault(); - searchDeck(playArea); - }); - - hotkeys('escape', 'peek', function (e) { - e.preventDefault(); - playArea.dismissFromZone(playArea.peekZone); - }); - - hotkeys('escape', 'tokenSearch', function (e) { - e.preventDefault(); - playArea.dismissFromZone(playArea.tokenSearchZone); - }); - - hotkeys('escape', 'reveal', function (e) { - e.preventDefault(); - playArea.dismissFromZone(playArea.revealZone); - }); - - hotkeys('t', 'battlefield', function () { - if (!cardMesh()) return; - playArea.tap(cardMesh()); - }); - - hotkeys('c', 'battlefield', function () { - if (!cardMesh()) return; - playArea.clone(cardMesh().userData.id); - }); - - hotkeys('f', 'battlefield', function () { - if (!cardMesh()) return; - playArea.flip(cardMesh()); - }); - - return () => { - hotkeys.unbind(); - }; - }); return (
{ + target.userData.mouseDistance = target + .worldToLocal(intersection.point.clone()) + .distanceTo(origin); + }); + + targets + .sort((a, b) => { + return b.userData.mouseDistance - a.userData.mouseDistance; + }) + .forEach((target, i) => { + setCardData(target, 'isDragging', true); + + let dragOffset = [0, 0, 0]; + if (target.userData.location !== 'hand') { + dragOffset = targets + .at(-1) + .worldToLocal(intersection.point.clone()) + .multiplyScalar(-1) + .add(new THREE.Vector3(0, CARD_STACK_OFFSET * (targets.length - i), i * CARD_THICKNESS)) + .toArray(); + } + + setCardData(target, 'dragOffset', dragOffset); + }); - dragTargets = [target]; + dragTargets = targets; } -function onDocumentDrop(event) { +async function onDocumentDrop(event) { event.preventDefault(); + selection.completeRectangleSelection(event); if (!dragTargets?.length) return; raycaster.setFromCamera(mouse, camera); - let intersects = raycaster.intersectObject(scene); + let intersections = raycaster.intersectObject(scene); + + let targetsById = Object.fromEntries(dragTargets.map(target => [target.userData.id, target])); + let intersection = intersections.find( + i => + !targetsById[i.object.userData.id] && + (i.object.userData.isInteractive || i.object.userData.zone) + )!; + + let shouldClearSelection = false; - dragTargets?.forEach(async target => { + for await (const target of dragTargets ?? []) { setCardData(target, 'isDragging', false); - let intersection = intersects.find( - i => - i.object.userData.id !== target.userData.id && - (i.object.userData.isInteractive || i.object.userData.zone) - )!; + let toZoneId = intersection.object.userData.zoneId; let fromZoneId = target.userData.zoneId; let fromZone = zonesById.get(fromZoneId); @@ -298,7 +331,7 @@ function onDocumentDrop(event) { }); setCardData(target, `zone.${toZone.id}.position`, target.position.toArray()); setCardData(target, `zone.${toZone.id}.rotation`, target.rotation.toArray()); - return; + continue; } let card = cardsById.get(target.userData.id); @@ -311,9 +344,16 @@ function onDocumentDrop(event) { positionArray: position.toArray(), }, }); + shouldClearSelection = true; + } + if (shouldClearSelection) { + selection.clearSelection(); + } + + if (dragTargets.length) { setHoverSignal(signal => { - let mesh = signal?.mesh ?? target; + let mesh = signal?.mesh ?? dragTargets[0]; focusOn(mesh); const tether = getCardMeshTetherPoint(mesh); return { @@ -323,7 +363,7 @@ function onDocumentDrop(event) { mesh, }; }); - }); + } dragTargets = []; } @@ -350,16 +390,23 @@ function onDocumentMouseMove(event) { -(event.clientY / window.innerHeight) * 2 + 1 ); + selection.onMove(event); + if (dragTargets?.length) { isDragging = true; raycaster.setFromCamera(mouse, camera); - let intersects = raycaster.intersectObject(scene); + let intersections = raycaster.intersectObject(scene); - if (!intersects.length) return; + if (!intersections.length) return; + let targetsById = Object.fromEntries(dragTargets.map(target => [target.userData.id, target])); + let intersection = intersections.find( + i => + !targetsById[i.object.userData.id] && + (i.object.userData.isInteractive || i.object.userData.zone) + )!; for (const target of dragTargets) { - let intersection = intersects.find(intersect => intersect.object.uuid !== target.uuid); if (!intersection) continue; let pointTarget = intersection.point.clone(); let zone = zonesById.get(target.userData.zoneId)!; @@ -418,14 +465,14 @@ export function animate() { time += delta; if (ticks >= interval) { - render3d(); + render3d(delta); ticks = ticks % interval; } } export function startAnimating() { if (animating()) return; - initClock() + initClock(); setAnimating(true); animate(); } @@ -479,28 +526,35 @@ function focusOn(target: THREE.Object3D) { focusCamera.userData.target = target.uuid; } -function render3d() { +function render3d(delta: number) { renderAnimations(time); + updateTextureAnimation(delta); raycaster.setFromCamera(mouse, camera); - let intersects = raycaster.intersectObject(scene).filter(hit => { - if (isSpectating()) return true; - if ( - hit.object?.userData.clientId !== provider.awareness.clientID && - !hit.object?.userData.isPublic - ) - return false; - return true; - }); + if (!selection.enabled) { + let intersects = raycaster.intersectObject(scene).filter(hit => { + if (isSpectating()) return true; + if ( + hit.object?.userData.clientId !== provider.awareness.clientID && + !hit.object?.userData.isPublic + ) + return false; + return true; + }); - hightlightHover(intersects); + hightlightHover(intersects); + } - if (hoverSignal()?.mesh && hoverSignal()?.mesh.userData.location !== 'deck') { - setHoverSignal(signal => ({ - ...signal, - tether: getCardMeshTetherPoint(signal.mesh), - })); + let signal = hoverSignal(); + if (signal?.mesh && signal?.mesh.userData.location !== 'deck') { + const tetherPoint = getCardMeshTetherPoint(signal.mesh); + if (!tetherPoint.equals(signal.tether)) { + setHoverSignal(signal => ({ + ...signal, + tether: getCardMeshTetherPoint(signal.mesh), + })); + } } camera.lookAt(scene.position); diff --git a/src/routes/game/[id].tsx b/src/routes/game/[id].tsx index bcc0c36..ba15ae3 100644 --- a/src/routes/game/[id].tsx +++ b/src/routes/game/[id].tsx @@ -11,6 +11,7 @@ import { selectedDeckIndex, setSelectedDeckIndex, } from '~/lib/globals'; +import { HotKeys } from '~/lib/shortcuts/hotkeys'; import DeckPicker from '~/lib/ui/deckPicker'; import Overlay from '~/lib/ui/overlay'; import { loadDeckAndJoin, localInit } from '~/main3d'; @@ -34,6 +35,7 @@ const GamePage: Component = props => { <> +