Last active
December 22, 2025 22:44
-
-
Save lardratboy/c6ece962c07c333cf4a0d6dced9352ad to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>π° LATTICE SLOTS</title> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Exo+2:wght@300;600&display=swap'); | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| /* Custom scrollbar styling */ | |
| #left-panel::-webkit-scrollbar, | |
| #right-panel::-webkit-scrollbar { | |
| width: 8px; | |
| } | |
| #left-panel::-webkit-scrollbar-track, | |
| #right-panel::-webkit-scrollbar-track { | |
| background: rgba(0, 0, 0, 0.3); | |
| border-radius: 4px; | |
| } | |
| #left-panel::-webkit-scrollbar-thumb { | |
| background: rgba(255, 215, 0, 0.4); | |
| border-radius: 4px; | |
| } | |
| #left-panel::-webkit-scrollbar-thumb:hover { | |
| background: rgba(255, 215, 0, 0.6); | |
| } | |
| #right-panel::-webkit-scrollbar-thumb { | |
| background: rgba(0, 212, 255, 0.4); | |
| border-radius: 4px; | |
| } | |
| #right-panel::-webkit-scrollbar-thumb:hover { | |
| background: rgba(0, 212, 255, 0.6); | |
| } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 50%, #0a0a0f 100%); | |
| font-family: 'Exo 2', sans-serif; | |
| touch-action: none; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| -webkit-touch-callout: none; | |
| color: #fff; | |
| } | |
| /* Left Panel */ | |
| #left-panel { | |
| position: absolute; | |
| left: 15px; | |
| top: 15px; | |
| width: 220px; | |
| background: rgba(10, 10, 20, 0.95); | |
| border: 2px solid #ffd700; | |
| border-radius: 16px; | |
| padding: 15px; | |
| z-index: 100; | |
| box-shadow: 0 0 30px rgba(255, 215, 0, 0.3), inset 0 0 20px rgba(0,0,0,0.5); | |
| max-height: calc(100vh - 30px); | |
| overflow-y: auto; | |
| } | |
| #game-title { | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 1.4rem; | |
| font-weight: 900; | |
| text-align: center; | |
| margin-bottom: 12px; | |
| background: linear-gradient(180deg, #ffd700 0%, #ff8c00 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.5)); | |
| } | |
| #credits-display { | |
| background: linear-gradient(135deg, #1a1a2e 0%, #0d0d1a 100%); | |
| border: 2px solid #444; | |
| border-radius: 10px; | |
| padding: 10px; | |
| text-align: center; | |
| margin-bottom: 12px; | |
| } | |
| #credits-label { | |
| font-size: 0.75rem; | |
| color: #888; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| } | |
| #credits-value { | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 2rem; | |
| font-weight: 900; | |
| color: #00ff88; | |
| text-shadow: 0 0 20px rgba(0, 255, 136, 0.5); | |
| } | |
| .control-row { | |
| margin-bottom: 10px; | |
| } | |
| .control-label { | |
| font-size: 0.75rem; | |
| color: #aaa; | |
| margin-bottom: 4px; | |
| } | |
| #bet-select { | |
| width: 100%; | |
| padding: 10px; | |
| background: rgba(30, 30, 50, 0.9); | |
| border: 1px solid #555; | |
| border-radius: 8px; | |
| color: #fff; | |
| font-family: 'Exo 2', sans-serif; | |
| font-size: 0.9rem; | |
| cursor: pointer; | |
| } | |
| #bet-select:hover { | |
| border-color: #ffd700; | |
| } | |
| #spin-all-btn { | |
| width: 100%; | |
| padding: 14px; | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 1.1rem; | |
| font-weight: 700; | |
| background: linear-gradient(180deg, #ffd700 0%, #ff8c00 100%); | |
| border: none; | |
| border-radius: 10px; | |
| color: #000; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| margin-bottom: 10px; | |
| text-shadow: none; | |
| box-shadow: 0 4px 15px rgba(255, 215, 0, 0.4); | |
| } | |
| #spin-all-btn:hover:not(:disabled) { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 25px rgba(255, 215, 0, 0.6); | |
| } | |
| #spin-all-btn:disabled { | |
| background: linear-gradient(180deg, #666 0%, #444 100%); | |
| cursor: not-allowed; | |
| box-shadow: none; | |
| } | |
| .toggle-btn { | |
| width: 100%; | |
| padding: 10px; | |
| background: rgba(40, 40, 60, 0.9); | |
| border: 1px solid #555; | |
| border-radius: 8px; | |
| color: #fff; | |
| font-family: 'Exo 2', sans-serif; | |
| font-size: 0.85rem; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| margin-bottom: 6px; | |
| } | |
| .toggle-btn:hover { | |
| background: rgba(60, 60, 90, 0.9); | |
| border-color: #888; | |
| } | |
| .toggle-btn.active { | |
| border-color: #00ff88; | |
| color: #00ff88; | |
| } | |
| /* Column Spin Section */ | |
| #column-spin-section { | |
| margin-top: 12px; | |
| padding-top: 12px; | |
| border-top: 1px solid #333; | |
| } | |
| #column-spin-section h3 { | |
| font-size: 0.8rem; | |
| color: #888; | |
| margin-bottom: 8px; | |
| } | |
| .column-group { | |
| margin-bottom: 8px; | |
| } | |
| .column-group-label { | |
| font-size: 0.7rem; | |
| color: #666; | |
| margin-bottom: 4px; | |
| } | |
| .column-buttons { | |
| display: flex; | |
| gap: 4px; | |
| } | |
| .col-btn { | |
| flex: 1; | |
| padding: 8px 0; | |
| background: rgba(40, 40, 60, 0.9); | |
| border: 1px solid #444; | |
| border-radius: 5px; | |
| color: #aaa; | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 0.8rem; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .col-btn:hover:not(:disabled) { | |
| background: rgba(60, 60, 100, 0.9); | |
| border-color: #ffd700; | |
| color: #ffd700; | |
| } | |
| .col-btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| .col-btn.spinning { | |
| background: rgba(255, 100, 0, 0.3); | |
| border-color: #ff6600; | |
| color: #ff6600; | |
| } | |
| /* Info Box */ | |
| #info-box { | |
| background: rgba(0, 100, 50, 0.2); | |
| border: 1px solid #00aa55; | |
| border-radius: 8px; | |
| padding: 10px; | |
| margin-top: 10px; | |
| text-align: center; | |
| } | |
| #info-box.win { | |
| background: rgba(0, 200, 100, 0.3); | |
| border-color: #00ff88; | |
| } | |
| #match-rule { | |
| font-size: 0.85rem; | |
| color: #00ff88; | |
| font-weight: 600; | |
| } | |
| #status-text { | |
| font-size: 0.75rem; | |
| color: #888; | |
| margin-top: 4px; | |
| } | |
| /* Controls Info */ | |
| #controls-info { | |
| margin-top: 12px; | |
| padding-top: 12px; | |
| border-top: 1px solid #333; | |
| } | |
| #controls-info h4 { | |
| font-size: 0.8rem; | |
| color: #00d4ff; | |
| margin-bottom: 6px; | |
| } | |
| .control-hint { | |
| font-size: 0.7rem; | |
| color: #666; | |
| margin-bottom: 2px; | |
| } | |
| .control-hint code { | |
| background: rgba(255,255,255,0.1); | |
| padding: 1px 4px; | |
| border-radius: 3px; | |
| color: #aaa; | |
| font-size: 0.65rem; | |
| } | |
| /* Right Panel - Paytable */ | |
| #right-panel { | |
| position: absolute; | |
| right: 15px; | |
| top: 15px; | |
| width: 220px; | |
| background: rgba(10, 10, 20, 0.95); | |
| border: 2px solid #00d4ff; | |
| border-radius: 16px; | |
| padding: 15px; | |
| z-index: 100; | |
| box-shadow: 0 0 30px rgba(0, 212, 255, 0.2); | |
| max-height: calc(100vh - 30px); | |
| overflow-y: auto; | |
| } | |
| #paytable-title { | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 1.1rem; | |
| text-align: center; | |
| color: #00d4ff; | |
| margin-bottom: 12px; | |
| text-shadow: 0 0 10px rgba(0, 212, 255, 0.5); | |
| } | |
| .rarity-section { | |
| margin-bottom: 12px; | |
| } | |
| .rarity-label { | |
| font-size: 0.7rem; | |
| color: #888; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| margin-bottom: 5px; | |
| padding-left: 5px; | |
| } | |
| .rarity-label.common { color: #888; } | |
| .rarity-label.uncommon { color: #00d4ff; } | |
| .rarity-label.rare { color: #ffd700; } | |
| .paytable-row { | |
| display: flex; | |
| align-items: center; | |
| padding: 6px 8px; | |
| background: rgba(30, 30, 50, 0.5); | |
| border-radius: 6px; | |
| margin-bottom: 4px; | |
| } | |
| .paytable-symbol { | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-family: 'Orbitron', sans-serif; | |
| font-weight: bold; | |
| font-size: 0.9rem; | |
| color: #fff; | |
| text-shadow: 1px 1px 2px rgba(0,0,0,0.5); | |
| margin-right: 10px; | |
| } | |
| .paytable-letter { | |
| flex: 1; | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 0.95rem; | |
| } | |
| .paytable-payout { | |
| font-size: 0.75rem; | |
| color: #00ff88; | |
| text-align: right; | |
| } | |
| /* Debug View */ | |
| #debug-panel { | |
| margin-top: 15px; | |
| padding-top: 15px; | |
| border-top: 1px solid #333; | |
| display: none; | |
| } | |
| #debug-panel.visible { | |
| display: block; | |
| } | |
| #debug-title { | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 0.9rem; | |
| color: #ff6600; | |
| margin-bottom: 10px; | |
| text-align: center; | |
| } | |
| .debug-face { | |
| margin-bottom: 12px; | |
| } | |
| .debug-face-title { | |
| font-size: 0.7rem; | |
| color: #ff8800; | |
| text-align: center; | |
| margin-bottom: 3px; | |
| } | |
| .debug-face-subtitle { | |
| font-size: 0.6rem; | |
| color: #666; | |
| text-align: center; | |
| margin-bottom: 5px; | |
| } | |
| .debug-grid { | |
| display: grid; | |
| grid-template-columns: repeat(5, 1fr); | |
| gap: 2px; | |
| } | |
| .debug-cell { | |
| aspect-ratio: 1; | |
| border-radius: 3px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 0.65rem; | |
| font-weight: bold; | |
| color: #fff; | |
| text-shadow: 1px 1px 1px rgba(0,0,0,0.5); | |
| position: relative; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| /* Win Overlay */ | |
| #win-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.7); | |
| display: none; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 200; | |
| pointer-events: none; | |
| } | |
| #win-overlay.visible { | |
| display: flex; | |
| animation: fadeIn 0.3s ease; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| #win-message { | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 3rem; | |
| font-weight: 900; | |
| text-align: center; | |
| animation: winPulse 0.5s ease infinite alternate; | |
| } | |
| @keyframes winPulse { | |
| from { transform: scale(1); filter: drop-shadow(0 0 20px #ffd700); } | |
| to { transform: scale(1.1); filter: drop-shadow(0 0 40px #ffd700); } | |
| } | |
| @keyframes jackpotPulse { | |
| 0% { transform: scale(1); filter: drop-shadow(0 0 30px #ff00ff) hue-rotate(0deg); } | |
| 50% { transform: scale(1.15); filter: drop-shadow(0 0 50px #00ffff) hue-rotate(180deg); } | |
| 100% { transform: scale(1); filter: drop-shadow(0 0 30px #ff00ff) hue-rotate(360deg); } | |
| } | |
| #win-message.jackpot { | |
| animation: jackpotPulse 0.5s ease infinite; | |
| } | |
| .win-credits { | |
| color: #00ff88; | |
| } | |
| .win-text { | |
| background: linear-gradient(180deg, #ffd700 0%, #ff8c00 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| /* Floating Text */ | |
| #float-container { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: 150; | |
| } | |
| .float-text { | |
| position: absolute; | |
| font-family: 'Orbitron', sans-serif; | |
| font-weight: 900; | |
| font-size: 2rem; | |
| white-space: nowrap; | |
| animation: floatUp 2s ease-out forwards; | |
| opacity: 0; | |
| } | |
| @keyframes floatUp { | |
| 0% { opacity: 0; transform: translateY(0) scale(0.5); } | |
| 15% { opacity: 1; transform: translateY(-20px) scale(1.1); } | |
| 30% { transform: translateY(-40px) scale(1); } | |
| 100% { opacity: 0; transform: translateY(-150px) scale(0.8); } | |
| } | |
| .float-text.match { color: #00ff88; text-shadow: 0 0 20px #00ff88; } | |
| .float-text.big-win { color: #ffd700; text-shadow: 0 0 30px #ffd700; } | |
| .float-text.jackpot { | |
| color: #ff00ff; | |
| text-shadow: 0 0 30px #ff00ff, 0 0 60px #00ffff; | |
| font-size: 3rem; | |
| } | |
| </style> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| </head> | |
| <body> | |
| <!-- Left Panel --> | |
| <div id="left-panel"> | |
| <div id="game-title">π° LATTICE SLOTS</div> | |
| <div id="credits-display"> | |
| <div id="credits-label">Credits</div> | |
| <div id="credits-value">1000</div> | |
| </div> | |
| <div class="control-row"> | |
| <div class="control-label">Bet per Spin:</div> | |
| <select id="bet-select"> | |
| <option value="5">5 Credits</option> | |
| <option value="10">10 Credits</option> | |
| <option value="25" selected>25 Credits</option> | |
| <option value="50">50 Credits</option> | |
| <option value="100">100 Credits</option> | |
| </select> | |
| </div> | |
| <button id="spin-all-btn">π² SPIN ALL</button> | |
| <button id="sound-toggle" class="toggle-btn active">π Sound: ON</button> | |
| <button id="debug-toggle" class="toggle-btn">π§ Debug: OFF</button> | |
| <div id="column-spin-section"> | |
| <h3>Spin Columns (0-9):</h3> | |
| <div class="column-group"> | |
| <div class="column-group-label">Left + Floor</div> | |
| <div class="column-buttons"> | |
| <button class="col-btn" data-col="0">0</button> | |
| <button class="col-btn" data-col="1">1</button> | |
| <button class="col-btn" data-col="2">2</button> | |
| <button class="col-btn" data-col="3">3</button> | |
| <button class="col-btn" data-col="4">4</button> | |
| </div> | |
| </div> | |
| <div class="column-group"> | |
| <div class="column-group-label">Right + Floor</div> | |
| <div class="column-buttons"> | |
| <button class="col-btn" data-col="5">5</button> | |
| <button class="col-btn" data-col="6">6</button> | |
| <button class="col-btn" data-col="7">7</button> | |
| <button class="col-btn" data-col="8">8</button> | |
| <button class="col-btn" data-col="9">9</button> | |
| </div> | |
| </div> | |
| <div style="font-size: 0.65rem; color: #666; margin-top: 6px;"> | |
| <div>0-4: 5 cubes | 5-9: 10 cubes</div> | |
| <div style="color: #ff6600;">House edge enabled</div> | |
| </div> | |
| </div> | |
| <div id="info-box"> | |
| <div id="match-rule">Match 4+ to win!</div> | |
| <div id="status-text">Ready to spin!</div> | |
| </div> | |
| <div id="controls-info"> | |
| <h4>β¨οΈ Controls</h4> | |
| <div class="control-hint"><code>Space</code> Spin All</div> | |
| <div class="control-hint"><code>0-9</code> Spin Column</div> | |
| <div class="control-hint"><code>M</code> Sound</div> | |
| <div class="control-hint"><code>Drag</code> Rotate</div> | |
| <div class="control-hint"><code>Scroll</code> Zoom</div> | |
| </div> | |
| </div> | |
| <!-- Right Panel - Paytable & Debug --> | |
| <div id="right-panel"> | |
| <div id="paytable-title">π° PAYTABLE</div> | |
| <div class="rarity-section"> | |
| <div class="rarity-label common">β COMMON</div> | |
| <div class="paytable-row"> | |
| <div class="paytable-symbol" style="background: #ff4444;">A</div> | |
| <div class="paytable-letter">A</div> | |
| <div class="paytable-payout">β +1/ea</div> | |
| </div> | |
| <div class="paytable-row"> | |
| <div class="paytable-symbol" style="background: #44cc44;">B</div> | |
| <div class="paytable-letter">B</div> | |
| <div class="paytable-payout">β +1/ea</div> | |
| </div> | |
| <div class="paytable-row"> | |
| <div class="paytable-symbol" style="background: #00aaff;">C</div> | |
| <div class="paytable-letter">C</div> | |
| <div class="paytable-payout">β +2/ea</div> | |
| </div> | |
| </div> | |
| <div class="rarity-section"> | |
| <div class="rarity-label uncommon">β UNCOMMON</div> | |
| <div class="paytable-row"> | |
| <div class="paytable-symbol" style="background: #ffdd00;">D</div> | |
| <div class="paytable-letter">D</div> | |
| <div class="paytable-payout">1Γbet +3/ea</div> | |
| </div> | |
| <div class="paytable-row"> | |
| <div class="paytable-symbol" style="background: #dd44dd;">E</div> | |
| <div class="paytable-letter">E</div> | |
| <div class="paytable-payout">1Γbet +4/ea</div> | |
| </div> | |
| <div class="paytable-row"> | |
| <div class="paytable-symbol" style="background: #44dddd;">F</div> | |
| <div class="paytable-letter">F</div> | |
| <div class="paytable-payout">1Γbet +5/ea</div> | |
| </div> | |
| </div> | |
| <div class="rarity-section"> | |
| <div class="rarity-label rare">β RARE</div> | |
| <div class="paytable-row"> | |
| <div class="paytable-symbol" style="background: #ff8800;">G</div> | |
| <div class="paytable-letter">G</div> | |
| <div class="paytable-payout">2Γbet +8/ea</div> | |
| </div> | |
| <div class="paytable-row"> | |
| <div class="paytable-symbol" style="background: #888888;">H</div> | |
| <div class="paytable-letter">H</div> | |
| <div class="paytable-payout">3Γbet +10/ea</div> | |
| </div> | |
| </div> | |
| <div id="debug-panel"> | |
| <div id="debug-title">π§ DEBUG VIEW</div> | |
| <div class="debug-face"> | |
| <div class="debug-face-title">XY Face (Left Wall)</div> | |
| <div class="debug-face-subtitle">Columns 0-4</div> | |
| <div class="debug-grid" id="debug-left"></div> | |
| </div> | |
| <div class="debug-face"> | |
| <div class="debug-face-title">YZ Face (Right Wall)</div> | |
| <div class="debug-face-subtitle">Columns 5-9</div> | |
| <div class="debug-grid" id="debug-right"></div> | |
| </div> | |
| <div class="debug-face"> | |
| <div class="debug-face-title">XZ Face (Floor)</div> | |
| <div class="debug-face-subtitle">Shared by both</div> | |
| <div class="debug-grid" id="debug-floor"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Win Overlay --> | |
| <div id="win-overlay"> | |
| <div id="win-message"></div> | |
| </div> | |
| <!-- Float Container --> | |
| <div id="float-container"></div> | |
| <script> | |
| // ============================================ | |
| // SOUND MANAGER | |
| // ============================================ | |
| class SlotSoundManager { | |
| constructor() { | |
| this.audioContext = null; | |
| this.masterGain = null; | |
| this.enabled = true; | |
| this.initAudio(); | |
| } | |
| initAudio() { | |
| try { | |
| this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| this.masterGain = this.audioContext.createGain(); | |
| this.masterGain.gain.value = 0.3; | |
| this.masterGain.connect(this.audioContext.destination); | |
| } catch (e) { | |
| console.warn('Web Audio API not supported'); | |
| this.enabled = false; | |
| } | |
| } | |
| resume() { | |
| if (this.audioContext?.state === 'suspended') this.audioContext.resume(); | |
| } | |
| setEnabled(v) { this.enabled = v; } | |
| isEnabled() { return this.enabled; } | |
| playTone(freq, dur, wave = 'square', vol = 0.1) { | |
| if (!this.enabled || !this.audioContext || this.audioContext.state === 'suspended') return; | |
| const osc = this.audioContext.createOscillator(); | |
| const gain = this.audioContext.createGain(); | |
| const now = this.audioContext.currentTime; | |
| osc.type = wave; | |
| osc.frequency.setValueAtTime(freq, now); | |
| gain.gain.setValueAtTime(0, now); | |
| gain.gain.linearRampToValueAtTime(vol, now + 0.01); | |
| gain.gain.exponentialRampToValueAtTime(0.001, now + dur); | |
| osc.connect(gain); | |
| gain.connect(this.masterGain); | |
| osc.start(); | |
| osc.stop(now + dur); | |
| } | |
| playSweep(startFreq, endFreq, dur, wave = 'sawtooth', vol = 0.1) { | |
| if (!this.enabled || !this.audioContext || this.audioContext.state === 'suspended') return; | |
| const osc = this.audioContext.createOscillator(); | |
| const gain = this.audioContext.createGain(); | |
| const now = this.audioContext.currentTime; | |
| osc.type = wave; | |
| osc.frequency.setValueAtTime(startFreq, now); | |
| osc.frequency.exponentialRampToValueAtTime(endFreq, now + dur); | |
| gain.gain.setValueAtTime(vol, now); | |
| gain.gain.exponentialRampToValueAtTime(0.001, now + dur); | |
| osc.connect(gain); | |
| gain.connect(this.masterGain); | |
| osc.start(); | |
| osc.stop(now + dur); | |
| } | |
| playChord(freqs, dur, wave = 'square', vol = 0.06) { | |
| freqs.forEach((f, i) => setTimeout(() => this.playTone(f, dur, wave, vol), i * 20)); | |
| } | |
| click() { | |
| this.playTone(400, 0.08, 'sine', 0.1); | |
| } | |
| spinStart() { | |
| this.playTone(200, 0.15, 'sawtooth', 0.08); | |
| setTimeout(() => this.playTone(300, 0.1, 'sawtooth', 0.06), 50); | |
| } | |
| spinTick(pitch = 0) { | |
| this.playTone(150 + pitch * 20, 0.05, 'square', 0.03); | |
| } | |
| spinStop(col) { | |
| const freq = 300 + col * 30; | |
| this.playTone(freq, 0.15, 'triangle', 0.1); | |
| } | |
| // Small win (4 matches - our minimum) | |
| winSmall() { | |
| const notes = [523, 659, 784]; // C E G | |
| notes.forEach((freq, i) => { | |
| setTimeout(() => this.playTone(freq, 0.2, 'square', 0.1), i * 100); | |
| }); | |
| } | |
| // Medium win (5-6 matches) | |
| winMedium() { | |
| const notes = [523, 659, 784, 1047]; // C E G C | |
| notes.forEach((freq, i) => { | |
| setTimeout(() => this.playTone(freq, 0.25, 'triangle', 0.12), i * 120); | |
| }); | |
| // Add sparkle | |
| setTimeout(() => this.playSweep(2000, 4000, 0.3, 'sine', 0.05), 400); | |
| } | |
| // Big win (7+ matches) | |
| winBig() { | |
| // Fanfare! | |
| const fanfare = [523, 523, 523, 659, 784, 659, 784, 1047]; | |
| const durations = [0.1, 0.1, 0.2, 0.2, 0.15, 0.15, 0.2, 0.5]; | |
| let time = 0; | |
| fanfare.forEach((freq, i) => { | |
| setTimeout(() => this.playTone(freq, durations[i] + 0.1, 'square', 0.1), time); | |
| time += durations[i] * 1000; | |
| }); | |
| // Celebration sweep | |
| setTimeout(() => this.playSweep(500, 2000, 0.5, 'sawtooth', 0.08), time); | |
| } | |
| // Jackpot (huge win - 10+ matches or high value) | |
| jackpot() { | |
| // Epic ascending arpeggio | |
| const notes = [262, 330, 392, 523, 659, 784, 1047, 1319, 1568]; | |
| notes.forEach((freq, i) => { | |
| setTimeout(() => { | |
| this.playTone(freq, 0.4, 'square', 0.08); | |
| this.playTone(freq * 1.5, 0.4, 'triangle', 0.05); | |
| }, i * 100); | |
| }); | |
| // Big finish | |
| setTimeout(() => { | |
| this.playChord([1047, 1319, 1568, 2093], 1.0, 'square', 0.06); | |
| this.playSweep(500, 3000, 0.8, 'sawtooth', 0.1); | |
| }, 900); | |
| } | |
| noWin() { | |
| this.playTone(200, 0.2, 'sawtooth', 0.06); | |
| setTimeout(() => this.playTone(150, 0.3, 'sawtooth', 0.05), 100); | |
| } | |
| } | |
| const soundManager = new SlotSoundManager(); | |
| // ============================================ | |
| // THREE.JS SETUP | |
| // ============================================ | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200); | |
| camera.position.set(14, 12, 14); | |
| camera.lookAt(2.5, 2, 2.5); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setClearColor(0x000000, 0); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| document.body.appendChild(renderer.domElement); | |
| // ============================================ | |
| // LIGHTING SETUP WITH SHADOWS | |
| // ============================================ | |
| // Reduced ambient light to make shadows more visible | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); | |
| scene.add(ambientLight); | |
| // Main directional light (from camera direction - top-right-front) | |
| const mainLight = new THREE.DirectionalLight(0xffffff, 0.7); | |
| mainLight.position.set(15, 20, 15); | |
| mainLight.castShadow = true; | |
| mainLight.shadow.mapSize.width = 2048; | |
| mainLight.shadow.mapSize.height = 2048; | |
| mainLight.shadow.camera.near = 1; | |
| mainLight.shadow.camera.far = 50; | |
| mainLight.shadow.camera.left = -15; | |
| mainLight.shadow.camera.right = 15; | |
| mainLight.shadow.camera.top = 15; | |
| mainLight.shadow.camera.bottom = -15; | |
| mainLight.shadow.bias = -0.001; | |
| mainLight.shadow.radius = 2; | |
| scene.add(mainLight); | |
| // Fill light from opposite side (softer) | |
| const fillLight = new THREE.DirectionalLight(0x4488ff, 0.3); | |
| fillLight.position.set(-10, 5, -10); | |
| scene.add(fillLight); | |
| // Rim light from below for depth | |
| const rimLight = new THREE.DirectionalLight(0xff88ff, 0.15); | |
| rimLight.position.set(0, -10, 0); | |
| scene.add(rimLight); | |
| // ============================================ | |
| // Starfield | |
| function createStarfield() { | |
| const geometry = new THREE.BufferGeometry(); | |
| const vertices = []; | |
| const colors = []; | |
| for (let i = 0; i < 2000; i++) { | |
| const r = 50 + Math.random() * 30; | |
| const theta = Math.random() * Math.PI * 2; | |
| const phi = Math.acos(2 * Math.random() - 1); | |
| vertices.push( | |
| r * Math.sin(phi) * Math.cos(theta), | |
| r * Math.sin(phi) * Math.sin(theta), | |
| r * Math.cos(phi) | |
| ); | |
| const brightness = 0.3 + Math.random() * 0.7; | |
| colors.push(brightness, brightness, brightness * (0.8 + Math.random() * 0.2)); | |
| } | |
| geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); | |
| geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3)); | |
| const material = new THREE.PointsMaterial({ | |
| size: 0.8, | |
| vertexColors: true, | |
| transparent: true, | |
| opacity: 0.8 | |
| }); | |
| return new THREE.Points(geometry, material); | |
| } | |
| const starfield = createStarfield(); | |
| scene.add(starfield); | |
| // ============================================ | |
| // GAME STATE | |
| // ============================================ | |
| const GRID_SIZE = 5; | |
| const SPACING = 1.1; | |
| const OFFSET = 0.5; | |
| let credits = 1000; | |
| let currentBet = 25; | |
| let isSpinning = false; | |
| let spinningColumns = new Set(); | |
| let debugMode = false; | |
| // Attract mode state | |
| let attractMode = false; | |
| let attractInterval = null; | |
| let lastMatches = []; | |
| let currentAttractIndex = 0; | |
| // Symbol configuration | |
| const SYMBOLS = { | |
| A: { color: 0xff4444, letter: 'A', rarity: 'common', baseValue: 1, betMult: 0, weight: 20 }, | |
| B: { color: 0x44cc44, letter: 'B', rarity: 'common', baseValue: 1, betMult: 0, weight: 20 }, | |
| C: { color: 0x0088ff, letter: 'C', rarity: 'common', baseValue: 2, betMult: 0, weight: 18 }, | |
| D: { color: 0xffdd00, letter: 'D', rarity: 'uncommon', baseValue: 3, betMult: 1, weight: 10 }, | |
| E: { color: 0xdd44dd, letter: 'E', rarity: 'uncommon', baseValue: 4, betMult: 1, weight: 10 }, | |
| F: { color: 0x44dddd, letter: 'F', rarity: 'uncommon', baseValue: 5, betMult: 1, weight: 8 }, | |
| G: { color: 0xff8800, letter: 'G', rarity: 'rare', baseValue: 8, betMult: 2, weight: 4 }, | |
| H: { color: 0x888888, letter: 'H', rarity: 'rare', baseValue: 10, betMult: 3, weight: 2 } | |
| }; | |
| const SYMBOL_KEYS = Object.keys(SYMBOLS); | |
| const MIN_MATCH = 4; | |
| // Weighted random symbol selection | |
| function getRandomSymbol() { | |
| const totalWeight = SYMBOL_KEYS.reduce((sum, k) => sum + SYMBOLS[k].weight, 0); | |
| let random = Math.random() * totalWeight; | |
| for (const key of SYMBOL_KEYS) { | |
| random -= SYMBOLS[key].weight; | |
| if (random <= 0) return key; | |
| } | |
| return SYMBOL_KEYS[0]; | |
| } | |
| // ============================================ | |
| // CUBE DATA STRUCTURE | |
| // ============================================ | |
| class SlotCube { | |
| constructor(gridX, gridY, gridZ, plane, symbol) { | |
| this.gridX = gridX; | |
| this.gridY = gridY; | |
| this.gridZ = gridZ; | |
| this.plane = plane; | |
| this.symbol = symbol; | |
| this.position = this.calculatePosition(); | |
| this.targetPosition = this.position.clone(); | |
| this.rotation = 0; | |
| this.targetRotation = 0; | |
| this.scale = 1; // Target scale | |
| this.displayScale = 1; // Current display scale (lerps toward scale) | |
| this.isSpinning = false; | |
| this.spinPhase = 0; | |
| } | |
| calculatePosition() { | |
| const x = this.gridX * SPACING + OFFSET; | |
| const y = this.gridY * SPACING + OFFSET; | |
| const z = this.gridZ * SPACING + OFFSET; | |
| if (this.plane === 'left') return new THREE.Vector3(-0.6, y, z); | |
| if (this.plane === 'right') return new THREE.Vector3(x, y, -0.6); | |
| return new THREE.Vector3(x, -0.6, z); | |
| } | |
| updatePosition() { | |
| this.targetPosition = this.calculatePosition(); | |
| } | |
| } | |
| let cubes = []; | |
| // Columns structure: | |
| // Columns 0-4: Left wall (each column is a vertical strip at different Z positions) | |
| // Columns 5-9: Right wall (each column is a vertical strip at different X positions) | |
| // Floor cubes are included in both sets where they intersect | |
| function getColumnCubes(colIndex) { | |
| if (colIndex < 5) { | |
| // Left wall column (Z = colIndex) + floor strip (X = 0, Z varies) | |
| const wallCubes = cubes.filter(c => c.plane === 'left' && c.gridZ === colIndex); | |
| const floorCubes = cubes.filter(c => c.plane === 'floor' && c.gridZ === colIndex); | |
| return [...wallCubes, ...floorCubes]; | |
| } else { | |
| // Right wall column (X = colIndex - 5) + floor strip (Z = 0, X varies) | |
| const adjustedCol = colIndex - 5; | |
| const wallCubes = cubes.filter(c => c.plane === 'right' && c.gridX === adjustedCol); | |
| const floorCubes = cubes.filter(c => c.plane === 'floor' && c.gridX === adjustedCol); | |
| return [...wallCubes, ...floorCubes]; | |
| } | |
| } | |
| function initBoard() { | |
| cubes = []; | |
| // Left wall (XY plane at X = -0.6) | |
| for (let y = 0; y < GRID_SIZE; y++) { | |
| for (let z = 0; z < GRID_SIZE; z++) { | |
| cubes.push(new SlotCube(0, y, z, 'left', getRandomSymbol())); | |
| } | |
| } | |
| // Right wall (YZ plane at Z = -0.6) | |
| for (let y = 0; y < GRID_SIZE; y++) { | |
| for (let x = 0; x < GRID_SIZE; x++) { | |
| cubes.push(new SlotCube(x, y, 0, 'right', getRandomSymbol())); | |
| } | |
| } | |
| // Floor (XZ plane at Y = -0.6) | |
| for (let x = 0; x < GRID_SIZE; x++) { | |
| for (let z = 0; z < GRID_SIZE; z++) { | |
| cubes.push(new SlotCube(x, 0, z, 'floor', getRandomSymbol())); | |
| } | |
| } | |
| } | |
| initBoard(); | |
| // ============================================ | |
| // INSTANCED RENDERER | |
| // ============================================ | |
| class CubeRenderer { | |
| constructor(scene) { | |
| this.scene = scene; | |
| this.meshes = {}; | |
| this.geometry = new THREE.BoxGeometry(1, 1, 1); | |
| this.dummy = new THREE.Object3D(); | |
| this.maxInstances = 100; | |
| this.createMeshes(); | |
| } | |
| createTexture(symbol, color) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 128; | |
| canvas.height = 128; | |
| const ctx = canvas.getContext('2d'); | |
| // Background | |
| ctx.fillStyle = '#' + color.toString(16).padStart(6, '0'); | |
| ctx.fillRect(0, 0, 128, 128); | |
| // Border | |
| ctx.strokeStyle = 'rgba(255,255,255,0.3)'; | |
| ctx.lineWidth = 12; | |
| ctx.strokeRect(6, 6, 116, 116); | |
| // Letter | |
| ctx.font = 'bold 72px Orbitron, Arial'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.strokeStyle = '#000'; | |
| ctx.lineWidth = 5; | |
| ctx.strokeText(symbol, 64, 68); | |
| ctx.fillStyle = '#fff'; | |
| ctx.fillText(symbol, 64, 68); | |
| return new THREE.CanvasTexture(canvas); | |
| } | |
| createMeshes() { | |
| for (const [key, data] of Object.entries(SYMBOLS)) { | |
| const texture = this.createTexture(data.letter, data.color); | |
| const material = new THREE.MeshPhongMaterial({ map: texture, shininess: 80 }); | |
| const mesh = new THREE.InstancedMesh(this.geometry, material, this.maxInstances); | |
| mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| this.scene.add(mesh); | |
| this.meshes[key] = mesh; | |
| } | |
| } | |
| render(cubes, time) { | |
| // Reset counts | |
| Object.values(this.meshes).forEach(mesh => mesh.count = 0); | |
| cubes.forEach(cube => { | |
| const mesh = this.meshes[cube.symbol]; | |
| if (!mesh) return; | |
| const idx = mesh.count; | |
| // Lerp position | |
| cube.position.lerp(cube.targetPosition, 0.15); | |
| // Lerp scale smoothly toward target | |
| cube.displayScale += (cube.scale - cube.displayScale) * 0.12; | |
| // Spin animation | |
| if (cube.isSpinning) { | |
| cube.spinPhase += 0.3; | |
| cube.rotation = cube.spinPhase; | |
| } else { | |
| // Snap to nearest 90 degrees | |
| const target = Math.round(cube.rotation / (Math.PI / 2)) * (Math.PI / 2); | |
| cube.rotation += (target - cube.rotation) * 0.2; | |
| } | |
| // Attract mode highlight - pulsing scale | |
| let scale = cube.displayScale; | |
| if (cube.attractHighlight) { | |
| const pulse = 1.1 + 0.15 * Math.sin(time * 0.006); | |
| scale = pulse; | |
| } | |
| this.dummy.position.copy(cube.position); | |
| this.dummy.rotation.set(cube.isSpinning ? cube.spinPhase * 0.5 : 0, cube.rotation, 0); | |
| this.dummy.scale.setScalar(scale); | |
| this.dummy.updateMatrix(); | |
| mesh.setMatrixAt(idx, this.dummy.matrix); | |
| mesh.count++; | |
| }); | |
| Object.values(this.meshes).forEach(mesh => { | |
| mesh.instanceMatrix.needsUpdate = true; | |
| }); | |
| } | |
| } | |
| const cubeRenderer = new CubeRenderer(scene); | |
| // ============================================ | |
| // MATCH DETECTION | |
| // ============================================ | |
| function areNeighbors(c1, c2) { | |
| const p1 = c1.plane, p2 = c2.plane; | |
| // Same plane | |
| if (p1 === p2) { | |
| if (p1 === 'left') { | |
| return Math.abs(c1.gridY - c2.gridY) + Math.abs(c1.gridZ - c2.gridZ) === 1; | |
| } | |
| if (p1 === 'right') { | |
| return Math.abs(c1.gridX - c2.gridX) + Math.abs(c1.gridY - c2.gridY) === 1; | |
| } | |
| // Floor | |
| return Math.abs(c1.gridX - c2.gridX) + Math.abs(c1.gridZ - c2.gridZ) === 1; | |
| } | |
| // Cross-face adjacency | |
| if ((p1 === 'left' && p2 === 'floor') || (p1 === 'floor' && p2 === 'left')) { | |
| const L = p1 === 'left' ? c1 : c2; | |
| const F = p1 === 'floor' ? c1 : c2; | |
| return L.gridY === 0 && F.gridX === 0 && L.gridZ === F.gridZ; | |
| } | |
| if ((p1 === 'right' && p2 === 'floor') || (p1 === 'floor' && p2 === 'right')) { | |
| const R = p1 === 'right' ? c1 : c2; | |
| const F = p1 === 'floor' ? c1 : c2; | |
| return R.gridY === 0 && F.gridZ === 0 && R.gridX === F.gridX; | |
| } | |
| if ((p1 === 'left' && p2 === 'right') || (p1 === 'right' && p2 === 'left')) { | |
| const L = p1 === 'left' ? c1 : c2; | |
| const R = p1 === 'right' ? c1 : c2; | |
| return L.gridZ === 0 && R.gridX === 0 && L.gridY === R.gridY; | |
| } | |
| return false; | |
| } | |
| function findMatches() { | |
| const visited = new Set(); | |
| const groups = []; | |
| cubes.forEach(start => { | |
| if (visited.has(start)) return; | |
| const group = [start]; | |
| const queue = [start]; | |
| visited.add(start); | |
| while (queue.length > 0) { | |
| const current = queue.shift(); | |
| cubes.forEach(neighbor => { | |
| if (!visited.has(neighbor) && | |
| neighbor.symbol === current.symbol && | |
| areNeighbors(current, neighbor)) { | |
| visited.add(neighbor); | |
| group.push(neighbor); | |
| queue.push(neighbor); | |
| } | |
| }); | |
| } | |
| if (group.length >= MIN_MATCH) { | |
| groups.push(group); | |
| } | |
| }); | |
| return groups; | |
| } | |
| function calculateWinnings(matches) { | |
| let totalWin = 0; | |
| matches.forEach(group => { | |
| const symbol = SYMBOLS[group[0].symbol]; | |
| const count = group.length; | |
| // Base value per cube | |
| const baseWin = count * symbol.baseValue; | |
| // Bet multiplier bonus | |
| const betBonus = symbol.betMult * currentBet; | |
| totalWin += baseWin + betBonus; | |
| }); | |
| return totalWin; | |
| } | |
| // ============================================ | |
| // ATTRACT MODE | |
| // ============================================ | |
| function startAttractMode(matches) { | |
| if (matches.length === 0) return; | |
| lastMatches = matches; | |
| currentAttractIndex = 0; | |
| attractMode = true; | |
| // Reset all cube scales first | |
| cubes.forEach(cube => cube.scale = 1); | |
| // Start cycling through matches | |
| attractCycle(); | |
| attractInterval = setInterval(attractCycle, 1500); | |
| } | |
| function stopAttractMode() { | |
| if (!attractMode) return; | |
| attractMode = false; | |
| if (attractInterval) { | |
| clearInterval(attractInterval); | |
| attractInterval = null; | |
| } | |
| // Reset all cube scales and glows | |
| cubes.forEach(cube => { | |
| cube.scale = 1; | |
| cube.attractHighlight = false; | |
| }); | |
| lastMatches = []; | |
| currentAttractIndex = 0; | |
| } | |
| function attractCycle() { | |
| if (!attractMode || lastMatches.length === 0) return; | |
| // Reset previous highlights | |
| cubes.forEach(cube => { | |
| cube.scale = 1; | |
| cube.attractHighlight = false; | |
| }); | |
| // Highlight current match group | |
| const currentGroup = lastMatches[currentAttractIndex]; | |
| if (currentGroup) { | |
| currentGroup.forEach(cube => { | |
| cube.attractHighlight = true; | |
| }); | |
| } | |
| // Move to next group | |
| currentAttractIndex = (currentAttractIndex + 1) % lastMatches.length; | |
| } | |
| // ============================================ | |
| // SPIN MECHANICS | |
| // ============================================ | |
| async function spinColumn(colIndex, duration = 1500) { | |
| if (spinningColumns.has(colIndex)) return; | |
| const columnCubes = getColumnCubes(colIndex); | |
| if (columnCubes.length === 0) return; | |
| spinningColumns.add(colIndex); | |
| // Mark cubes as spinning | |
| columnCubes.forEach(cube => { | |
| cube.isSpinning = true; | |
| cube.spinPhase = 0; | |
| }); | |
| soundManager.spinStart(); | |
| // Update column button | |
| const btn = document.querySelector(`.col-btn[data-col="${colIndex}"]`); | |
| if (btn) btn.classList.add('spinning'); | |
| // Spin duration with randomness | |
| const spinTime = duration + Math.random() * 500; | |
| const startTime = performance.now(); | |
| // Symbol cycling during spin - faster at start, slowing down near end | |
| const symbolCycleInterval = setInterval(() => { | |
| const elapsed = performance.now() - startTime; | |
| const progress = elapsed / spinTime; | |
| // Slow down symbol changes as we approach the end | |
| const cycleChance = progress < 0.7 ? 1 : (1 - progress) * 3; | |
| columnCubes.forEach(cube => { | |
| if (cube.isSpinning && Math.random() < cycleChance) { | |
| cube.symbol = getRandomSymbol(); | |
| } | |
| }); | |
| }, 50); // Cycle symbols every 50ms | |
| // Tick sounds during spin | |
| const tickInterval = setInterval(() => { | |
| soundManager.spinTick(colIndex); | |
| }, 100); | |
| await new Promise(resolve => setTimeout(resolve, spinTime)); | |
| clearInterval(tickInterval); | |
| clearInterval(symbolCycleInterval); | |
| // Final symbol assignment | |
| columnCubes.forEach(cube => { | |
| cube.symbol = getRandomSymbol(); | |
| cube.isSpinning = false; | |
| }); | |
| soundManager.spinStop(colIndex); | |
| // Update button | |
| if (btn) btn.classList.remove('spinning'); | |
| spinningColumns.delete(colIndex); | |
| } | |
| async function spinAll() { | |
| if (isSpinning || credits < currentBet) return; | |
| // Stop attract mode | |
| stopAttractMode(); | |
| isSpinning = true; | |
| soundManager.resume(); | |
| // Deduct bet | |
| credits -= currentBet; | |
| updateCreditsDisplay(); | |
| updateStatus('Spinning...'); | |
| document.getElementById('spin-all-btn').disabled = true; | |
| // Spin all columns with staggered timing | |
| const spinPromises = []; | |
| for (let i = 0; i < 10; i++) { | |
| spinPromises.push( | |
| new Promise(resolve => { | |
| setTimeout(() => { | |
| spinColumn(i, 1000 + i * 200).then(resolve); | |
| }, i * 100); | |
| }) | |
| ); | |
| } | |
| await Promise.all(spinPromises); | |
| // Small delay before checking matches | |
| await new Promise(resolve => setTimeout(resolve, 300)); | |
| // Check for wins | |
| const matches = findMatches(); | |
| const winnings = calculateWinnings(matches); | |
| if (winnings > 0) { | |
| credits += winnings; | |
| updateCreditsDisplay(); | |
| // Collect all matching cubes into a Set for quick lookup | |
| const matchingCubes = new Set(); | |
| matches.forEach(group => { | |
| group.forEach(cube => matchingCubes.add(cube)); | |
| }); | |
| // Reveal effect: shrink non-matching cubes, grow matching ones | |
| cubes.forEach(cube => { | |
| if (matchingCubes.has(cube)) { | |
| cube.scale = 1.1; // Emphasize matches | |
| } else { | |
| cube.scale = 0.7; // Shrink non-matches | |
| } | |
| }); | |
| const totalMatched = matches.reduce((sum, g) => sum + g.length, 0); | |
| const multiplier = winnings / currentBet; | |
| // Tiered win sounds based on payout multiplier | |
| if (multiplier >= 6) { | |
| // Jackpot! 6x+ bet - requires large rare matches | |
| soundManager.jackpot(); | |
| showWinOverlay(`π° JACKPOT! +${winnings}`, true); | |
| showFloatingText(`JACKPOT +${winnings}`, 'jackpot'); | |
| } else if (multiplier >= 3) { | |
| // Big win: 3x-6x bet - rare symbol matches | |
| soundManager.winBig(); | |
| showWinOverlay(`BIG WIN! +${winnings}`); | |
| showFloatingText(`+${winnings}`, 'big-win'); | |
| } else if (multiplier >= 1) { | |
| // Medium win: 1x-3x bet - broke even or profit | |
| soundManager.winMedium(); | |
| showFloatingText(`+${winnings}`, 'match'); | |
| } else { | |
| // Small win: < 1x bet - common symbols | |
| soundManager.winSmall(); | |
| showFloatingText(`+${winnings}`, 'match'); | |
| } | |
| updateStatus(`Won ${winnings} credits! (${multiplier.toFixed(1)}x)`); | |
| document.getElementById('info-box').classList.add('win'); | |
| setTimeout(() => document.getElementById('info-box').classList.remove('win'), 2000); | |
| // Restore non-matching cubes after reveal, then start attract mode | |
| setTimeout(() => { | |
| cubes.forEach(cube => { | |
| if (!matchingCubes.has(cube)) { | |
| cube.scale = 1; // Restore non-matches | |
| } | |
| }); | |
| // Normalize matching cubes too | |
| matchingCubes.forEach(cube => cube.scale = 1); | |
| // Start attract mode | |
| if (!isSpinning) { | |
| startAttractMode(matches); | |
| } | |
| }, 2000); | |
| } else { | |
| soundManager.noWin(); | |
| updateStatus('No matches. Try again!'); | |
| } | |
| document.getElementById('spin-all-btn').disabled = false; | |
| isSpinning = false; | |
| } | |
| async function spinSingleColumn(colIndex) { | |
| if (spinningColumns.has(colIndex)) return; | |
| // Stop attract mode | |
| stopAttractMode(); | |
| const singleBet = Math.ceil(currentBet / 10); | |
| if (credits < singleBet) { | |
| updateStatus('Not enough credits!'); | |
| return; | |
| } | |
| soundManager.resume(); | |
| credits -= singleBet; | |
| updateCreditsDisplay(); | |
| await spinColumn(colIndex, 800); | |
| // Check matches after single column spin | |
| await new Promise(resolve => setTimeout(resolve, 200)); | |
| const matches = findMatches(); | |
| const winnings = calculateWinnings(matches); | |
| if (winnings > 0) { | |
| credits += winnings; | |
| updateCreditsDisplay(); | |
| // Collect all matching cubes into a Set for quick lookup | |
| const matchingCubes = new Set(); | |
| matches.forEach(group => { | |
| group.forEach(cube => matchingCubes.add(cube)); | |
| }); | |
| // Reveal effect: shrink non-matching cubes, grow matching ones | |
| cubes.forEach(cube => { | |
| if (matchingCubes.has(cube)) { | |
| cube.scale = 1.1; // Emphasize matches | |
| } else { | |
| cube.scale = 0.7; // Shrink non-matches | |
| } | |
| }); | |
| const totalMatched = matches.reduce((sum, g) => sum + g.length, 0); | |
| const multiplier = winnings / currentBet; | |
| // Tiered win sounds based on multiplier | |
| if (multiplier >= 6) { | |
| soundManager.jackpot(); | |
| showFloatingText(`JACKPOT +${winnings}`, 'jackpot'); | |
| } else if (multiplier >= 3) { | |
| soundManager.winBig(); | |
| showFloatingText(`+${winnings}`, 'big-win'); | |
| } else if (multiplier >= 1) { | |
| soundManager.winMedium(); | |
| showFloatingText(`+${winnings}`, 'match'); | |
| } else { | |
| soundManager.winSmall(); | |
| showFloatingText(`+${winnings}`, 'match'); | |
| } | |
| updateStatus(`Won ${winnings} credits! (${multiplier.toFixed(1)}x)`); | |
| // Restore non-matching cubes after reveal, then start attract mode | |
| setTimeout(() => { | |
| cubes.forEach(cube => { | |
| if (!matchingCubes.has(cube)) { | |
| cube.scale = 1; // Restore non-matches | |
| } | |
| }); | |
| // Normalize matching cubes too | |
| matchingCubes.forEach(cube => cube.scale = 1); | |
| // Start attract mode | |
| if (!isSpinning && spinningColumns.size === 0) { | |
| startAttractMode(matches); | |
| } | |
| }, 1500); | |
| } else { | |
| updateStatus(`Column ${colIndex} spun`); | |
| } | |
| } | |
| // ============================================ | |
| // UI UPDATES | |
| // ============================================ | |
| function updateCreditsDisplay() { | |
| document.getElementById('credits-value').textContent = credits.toLocaleString(); | |
| } | |
| function updateStatus(text) { | |
| document.getElementById('status-text').textContent = text; | |
| } | |
| function showWinOverlay(text, isJackpot = false) { | |
| const overlay = document.getElementById('win-overlay'); | |
| const message = document.getElementById('win-message'); | |
| message.innerHTML = `<span class="win-text">${text}</span>`; | |
| message.classList.toggle('jackpot', isJackpot); | |
| overlay.classList.add('visible'); | |
| const duration = isJackpot ? 3500 : 2500; | |
| setTimeout(() => { | |
| overlay.classList.remove('visible'); | |
| message.classList.remove('jackpot'); | |
| }, duration); | |
| } | |
| function showFloatingText(text, type) { | |
| const container = document.getElementById('float-container'); | |
| const el = document.createElement('div'); | |
| el.className = `float-text ${type}`; | |
| el.textContent = text; | |
| el.style.left = '50%'; | |
| el.style.top = '50%'; | |
| el.style.transform = 'translate(-50%, -50%)'; | |
| container.appendChild(el); | |
| setTimeout(() => el.remove(), 2000); | |
| } | |
| function updateDebugView() { | |
| if (!debugMode) return; | |
| const symbolColors = {}; | |
| for (const [key, data] of Object.entries(SYMBOLS)) { | |
| symbolColors[key] = '#' + data.color.toString(16).padStart(6, '0'); | |
| } | |
| // Left wall (Y rows, Z columns - displayed top to bottom) | |
| const leftGrid = document.getElementById('debug-left'); | |
| leftGrid.innerHTML = ''; | |
| for (let y = GRID_SIZE - 1; y >= 0; y--) { | |
| for (let z = 0; z < GRID_SIZE; z++) { | |
| const cube = cubes.find(c => c.plane === 'left' && c.gridY === y && c.gridZ === z); | |
| const cell = document.createElement('div'); | |
| cell.className = 'debug-cell'; | |
| if (cube) { | |
| cell.style.background = symbolColors[cube.symbol]; | |
| cell.textContent = cube.symbol; | |
| if (cube.attractHighlight) { | |
| cell.style.boxShadow = '0 0 8px 2px #fff'; | |
| cell.style.transform = 'scale(1.1)'; | |
| cell.style.zIndex = '1'; | |
| } | |
| } | |
| leftGrid.appendChild(cell); | |
| } | |
| } | |
| // Right wall (Y rows, X columns - displayed top to bottom) | |
| const rightGrid = document.getElementById('debug-right'); | |
| rightGrid.innerHTML = ''; | |
| for (let y = GRID_SIZE - 1; y >= 0; y--) { | |
| for (let x = 0; x < GRID_SIZE; x++) { | |
| const cube = cubes.find(c => c.plane === 'right' && c.gridY === y && c.gridX === x); | |
| const cell = document.createElement('div'); | |
| cell.className = 'debug-cell'; | |
| if (cube) { | |
| cell.style.background = symbolColors[cube.symbol]; | |
| cell.textContent = cube.symbol; | |
| if (cube.attractHighlight) { | |
| cell.style.boxShadow = '0 0 8px 2px #fff'; | |
| cell.style.transform = 'scale(1.1)'; | |
| cell.style.zIndex = '1'; | |
| } | |
| } | |
| rightGrid.appendChild(cell); | |
| } | |
| } | |
| // Floor (Z rows, X columns) | |
| const floorGrid = document.getElementById('debug-floor'); | |
| floorGrid.innerHTML = ''; | |
| for (let z = 0; z < GRID_SIZE; z++) { | |
| for (let x = 0; x < GRID_SIZE; x++) { | |
| const cube = cubes.find(c => c.plane === 'floor' && c.gridX === x && c.gridZ === z); | |
| const cell = document.createElement('div'); | |
| cell.className = 'debug-cell'; | |
| if (cube) { | |
| cell.style.background = symbolColors[cube.symbol]; | |
| cell.textContent = cube.symbol; | |
| if (cube.attractHighlight) { | |
| cell.style.boxShadow = '0 0 8px 2px #fff'; | |
| cell.style.transform = 'scale(1.1)'; | |
| cell.style.zIndex = '1'; | |
| } | |
| } | |
| floorGrid.appendChild(cell); | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // EVENT LISTENERS | |
| // ============================================ | |
| document.getElementById('spin-all-btn').addEventListener('click', () => { | |
| spinAll(); | |
| }); | |
| document.getElementById('bet-select').addEventListener('change', (e) => { | |
| currentBet = parseInt(e.target.value); | |
| stopAttractMode(); | |
| soundManager.click(); | |
| }); | |
| document.getElementById('sound-toggle').addEventListener('click', () => { | |
| const enabled = !soundManager.isEnabled(); | |
| soundManager.setEnabled(enabled); | |
| const btn = document.getElementById('sound-toggle'); | |
| btn.textContent = enabled ? 'π Sound: ON' : 'π Sound: OFF'; | |
| btn.classList.toggle('active', enabled); | |
| if (enabled) soundManager.click(); | |
| }); | |
| document.getElementById('debug-toggle').addEventListener('click', () => { | |
| debugMode = !debugMode; | |
| const btn = document.getElementById('debug-toggle'); | |
| btn.textContent = debugMode ? 'π§ Debug: ON' : 'π§ Debug: OFF'; | |
| btn.classList.toggle('active', debugMode); | |
| document.getElementById('debug-panel').classList.toggle('visible', debugMode); | |
| soundManager.click(); | |
| if (debugMode) updateDebugView(); | |
| }); | |
| // Column buttons | |
| document.querySelectorAll('.col-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const col = parseInt(btn.dataset.col); | |
| spinSingleColumn(col); | |
| }); | |
| }); | |
| // Keyboard controls | |
| window.addEventListener('keydown', (e) => { | |
| if (e.code === 'Space') { | |
| e.preventDefault(); | |
| spinAll(); | |
| } else if (e.key >= '0' && e.key <= '9') { | |
| spinSingleColumn(parseInt(e.key)); | |
| } else if (e.key.toLowerCase() === 'm') { | |
| document.getElementById('sound-toggle').click(); | |
| } | |
| }); | |
| // ============================================ | |
| // ANIMATION LOOP | |
| // ============================================ | |
| let lastDebugUpdate = 0; | |
| function animate(time) { | |
| requestAnimationFrame(animate); | |
| // Rotate starfield slowly | |
| starfield.rotation.y += 0.0002; | |
| starfield.rotation.x += 0.0001; | |
| // Render cubes with time for attract mode pulse | |
| cubeRenderer.render(cubes, time || 0); | |
| // Update debug view continuously (throttled to ~20fps for performance) | |
| const now = performance.now(); | |
| if (debugMode && now - lastDebugUpdate > 50) { | |
| updateDebugView(); | |
| lastDebugUpdate = now; | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| // Handle resize | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // Initialize | |
| updateDebugView(); | |
| animate(); | |
| console.log('π° Lattice Slots loaded! Press Space to spin or use the buttons.'); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment