Created
December 20, 2025 13:11
-
-
Save EncodeTheCode/36248aeb2ed79c24b8cb940341dff23c 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" /> | |
| <title>Rogue — Talent Build Mock (HTML5 + JS)</title> | |
| <style> | |
| :root{--bg:#0f1724;--panel:#0b1220;--accent:#0ea5e9;--muted:#94a3b8;--node:#111827} | |
| html,body{height:100%;margin:0;font-family:Inter,Segoe UI,Arial,sans-serif;background:linear-gradient(180deg,#071226 0%, #021017 100%);color:#e6eef6} | |
| #game-wrap{display:flex;height:100vh;gap:12px;padding:12px;box-sizing:border-box} | |
| #left{flex:1;position:relative;display:flex;flex-direction:column} | |
| canvas{background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.02));border-radius:8px;box-shadow:0 6px 18px rgba(2,6,23,0.6);} | |
| .hud{position:absolute;left:20px;top:20px;background:rgba(2,6,23,0.6);padding:8px 10px;border-radius:8px;border:1px solid rgba(255,255,255,0.03)} | |
| .hud small{display:block;color:var(--muted);} | |
| #right{width:380px;display:flex;flex-direction:column;gap:12px} | |
| .card{background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.02));padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,0.03)} | |
| .title{font-weight:700;font-size:18px;color:#fff;margin-bottom:8px} | |
| .muted{color:var(--muted);font-size:13px} | |
| /* Modal / Talent Window */ | |
| .overlay{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:none;align-items:center;justify-content:center;z-index:1000} | |
| .talent-window{width:1000px;max-width:calc(100% - 40px);height:680px;background:linear-gradient(180deg,#071226,#07142a);border-radius:12px;padding:18px;display:flex;gap:12px;box-shadow:0 10px 40px rgba(1,4,10,0.8);border:1px solid rgba(255,255,255,0.04)} | |
| .tree{flex:1;background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.02));border-radius:8px;padding:10px;display:flex;flex-direction:column;align-items:center} | |
| .tree h3{margin:0 0 6px 0} | |
| .points{font-weight:700;color:var(--accent)} | |
| .grid{display:grid;grid-template-rows:repeat(7,60px);grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px} | |
| .node{background:linear-gradient(180deg, rgba(255,255,255,0.01), rgba(0,0,0,0.02));border-radius:8px;border:1px solid rgba(255,255,255,0.03);display:flex;align-items:center;justify-content:center;position:relative;cursor:pointer;padding:6px} | |
| .node.locked{opacity:0.35} | |
| .node.threshold{background:linear-gradient(180deg, rgba(14,165,233,0.06), rgba(2,6,23,0.02));border:1px dashed rgba(14,165,233,0.18)} | |
| .node .label{font-size:12px;text-align:center} | |
| .node .count{position:absolute;right:6px;top:6px;background:rgba(0,0,0,0.4);padding:2px 6px;border-radius:8px;font-size:11px} | |
| .node .controls{position:absolute;left:6px;bottom:6px;display:flex;gap:6px} | |
| .btn{background:transparent;border:1px solid rgba(255,255,255,0.06);padding:2px 6px;border-radius:6px;color:var(--muted);font-weight:700;cursor:pointer} | |
| .btn:disabled{opacity:0.35;cursor:not-allowed} | |
| .footer{display:flex;justify-content:space-between;margin-top:10px} | |
| .close{background:var(--accent);border:none;padding:8px 10px;border-radius:8px;color:#022235;font-weight:700;cursor:pointer} | |
| /* Tooltip */ | |
| .tooltip{position:fixed;background:#071226;border:1px solid rgba(255,255,255,0.04);padding:10px;border-radius:8px;pointer-events:none;display:none;max-width:300px;z-index:2000} | |
| .tooltip h4{margin:0 0 4px 0} | |
| .tooltip p{margin:0;color:var(--muted);font-size:13px} | |
| /* Simple responsive */ | |
| @media (max-width:1100px){#right{display:none}} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="game-wrap"> | |
| <div id="left"> | |
| <canvas id="game" width="960" height="640"></canvas> | |
| <div class="hud"> | |
| <div><strong>Rogue Mock — Playable</strong></div> | |
| <small>Move: WASD / Aim: mouse — Open talent window: <strong>K</strong></small> | |
| </div> | |
| </div> | |
| <div id="right"> | |
| <div class="card"> | |
| <div class="title">Controls</div> | |
| <div class="muted">Use WASD to move the blue orb. Move your mouse to aim the black line.</div> | |
| <hr style="margin:10px 0;opacity:0.07"> | |
| <div class="muted">Open/close talent window with <strong>K</strong>. Click + to place a point (subject to requirements). You cannot remove points unless you call <code>reset_talents()</code>.</div> | |
| </div> | |
| <div class="card"> | |
| <div class="title">Talent Points</div> | |
| <div style="display:flex;gap:10px;align-items:center"><div>Available:</div><div class="points" id="availablePoints">51</div></div> | |
| <div style="margin-top:8px;color:var(--muted)">Baseline: 51 points (WoW Classic). Tier unlocks: 5, 10, 15, 20, 25, 30 points in a tree; third-row has special half-fill behavior (see tooltip).</div> | |
| </div> | |
| <div class="card"> | |
| <div class="title">Quick Tips</div> | |
| <ul style="margin:6px 0 0 18px;color:var(--muted)"> | |
| <li>Some talents are single-point (unique). Others allow multiple points.</li> | |
| <li>Invest points in a tree to unlock higher-tier talents.</li> | |
| <li>Third row (row index 2) unlocks if at least half of the talents in each of the first two rows have at least one point, OR when the tree reaches the usual 10-point threshold.</li> | |
| </ul> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Talent overlay --> | |
| <div class="overlay" id="overlay" aria-hidden="true"> | |
| <div class="talent-window" role="dialog" aria-modal="true"> | |
| <div class="tree" id="tree0"> | |
| <h3>Stalker</h3> | |
| <div class="muted">A nimble specialist who relies on stealth and critical strikes.</div> | |
| <div style="margin-top:8px">Points: <span class="points" id="tree0Points">0</span></div> | |
| <div class="grid" data-tree="0" id="grid0"></div> | |
| </div> | |
| <div class="tree" id="tree1"> | |
| <h3>Duelist</h3> | |
| <div class="muted">A master of sustained combat and tactical advantage.</div> | |
| <div style="margin-top:8px">Points: <span class="points" id="tree1Points">0</span></div> | |
| <div class="grid" data-tree="1" id="grid1"></div> | |
| </div> | |
| <div class="tree" id="tree2"> | |
| <h3>Nightblade</h3> | |
| <div class="muted">Focuses on shadowy tricks and speed to outmaneuver foes.</div> | |
| <div style="margin:8px 0 0 0">Points: <span class="points" id="tree2Points">0</span></div> | |
| <div class="grid" data-tree="2" id="grid2"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tooltip" id="tooltip" role="tooltip"></div> | |
| <script> | |
| // === Configuration === | |
| const TOTAL_POINTS = 51; // same as WoW Classic baseline | |
| const ROWS = 7, COLS = 3; | |
| // Exact tier unlock thresholds (points spent in that tree required before placing a point in that tier) | |
| const TIER_THRESHOLDS = [0,5,10,15,20,25,30]; // row index -> required points | |
| function tierRequirement(row){ return TIER_THRESHOLDS[row] ?? (row*5); } | |
| // We'll programmatically generate talents per tree. Each talent has: id,name,desc,max,spent,prereq(optional) | |
| const trees = []; | |
| const treeNames = ['Stalker','Duelist','Nightblade']; | |
| function makeTalentId(ti,row,col){return `t${ti}r${row}c${col}`} | |
| // Deterministic function for max ranks for each slot. | |
| function maxRanksFor(ti,row,col){ | |
| if(row === 0) return (col === 1 ? 3 : 1); | |
| if(row === 1) return 1; | |
| if(row === 2) return (col === 1 ? 2 : 1); | |
| if(row === 3) return 1; | |
| if(row === 4) return (col === 1 ? 2 : 1); | |
| return 1; | |
| } | |
| const ADJ = ['Quick','Keen','Shadow','Deadly','Fleet','Razor','Lethal']; | |
| const NNS = ['Strike','Edge','Trick','Focus','Assault','Ambush','Feint']; | |
| // Build talents and ensure ALL start at 0 spent by default | |
| for(let ti=0;ti<3;ti++){ | |
| const talents = []; | |
| for(let r=0;r<ROWS;r++){ | |
| for(let c=0;c<COLS;c++){ | |
| const id = makeTalentId(ti,r,c); | |
| const isThreshold = (c===1 && r>=1); | |
| const name = isThreshold ? `Tier ${r+1} Gate` : `${ADJ[(r+c+ti)%ADJ.length]} ${NNS[(r*3+c+ti)%NNS.length]}`; | |
| const desc = isThreshold ? `Tier ${r+1} marker. Becomes active when the required tree conditions are met.` : `Tier ${r+1} talent. ${name} grants a fantasy passive.`; | |
| const max = isThreshold ? 1 : maxRanksFor(ti,r,c); | |
| // IMPORTANT: spent always initialized to 0 here to meet your request | |
| const talent = {id,name,desc,max,spent:0,row:r,col:c,prereq:null,isThreshold:isThreshold,threshold: isThreshold ? tierRequirement(r) : null}; | |
| if(r >= 2 && !isThreshold){ | |
| const aboveId = makeTalentId(ti, Math.max(0,r-1), c); | |
| talent.prereq = {id: aboveId, points: 1}; | |
| } | |
| talents.push(talent); | |
| } | |
| } | |
| trees.push({name:treeNames[ti],talents,spent:0}); | |
| } | |
| // === State === | |
| let availablePoints = TOTAL_POINTS; | |
| // expose reset function to global scope | |
| window.reset_talents = function(){ | |
| if(!confirm('Reset all talent points in all trees?')) return; | |
| for(const tr of trees) for(const t of tr.talents) t.spent = 0; | |
| availablePoints = TOTAL_POINTS; | |
| refreshUI(); saveLS(); | |
| } | |
| // DOM refs | |
| const overlay = document.getElementById('overlay'); | |
| const availableEl = document.getElementById('availablePoints'); | |
| const tooltip = document.getElementById('tooltip'); | |
| // Build UI grid | |
| function buildGrids(){ | |
| for(let ti=0;ti<3;ti++){ | |
| const grid = document.getElementById(`grid${ti}`); | |
| grid.innerHTML=''; | |
| const talents = trees[ti].talents; | |
| for(const t of talents){ | |
| const node = document.createElement('div'); | |
| node.className = 'node'; | |
| if(t.isThreshold) node.classList.add('threshold'); | |
| node.dataset.id = t.id; | |
| node.dataset.tree = ti; | |
| node.dataset.row = t.row; | |
| node.dataset.col = t.col; | |
| const label = document.createElement('div'); label.className='label'; label.innerText = t.name; | |
| const count = document.createElement('div'); count.className='count'; count.innerText = t.isThreshold ? `0/${t.max}` : `${t.spent}/${t.max}`; | |
| const controls = document.createElement('div'); controls.className='controls'; | |
| if(!t.isThreshold){ | |
| const plus = document.createElement('button'); plus.className='btn'; plus.innerText='+'; | |
| plus.addEventListener('click', (e)=>{e.stopPropagation(); trySpendPoint(t.id);}); | |
| controls.appendChild(plus); | |
| } else { | |
| const info = document.createElement('div'); info.style.fontSize='11px'; info.style.color='var(--muted)'; info.innerText = `Requires ${t.threshold}`; | |
| controls.appendChild(info); | |
| } | |
| node.appendChild(label); node.appendChild(count); node.appendChild(controls); | |
| node.addEventListener('mouseenter',(e)=>{showTooltip(t,e.clientX,e.clientY);}); | |
| node.addEventListener('mousemove',(e)=>{moveTooltip(e.clientX,e.clientY);}); | |
| node.addEventListener('mouseleave',hideTooltip); | |
| grid.appendChild(node); | |
| } | |
| } | |
| refreshUI(); | |
| } | |
| function pointsSpentInTree(ti){ | |
| return trees[ti].talents.reduce((s,t)=> s + (t.isThreshold ? 0 : t.spent), 0); | |
| } | |
| function findTalentById(id){ | |
| for(let ti=0;ti<3;ti++){ | |
| const t = trees[ti].talents.find(x=>x.id===id); | |
| if(t) return {tal:t, tree:ti}; | |
| } | |
| return null; | |
| } | |
| // third-row half-fill logic | |
| function halfFilledPriorRows(ti){ | |
| const tr = trees[ti]; | |
| for(let r=0;r<=1;r++){ | |
| const rowTalents = tr.talents.filter(x=>x.row===r && !x.isThreshold); | |
| const need = Math.ceil(rowTalents.length/2); | |
| const have = rowTalents.reduce((s,x)=> s + (x.spent>0?1:0), 0); | |
| if(have < need) return false; | |
| } | |
| return true; | |
| } | |
| function isTalentUnlocked(ti,t){ | |
| if(t.isThreshold){ | |
| return pointsSpentInTree(ti) >= t.threshold || (t.row===2 && halfFilledPriorRows(ti)); | |
| } | |
| if(t.row === 2){ | |
| const byPoints = pointsSpentInTree(ti) >= tierRequirement(2); | |
| const byHalf = halfFilledPriorRows(ti); | |
| if(!(byPoints || byHalf)) return false; | |
| } else { | |
| const req = tierRequirement(t.row); | |
| if(pointsSpentInTree(ti) < req) return false; | |
| } | |
| if(t.prereq){ | |
| const p = findTalentById(t.prereq.id); | |
| if(!p) return false; | |
| if(p.tal.isThreshold){ | |
| if(pointsSpentInTree(ti) < p.tal.threshold) return false; | |
| } else { | |
| if(p.tal.spent < t.prereq.points) return false; | |
| } | |
| } | |
| return true; | |
| } | |
| function trySpendPoint(id){ | |
| const info = findTalentById(id); if(!info) return; | |
| const t = info.tal; const ti = info.tree; | |
| if(t.isThreshold) return; // cannot click thresholds | |
| if(availablePoints <=0) return; // no points | |
| if(t.spent >= t.max) return; | |
| if(!isTalentUnlocked(ti,t)){ | |
| flashNode(id,'locked'); return; | |
| } | |
| t.spent += 1; availablePoints -=1; refreshUI(); saveLS(); | |
| } | |
| function flashNode(id,cls){ | |
| const el = document.querySelector(`[data-id='${id}']`); | |
| if(!el) return; el.classList.add(cls); | |
| setTimeout(()=>el.classList.remove(cls),350); | |
| } | |
| function refreshUI(){ | |
| for(let ti=0;ti<3;ti++){ | |
| const grid = document.getElementById(`grid${ti}`); | |
| const nodes = grid.querySelectorAll('.node'); | |
| nodes.forEach(n=>{ | |
| const id = n.dataset.id; const info = findTalentById(id); | |
| const t = info.tal; | |
| if(t.isThreshold){ | |
| const filled = pointsSpentInTree(ti) >= t.threshold || (t.row===2 && halfFilledPriorRows(ti)); | |
| // ensure threshold nodes DO NOT show as filled unless conditions are met | |
| t.spent = filled ? 1 : 0; | |
| n.querySelector('.count').innerText = filled ? '✓' : `0/${t.max}`; | |
| if(filled) n.classList.remove('locked'); else n.classList.add('locked'); | |
| } else { | |
| // normal talents always show the actual spent value (initially 0) | |
| n.querySelector('.count').innerText = `${t.spent}/${t.max}`; | |
| if(isTalentUnlocked(ti,t)) n.classList.remove('locked'); else n.classList.add('locked'); | |
| const plus = n.querySelector('.controls .btn'); | |
| if(plus) plus.disabled = !(availablePoints>0 && t.spent < t.max && isTalentUnlocked(ti,t)); | |
| } | |
| }); | |
| document.getElementById(`tree${ti}Points`).innerText = pointsSpentInTree(ti); | |
| } | |
| availableEl.innerText = availablePoints; | |
| } | |
| function showTooltip(t,eX,eY){ | |
| tooltip.style.display='block'; | |
| const ti = findTalentById(t.id).tree; | |
| const extra = (t.row===2 && !t.isThreshold) ? `<div style="margin-top:6px;color:var(--muted);font-size:12px">Note: this row also unlocks when half of talents in rows 1 and 2 have at least 1 point.</div>` : ''; | |
| tooltip.innerHTML = `<h4>${t.name} <span style="float:right;font-weight:700;color:var(--accent)">${t.isThreshold ? (pointsSpentInTree(ti) >= t.threshold ? '✓' : `0/${t.max}`) : `${t.spent}/${t.max}`}</span></h4><p>${t.desc}</p>${extra}`; | |
| moveTooltip(eX,eY); | |
| } | |
| function moveTooltip(x,y){ | |
| const pad = 14; | |
| const winW = window.innerWidth, winH = window.innerHeight; | |
| const tw = tooltip.offsetWidth || 260, th = tooltip.offsetHeight || 60; | |
| let left = x + pad; let top = y + pad; | |
| if(left + tw > winW) left = x - tw - pad; | |
| if(top + th > winH) top = y - th - pad; | |
| tooltip.style.left = (left) + 'px'; tooltip.style.top = (top) + 'px'; | |
| } | |
| function hideTooltip(){tooltip.style.display='none'} | |
| // Toggle overlay with key | |
| let overlayOpen = false; | |
| function toggleOverlay(){overlayOpen = !overlayOpen; overlay.style.display = overlayOpen?'flex':'none'; overlay.setAttribute('aria-hidden', overlayOpen ? 'false' : 'true');} | |
| document.addEventListener('keydown',(e)=>{ if(e.key.toLowerCase()==='k'){ toggleOverlay(); } if(e.key==='Escape' && overlayOpen){ toggleOverlay(); }}); | |
| // Initialize UI (important: do NOT auto-load saved builds so all talents default to 0) | |
| buildGrids(); | |
| // === Game canvas: player movement and aiming === | |
| const canvasGame = document.getElementById('game'); | |
| const ctxGame = canvasGame.getContext('2d'); | |
| const playerState = {x:canvasGame.width/2,y:canvasGame.height/2, speed:180, aimX:canvasGame.width/2, aimY:canvasGame.height/2}; | |
| const keysState = {}; | |
| window.addEventListener('keydown', e=>{ keysState[e.key.toLowerCase()] = true; }); | |
| window.addEventListener('keyup', e=>{ keysState[e.key.toLowerCase()] = false; }); | |
| canvasGame.addEventListener('mousemove',(e)=>{ const rect = canvasGame.getBoundingClientRect(); playerState.aimX = e.clientX-rect.left; playerState.aimY = e.clientY-rect.top; }); | |
| let lastFrame = performance.now(); | |
| function frame(now){ const dt = Math.min(0.06,(now-lastFrame)/1000); lastFrame = now; update(dt); draw(); requestAnimationFrame(frame);} | |
| function update(dt){ let dx=0,dy=0; if(keysState['w']||keysState['arrowup']) dy-=1; if(keysState['s']||keysState['arrowdown']) dy+=1; if(keysState['a']||keysState['arrowleft']) dx-=1; if(keysState['d']||keysState['arrowright']) dx+=1; const mag = Math.hypot(dx,dy); if(mag>0){dx/=mag;dy/=mag} playerState.x += dx*playerState.speed*dt; playerState.y += dy*playerState.speed*dt; playerState.x = Math.max(16,Math.min(canvasGame.width-16,playerState.x)); playerState.y = Math.max(16,Math.min(canvasGame.height-16,playerState.y)); } | |
| function draw(){ ctxGame.clearRect(0,0,canvasGame.width,canvasGame.height); ctxGame.save(); for(let i=0;i<canvasGame.width;i+=40){ctxGame.beginPath();ctxGame.moveTo(i,0);ctxGame.lineTo(i,canvasGame.height);ctxGame.strokeStyle='rgba(255,255,255,0.02)';ctxGame.lineWidth=1;ctxGame.stroke();} for(let j=0;j<canvasGame.height;j+=40){ctxGame.beginPath();ctxGame.moveTo(0,j);ctxGame.lineTo(canvasGame.width,j);ctxGame.strokeStyle='rgba(255,255,255,0.02)';ctxGame.lineWidth=1;ctxGame.stroke();} ctxGame.restore(); ctxGame.beginPath(); ctxGame.moveTo(playerState.x,playerState.y); ctxGame.lineTo(playerState.aimX,playerState.aimY); ctxGame.strokeStyle='#000'; ctxGame.lineWidth=3; ctxGame.stroke(); ctxGame.beginPath(); ctxGame.arc(playerState.x,playerState.y,14,0,Math.PI*2); ctxGame.fillStyle='#2ea3ff'; ctxGame.fill(); ctxGame.beginPath(); ctxGame.arc(playerState.x,playerState.y,14,0,Math.PI*2); ctxGame.strokeStyle='rgba(0,0,0,0.25)'; ctxGame.lineWidth=2; ctxGame.stroke(); ctxGame.beginPath(); ctxGame.arc(playerState.aimX,playerState.aimY,4,0,Math.PI*2); ctxGame.fillStyle='rgba(255,255,255,0.9)'; ctxGame.fill(); } | |
| requestAnimationFrame(frame); | |
| // Export/Save helpers - NOTE: load is intentionally NOT auto-called so new sessions start with all-zero talents | |
| function exportBuild(){ const data = {trees:trees.map(t=>t.talents.map(x=>x.isThreshold? (pointsSpentInTree(findTalentById(x.id).tree) >= x.threshold?1:0) : x.spent)), available:availablePoints}; const blob = new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'rogue_build.json'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);} | |
| document.addEventListener('keydown',(e)=>{ if((e.ctrlKey||e.metaKey) && e.key.toLowerCase()==='e'){ e.preventDefault(); exportBuild(); alert('Build exported to rogue_build.json'); }}); | |
| function saveLS(){ const payload = {trees:trees.map(t=>t.talents.map(x=>x.isThreshold? (pointsSpentInTree(findTalentById(x.id).tree) >= x.threshold?1:0) : x.spent)), available:availablePoints}; localStorage.setItem('rogue_mock_build',JSON.stringify(payload)); } | |
| function loadLS(){ /* Intentionally left blank. Loading saved builds is disabled by default so all talents start at 0. */ } | |
| // autosave still runs but will only persist any changes made during the current session | |
| setInterval(()=>{ saveLS(); },2000); | |
| function findTalentById(x){ | |
| for(let ti=0;ti<trees.length;ti++){ const t = trees[ti].talents.find(y=>y.id===x); if(t) return {tal:t,tree:ti}; } return null; | |
| } | |
| // expose a manual loader if you want to load saved data later | |
| window.load_saved_build = function(){ | |
| const raw = localStorage.getItem('rogue_mock_build'); if(!raw) return alert('No saved build found'); | |
| try{ const s = JSON.parse(raw); if(s && s.trees){ s.trees.forEach((arr,ti)=>{ trees[ti].talents.forEach((t,i)=>{ if(!t.isThreshold) t.spent = arr[i] || 0; }); }); availablePoints = (typeof s.available === 'number') ? s.available : TOTAL_POINTS; refreshUI(); } } catch(e){ console.warn('Failed to load saved build:', e); alert('Failed to load saved build (console)'); } | |
| } | |
| // Save on changes | |
| setInterval(()=>{ saveLS(); },2000); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment