diff --git a/models/poker_hole_cards.glb b/models/poker_hole_cards.glb new file mode 100644 index 0000000..a5230b8 Binary files /dev/null and b/models/poker_hole_cards.glb differ diff --git a/scripts/poker-admin-tablet.js b/scripts/poker-admin-tablet.js index c63a596..b0864a7 100644 --- a/scripts/poker-admin-tablet.js +++ b/scripts/poker-admin-tablet.js @@ -60,7 +60,7 @@ // Place table in front of avatar at floor level // Use only the yaw component of avatar rotation so table sits flat var yaw = Quat.safeEulerAngles(avatarRotation).y; - var tableRot = Quat.fromPitchYawRollDegrees(0, yaw - 90, 0); + var tableRot = Quat.fromPitchYawRollDegrees(0, yaw, 0); // 2.5m in front of avatar, snapped to floor var forward = Vec3.multiplyQbyV(tableRot, { x: 0, y: 0, z: -2.5 }); @@ -87,6 +87,7 @@ locked: false, }); + // Timeout to set locked true after displaying mesh in world Script.setTimeout(function() { Entities.editEntity(tableID, { locked: true }); }, 2000); @@ -119,8 +120,11 @@ pokerID: pokerID, seatIndex: i, }), - triggerable: true, - grabbable: false, + grab: { + triggerable: true, + grabbable: false + }, + //grabbable: false, ignorePickIntersection: false, locked: true, }); diff --git a/scripts/poker_cards.js b/scripts/poker_cards.js new file mode 100644 index 0000000..e8081fb --- /dev/null +++ b/scripts/poker_cards.js @@ -0,0 +1,237 @@ +(function() { + // poker_cards.js — attach to the card hand mesh entity + // userData: { "pokerID": "main", "tableID": "{uuid}" } + + var constants = Script.require(Script.resolvePath("poker_constants.js")); + var API_BASE = constants.API_BASE; + + var _entityID = null; + var _pokerID = null; + var _username = null; + var _token = null; + var _ws = null; + var _rendererID = null; // local web entity that generates the texture + var _materialID = null; // local material entity applied to this mesh + var _wsRetryTimer = null; + var _pendingCards = null; + + // ── helpers ────────────────────────────────────────────────── + + function getUserData(id) { + try { + return JSON.parse(Entities.getEntityProperties(id, ["userData"]).userData || "{}"); + } catch(e) { return {}; } + } + + // ── session ────────────────────────────────────────────────── + + function ensureSession(cb) { + if (_token) { cb(null, _token); return; } + _username = AccountServices.username; + if (!_username) { cb("not logged in"); return; } + + var xhr = new XMLHttpRequest(); + xhr.open("POST", API_BASE + "/session", true); + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.onreadystatechange = function() { + if (xhr.readyState !== 4) return; + if (xhr.status === 200) { + try { + _token = JSON.parse(xhr.responseText).token; + cb(null, _token); + } catch(e) { cb("session parse error"); } + } else { + cb("session error " + xhr.status); + } + }; + xhr.send(JSON.stringify({ username: _username })); + } + + // ── WebSocket ──────────────────────────────────────────────── + + function connectWS() { + var wsURL = API_BASE.replace("https://", "wss://").replace("http://", "ws://") + .replace("/api", "/ws"); + _ws = new WebSocket(wsURL); + + _ws.onopen = function() { + print("[pokerCards] WS connected"); + // Identify so server can route private messages + _ws.send(JSON.stringify({ type: "identify", username: _username })); + }; + + _ws.onmessage = function(event) { + try { + var msg = JSON.parse(event.data); + if (msg.type === "hand:dealt") { + onCardsDealt(msg.cards); + } else if (msg.type === "hand:end") { + hideCards(); + } + } catch(e) { + print("[pokerCards] WS parse error: " + e); + } + }; + + _ws.onclose = function() { + print("[pokerCards] WS closed, retrying in 5s"); + _wsRetryTimer = Script.setTimeout(connectWS, 5000); + }; + + _ws.onerror = function(e) { + print("[pokerCards] WS error: " + e); + }; + } + + // ── card rendering ─────────────────────────────────────────── + + function onCardsDealt(cards) { + // cards is ["As", "Kh"] format from server + if (!cards || cards.length < 2) return; + + var card1 = parseCard(cards[0]); + var card2 = parseCard(cards[1]); + + print("[pokerCards] dealt " + cards[0] + " " + cards[1]); + + // Spawn hidden web entity to render card texture + _rendererID = Entities.addEntity({ + type: "Web", + name: "card-renderer", + sourceUrl: "https://wizards.cyou/tablet/card-face.html", + position: MyAvatar.position, // doesn't matter, it's invisible + dimensions: { x: 0.01, y: 0.01, z: 0.01 }, + visible: false, + }, "local"); + + // Wait for web entity to load then send card data + Script.setTimeout(function() { + Entities.emitScriptEvent(_rendererID, JSON.stringify({ + type: "dealCards", + card1: card1, + card2: card2, + })); + }, 1500); + } + + function onTextureReady(dataURI) { + // Clean up renderer + if (_rendererID) { + Entities.deleteEntity(_rendererID); + _rendererID = null; + } + + // Remove old material if any + if (_materialID) { + Entities.deleteEntity(_materialID); + _materialID = null; + } + + // Apply local material entity to card mesh + _materialID = Entities.addEntity({ + type: "Material", + name: "card-face-material", + parentID: _entityID, + materialURL: "materialData", + materialData: JSON.stringify({ + materials: { + model: "hifi_pbr", + albedoMap: dataURI, + roughness: 0.8, + metallic: 0.0, + } + }), + priority: 1, + }, "local"); + + print("[pokerCards] card texture applied"); + } + + function hideCards() { + if (_materialID) { + Entities.deleteEntity(_materialID); + _materialID = null; + } + print("[pokerCards] cards hidden"); + } + + function parseCard(str) { + // "As" → { rank: "A", suit: "♠" } + // "10h" → { rank: "10", suit: "♥" } + var suitMap = { s: '♠', h: '♥', d: '♦', c: '♣' }; + var suit = suitMap[str.slice(-1)] || '♠'; + var rank = str.slice(0, -1); + return { rank: rank, suit: suit }; + } + + function onCardsDealt(cards) { + if (!cards || cards.length < 2) return; + var card1 = parseCard(cards[0]); + var card2 = parseCard(cards[1]); + print("[pokerCards] dealt " + cards[0] + " " + cards[1]); + + _pendingCards = { card1: card1, card2: card2 }; + + _rendererID = Entities.addEntity({ + type: "Web", + name: "card-renderer", + sourceUrl: "https://wizards.cyou/tablet/card-face.html", + position: MyAvatar.position, + dimensions: { x: 0.01, y: 0.01, z: 0.01 }, + visible: false, + }, "local"); + } + + function onWebEventReceived(id, data) { + if (id !== _rendererID) return; + try { + var msg = JSON.parse(data); + if (msg.type === 'rendererReady') { + // Web entity loaded — now send card data + Entities.emitScriptEvent(_rendererID, JSON.stringify({ + type: 'dealCards', + card1: _pendingCards.card1, + card2: _pendingCards.card2, + })); + } else if (msg.type === 'cardTexture') { + onTextureReady(msg.dataURI); + } + } catch(e) {} + } + + // ── web entity event listener ──────────────────────────────── + + function onWebEventReceived(id, data) { + if (id !== _rendererID) return; + try { + var msg = JSON.parse(data); + if (msg.type === "cardTexture") { + onTextureReady(msg.dataURI); + } + } catch(e) {} + } + + // ── lifecycle ──────────────────────────────────────────────── + + this.preload = function(entityID) { + _entityID = entityID; + var ud = getUserData(entityID); + _pokerID = ud.pokerID || "main"; + + Entities.webEventReceived.connect(onWebEventReceived); + + ensureSession(function(err) { + if (err) { print("[pokerCards] session error: " + err); return; } + connectWS(); + }); + }; + + this.unload = function() { + if (_wsRetryTimer) Script.clearTimeout(_wsRetryTimer); + if (_ws) _ws.close(); + if (_materialID) Entities.deleteEntity(_materialID); + if (_rendererID) Entities.deleteEntity(_rendererID); + Entities.webEventReceived.disconnect(onWebEventReceived); + _token = null; + }; +}); diff --git a/tablet/card-face.html b/tablet/card-face.html new file mode 100644 index 0000000..6b09ecc --- /dev/null +++ b/tablet/card-face.html @@ -0,0 +1,122 @@ + + +
+ + + + + + + +