/** * Galaxy Strike Online — Starbase World v3 * Isometric Habbo-style walkable environment. * * Features: * - JSON-driven grid size, walls, doors, rooms * - Per-segment wall colour overrides * - Animated sliding doors (open when player is adjacent) * - Locked rooms (filled with sealed tiles, show LOCKED badge) * → unlock via room_unlock decoration items from shop/dungeons/quests * - Wallpapers: global or per-room, purchasable/earnable * → active wallpaper overrides style.floorColor* + style.wall* + style.door* * - Wallpaper selector panel (bottom toolbar) * - Click-to-walk BFS pathfinding * - Pixel-art astronaut with walk animation */ const TILE = { VOID: 0, FLOOR: 1, WALL: 2, DOOR: 3, LOCKED: 4 }; class StarbaseWorld { constructor(canvasId, layoutJson, starbasePlayerData, decorationCatalog) { this.canvas = document.getElementById(canvasId); this.ctx = this.canvas.getContext('2d'); this.TW = 64; this.TH = 32; this.layout = layoutJson || StarbaseWorld.defaultLayout(); this.COLS = this.layout.grid.cols; this.ROWS = this.layout.grid.rows; // Player's starbase save data { wallpaper, ownedWallpapers, unlockedRooms, roomWallpapers } this.sbData = starbasePlayerData || { wallpaper: null, ownedWallpapers: [], unlockedRooms: [] }; // Full decoration catalog from server this.catalog = decorationCatalog || []; this.originX = 0; this.originY = 0; const ps = this.layout.playerStart || { col: Math.floor(this.COLS/2), row: 2 }; this.player = { col: ps.col, row: ps.row, x: 0, y: 0, path: [], moving: false, frame: 0, frameTimer: 0, dir: 'se', name: 'Commander' }; this.tileMap = this._buildMapFromLayout(); this.doorStates = {}; this.doorDefs = {}; for (const d of (this.layout.doors || [])) { const k = `${d.col},${d.row}`; this.doorStates[k] = { open: false, anim: 0 }; this.doorDefs[k] = d; } this.wallDefs = {}; for (const w of (this.layout.walls || [])) { for (let i = 0; i < (w.span || 1); i++) { const c = w.dir === 'h' ? w.col + i : w.col; const r = w.dir === 'v' ? w.row + i : w.row; this.wallDefs[`${c},${r}`] = w; } } // Which room each floor tile belongs to (for per-room wallpaper) this.tileRoom = this._buildRoomMap(); this.objects = this._buildObjects(); this.hoveredTile = null; this._pendingInteract = null; this._stars = null; this.lastTime = 0; this.running = false; this.animId = null; // Wallpaper UI state this._wpPanelOpen = false; this._wpSelected = null; // wallpaperId being previewed this._wpRoomTarget= null; // roomId to apply to (null = global) this._recenterCamera(); this._bindInput(); this._snapPlayerPos(); this._buildWallpaperPanel(); } // ─── Default layout ────────────────────────────────────────────────────── static defaultLayout() { return { name: 'Starbase Alpha-7', grid: { cols: 20, rows: 16 }, style: { wallColor: '#00d4ff', wallColorLeft: '#0c1626', wallColorRight: '#0a1220', wallColorTop: '#1a2840', floorColorEven: '#151c2e', floorColorOdd: '#111827', doorColor: '#00ffcc', doorFrameColor: '#00d4ff' }, walls: [ { col:0, row:0, span:20, dir:'h' }, { col:0, row:15, span:20, dir:'h' }, { col:0, row:0, span:16, dir:'v' }, { col:19, row:0, span:16, dir:'v' }, ], doors: [], rooms: [], playerStart: { col:10, row:2 } }; } // ─── Map builder ───────────────────────────────────────────────────────── _buildMapFromLayout() { const C = this.COLS, R = this.ROWS; const map = Array.from({ length: R }, () => Array(C).fill(TILE.FLOOR)); for (const w of (this.layout.walls || [])) { for (let i = 0; i < (w.span || 1); i++) { const c = w.dir === 'h' ? w.col + i : w.col; const r = w.dir === 'v' ? w.row + i : w.row; if (r >= 0 && r < R && c >= 0 && c < C) map[r][c] = TILE.WALL; } } for (const d of (this.layout.doors || [])) { if (d.row >= 0 && d.row < R && d.col >= 0 && d.col < C) map[d.row][d.col] = TILE.DOOR; } // Seal locked rooms for (const room of (this.layout.rooms || [])) { if (!room.unlock) continue; const owned = this.sbData.unlockedRooms || []; if (owned.includes(room.id)) continue; // player has unlocked it const b = room.bounds; for (let r = b.row; r < b.row + b.rows; r++) { for (let c = b.col; c < b.col + b.cols; c++) { if (r >= 0 && r < R && c >= 0 && c < C && map[r][c] === TILE.FLOOR) map[r][c] = TILE.LOCKED; } } } return map; } _buildRoomMap() { const C = this.COLS, R = this.ROWS; const rm = Array.from({ length: R }, () => Array(C).fill(null)); for (const room of (this.layout.rooms || [])) { const b = room.bounds; for (let r = b.row; r < b.row + b.rows; r++) for (let c = b.col; c < b.col + b.cols; c++) if (r >= 0 && r < R && c >= 0 && c < C) rm[r][c] = room.id; } return rm; } // ─── Style resolver ─────────────────────────────────────────────────────── /** * Returns the effective style for a given tile position. * Priority: per-room wallpaper → global wallpaper → layout.style defaults */ _styleFor(col, row) { const base = this.layout.style || {}; const globalWpId = this.sbData.wallpaper; const roomId = this.tileRoom[row]?.[col]; const roomWpId = roomId && this.sbData.roomWallpapers?.[roomId]; const activeWpId = this._wpSelected || roomWpId || globalWpId; if (!activeWpId) return base; const wp = this.catalog.find(i => i.id === activeWpId && i.subtype === 'wallpaper'); if (!wp?.preview) return base; // Merge: preview overrides base, but let per-wall-segment defs still win later return { ...base, ...wp.preview }; } // ─── Interactive objects ────────────────────────────────────────────────── _buildObjects() { const sc = c => Math.min(c, this.COLS - 2); const sr = r => Math.min(r, this.ROWS - 2); const mk = (id, col, row, icon, label, color, glow, action) => ({ id, col: sc(col), row: sr(row), icon, label, color, glowColor: glow, action }); const modal = (title, icon, desc, color, btn) => () => this._showInteractModal(title, icon, desc, color, btn ? [btn] : []); return [ mk('shop', 3, 2, '🛒','Trade Post', '#00d4ff','rgba(0,212,255,.6)', modal('Trade Post','🛒','Access the galactic marketplace to buy ships, weapons, armour, wallpapers, and room unlocks.','#00d4ff',{label:'Open Shop',action:'shop'})), mk('crafting', 21, 2, '⚗️','Forge', '#ff8800','rgba(255,136,0,.6)', modal('Forge','⚗️','Smelt ores, combine alloys, build circuits and hull sections.','#ff8800',{label:'Open Crafting',action:'crafting'})), mk('quests', 3, 16, '📋','Mission Board', '#ffdd00','rgba(255,221,0,.6)', modal('Mission Board','📋','Daily missions, weekly challenges, and story campaign.','#ffdd00',{label:'View Quests',action:'quests'})), mk('hangar', 21, 16, '🚀','Hangar', '#aa44ff','rgba(170,68,255,.6)', modal('Hangar','🚀','Equip your active ship and prepare for dungeon runs.','#aa44ff',{label:'View Ships',action:'ships'})), mk('skills', 12, 3, '🎓','Academy', '#00ff88','rgba(0,255,136,.6)', modal('Academy','🎓','Improve combat, science, and crafting proficiencies.','#00ff88',{label:'Train Skills',action:'skills'})), mk('terminal', 12, 10, '💻','Command Terminal', '#ff00ff','rgba(255,0,255,.6)', modal('Command Terminal','💻','Monitor stats, server status, and your empire.','#ff00ff',{label:'Base Overview',action:'base'})), mk('wallpaper',12, 17, '🎨','Wallpaper Desk', '#ffaa00','rgba(255,170,0,.6)', () => this._toggleWallpaperPanel()), ]; } // ─── Coordinate helpers ─────────────────────────────────────────────────── _isoProject(col, row) { return { x: this.originX + (col - row) * (this.TW / 2), y: this.originY + (col + row) * (this.TH / 2) }; } _screenToGrid(sx, sy) { const tx = sx - this.originX, ty = sy - this.originY; return { col: Math.floor((tx / (this.TW/2) + ty / (this.TH/2)) / 2), row: Math.floor((ty / (this.TH/2) - tx / (this.TW/2)) / 2) }; } _recenterCamera() { this.originX = this.canvas.width / 2; this.originY = this.canvas.height * 0.13; } _snapPlayerPos() { const iso = this._isoProject(this.player.col + 0.5, this.player.row + 0.5); this.player.x = iso.x; this.player.y = iso.y; } // ─── Pathfinding ────────────────────────────────────────────────────────── _walkable(col, row) { if (col < 0 || row < 0 || col >= this.COLS || row >= this.ROWS) return false; const t = this.tileMap[row][col]; return t === TILE.FLOOR || t === TILE.DOOR; } _findPath(sc, sr, tc, tr) { if (!this._walkable(tc, tr)) return []; const key = (c, r) => `${c},${r}`; const q = [{ c: sc, r: sr, path: [] }]; const vis = new Set([key(sc, sr)]); const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; while (q.length) { const { c, r, path } = q.shift(); if (c === tc && r === tr) return path; for (const [dc, dr] of dirs) { const nc = c+dc, nr = r+dr, k = key(nc, nr); if (!vis.has(k) && this._walkable(nc, nr)) { vis.add(k); q.push({ c: nc, r: nr, path: [...path, { col: nc, row: nr }] }); } } } return []; } _findAdjacentWalkable(col, row) { for (const [dc, dr] of [[0,-1],[0,1],[-1,0],[1,0]]) { const nc = col+dc, nr = row+dr; if (this._walkable(nc, nr)) return { col: nc, row: nr }; } return null; } // ─── Input ──────────────────────────────────────────────────────────────── _bindInput() { this.canvas.addEventListener('click', e => this._onClick(e)); this.canvas.addEventListener('mousemove', e => this._onMouseMove(e)); this.canvas.addEventListener('mouseleave',() => { this.hoveredTile = null; }); } _canvasPos(e) { const r = this.canvas.getBoundingClientRect(); return { x: (e.clientX - r.left) * (this.canvas.width / r.width), y: (e.clientY - r.top) * (this.canvas.height / r.height) }; } _onClick(e) { const { x, y } = this._canvasPos(e); const { col, row } = this._screenToGrid(x, y); if (col < 0 || row < 0 || col >= this.COLS || row >= this.ROWS) return; // Clicking a locked tile → show unlock info if (this.tileMap[row][col] === TILE.LOCKED) { const roomId = this.tileRoom[row]?.[col]; const room = this.layout.rooms?.find(rm => rm.id === roomId); if (room) this._showLockedRoomModal(room); return; } const obj = this.objects.find(o => o.col === col && o.row === row); if (obj) { const adj = this._findAdjacentWalkable(obj.col, obj.row); if (adj) { this.player.path = this._findPath(this.player.col, this.player.row, adj.col, adj.row); this.player.moving = this.player.path.length > 0; this._pendingInteract = obj; } else { obj.action(); } return; } this.player.path = this._findPath(this.player.col, this.player.row, col, row); this.player.moving = this.player.path.length > 0; this._pendingInteract = null; } _onMouseMove(e) { const { x, y } = this._canvasPos(e); const { col, row } = this._screenToGrid(x, y); this.hoveredTile = (col>=0&&row>=0&&col { if (!this.running) return; const dt = Math.min((ts - this.lastTime) / 1000, 0.1); this.lastTime = ts; this._update(dt); this._render(); this.animId = requestAnimationFrame(loop); }; this.animId = requestAnimationFrame(loop); } stop() { this.running = false; if (this.animId) cancelAnimationFrame(this.animId); } resize(w, h) { this.canvas.width = w; this.canvas.height = h; this._recenterCamera(); this._snapPlayerPos(); this._stars = null; } // ─── Update ─────────────────────────────────────────────────────────────── _update(dt) { this._updatePlayer(dt); this._updateDoors(dt); } _updatePlayer(dt) { const p = this.player; if (!p.moving || !p.path.length) return; const SPEED = 4.5; const next = p.path[0]; const tPos = this._isoProject(next.col + 0.5, next.row + 0.5); const dx = tPos.x - p.x, dy = tPos.y - p.y; const dist = Math.sqrt(dx*dx + dy*dy); const step = SPEED * this.TW * dt; const dC = next.col - p.col, dR = next.row - p.row; if (dC>0&&dR===0) p.dir='se'; else if (dC<0&&dR===0) p.dir='nw'; else if (dR>0&&dC===0) p.dir='sw'; else if (dR<0&&dC===0) p.dir='ne'; if (dist <= step) { p.x = tPos.x; p.y = tPos.y; p.col = next.col; p.row = next.row; p.path.shift(); if (!p.path.length) { p.moving = false; if (this._pendingInteract) { const obj = this._pendingInteract; this._pendingInteract = null; setTimeout(() => obj.action(), 100); } } } else { p.x += (dx/dist)*step; p.y += (dy/dist)*step; } p.frameTimer += dt; if (p.frameTimer > 0.15) { p.frame = (p.frame+1)%4; p.frameTimer = 0; } } _updateDoors(dt) { const p = this.player; const SPEED = 3.5; for (const key of Object.keys(this.doorStates)) { const [dc, dr] = key.split(',').map(Number); const state = this.doorStates[key]; state.open = (Math.abs(p.col - dc) + Math.abs(p.row - dr)) <= 1; const target = state.open ? 1 : 0; if (state.anim < target) state.anim = Math.min(state.anim + SPEED*dt, 1); else if (state.anim > target) state.anim = Math.max(state.anim - SPEED*dt, 0); } } // ─── Render ─────────────────────────────────────────────────────────────── _render() { const ctx = this.ctx; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); ctx.fillStyle = '#07091a'; ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); this._drawStarfield(); for (let r = 0; r < this.ROWS; r++) { for (let c = 0; c < this.COLS; c++) { const tile = this.tileMap[r][c]; const iso = this._isoProject(c, r); const hov = this.hoveredTile?.col===c && this.hoveredTile?.row===r; const st = this._styleFor(c, r); if (tile === TILE.FLOOR) this._drawFloorTile(iso.x, iso.y, c, r, hov, st); else if (tile === TILE.WALL) { this._drawFloorTile(iso.x, iso.y, c, r, false, st); this._drawWallTile(iso.x, iso.y, this.wallDefs[`${c},${r}`], st); } else if (tile === TILE.DOOR) { this._drawFloorTile(iso.x, iso.y, c, r, hov, st); this._drawDoorTile(iso.x, iso.y, c, r, this.doorDefs[`${c},${r}`], st); } else if (tile === TILE.LOCKED) this._drawLockedTile(iso.x, iso.y, c, r, hov); } } this._drawRoomLabels(); const sorted = [...this.objects].sort((a,b) => (a.col+a.row)-(b.col+b.row)); for (const obj of sorted) { const iso = this._isoProject(obj.col+0.5, obj.row+0.5); this._drawObject(obj, iso.x, iso.y); } this._drawPlayer(); this._drawHUD(); } // ─── Tile drawers ───────────────────────────────────────────────────────── _drawStarfield() { const ctx = this.ctx; if (!this._stars) { this._stars = []; const rng = mulberry32(0xdeadbeef); for (let i=0; i<120; i++) this._stars.push({ x: rng()*this.canvas.width, y: rng()*(this.canvas.height*0.25), r: rng()*1.5+0.3, a: rng()*0.6+0.2 }); } for (const s of this._stars) { ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, Math.PI*2); ctx.fillStyle = `rgba(180,210,255,${s.a})`; ctx.fill(); } } _drawFloorTile(x, y, col, row, hovered, st) { const ctx = this.ctx, tw = this.TW, th = this.TH; const even = (col+row)%2===0; const base = even ? (st.floorColorEven||'#151c2e') : (st.floorColorOdd||'#111827'); const fill = hovered ? this._lighten(base, 0.35) : base; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x+tw/2, y+th/2); ctx.lineTo(x, y+th); ctx.lineTo(x-tw/2, y+th/2); ctx.closePath(); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = hovered ? 'rgba(0,212,255,.5)' : 'rgba(0,212,255,.08)'; ctx.lineWidth = hovered ? 1.5 : 0.8; ctx.stroke(); } _drawWallTile(x, y, def, st) { const ctx = this.ctx, tw = this.TW, th = this.TH, h = th*1.8; const accent = (def?.color) || st.wallColor || '#00d4ff'; const left = (def?.colorLeft) || st.wallColorLeft || '#0c1626'; const right = (def?.colorRight) || st.wallColorRight || '#0a1220'; const top = (def?.colorTop) || st.wallColorTop || '#1a2840'; ctx.beginPath(); ctx.moveTo(x-tw/2, y+th/2 ); ctx.lineTo(x, y+th ); ctx.lineTo(x, y+th+h ); ctx.lineTo(x-tw/2, y+th/2+h); ctx.closePath(); ctx.fillStyle=left; ctx.fill(); ctx.strokeStyle=this._alpha(accent,.15); ctx.lineWidth=0.8; ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, y+th ); ctx.lineTo(x+tw/2, y+th/2 ); ctx.lineTo(x+tw/2, y+th/2+h); ctx.lineTo(x, y+th+h ); ctx.closePath(); ctx.fillStyle=right; ctx.fill(); ctx.strokeStyle=this._alpha(accent,.10); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, y ); ctx.lineTo(x+tw/2, y+th/2); ctx.lineTo(x, y+th ); ctx.lineTo(x-tw/2, y+th/2); ctx.closePath(); ctx.fillStyle=top; ctx.fill(); ctx.strokeStyle=this._alpha(accent,.35); ctx.lineWidth=1; ctx.stroke(); } _drawDoorTile(x, y, col, row, def, st) { const ctx = this.ctx, tw = this.TW, th = this.TH, h = th*1.8; const key = `${col},${row}`; const anim = (this.doorStates[key]||{anim:0}).anim; const fc = (def?.frameColor) || st.doorFrameColor || '#00d4ff'; const dc = (def?.color) || st.doorColor || '#00ffcc'; const fw = tw*0.12; const slide= anim*h*0.93; // Left pillar ctx.beginPath(); ctx.moveTo(x-tw/2, y+th/2 ); ctx.lineTo(x-tw/2+fw, y+th/2+fw*.5 ); ctx.lineTo(x-tw/2+fw, y+th/2+h+fw*.5); ctx.lineTo(x-tw/2, y+th/2+h ); ctx.closePath(); ctx.fillStyle=this._darken(fc,.25); ctx.fill(); ctx.strokeStyle=this._alpha(fc,.6); ctx.lineWidth=0.8; ctx.stroke(); // Right pillar ctx.beginPath(); ctx.moveTo(x+tw/2, y+th/2 ); ctx.lineTo(x+tw/2-fw, y+th/2+fw*.5 ); ctx.lineTo(x+tw/2-fw, y+th/2+h+fw*.5); ctx.lineTo(x+tw/2, y+th/2+h ); ctx.closePath(); ctx.fillStyle=this._darken(fc,.3); ctx.fill(); ctx.strokeStyle=this._alpha(fc,.5); ctx.stroke(); // Top lintel ctx.beginPath(); ctx.moveTo(x-tw/2+fw, y+th/2+fw*.5 ); ctx.lineTo(x+tw/2-fw, y+th/2+fw*.5 ); ctx.lineTo(x+tw/2-fw, y+th/2+fw*1.5); ctx.lineTo(x-tw/2+fw, y+th/2+fw*1.5); ctx.closePath(); ctx.fillStyle=this._darken(fc,.2); ctx.fill(); ctx.strokeStyle=this._alpha(fc,.6); ctx.stroke(); // Sliding panel const pTop = y+th/2+fw*1.5 - slide; const pBottom = y+th/2+h; const pw = tw - fw*2; if (pBottom - pTop > 1) { ctx.save(); ctx.beginPath(); ctx.rect(x-tw/2, y+th/2+fw*1.5-h, tw, h*2); ctx.clip(); ctx.beginPath(); ctx.moveTo(x-pw/2, pTop ); ctx.lineTo(x+pw/2, pTop -th*.25); ctx.lineTo(x+pw/2, pBottom-th*.25 ); ctx.lineTo(x-pw/2, pBottom ); ctx.closePath(); const pg = ctx.createLinearGradient(x, pTop, x, pBottom); pg.addColorStop(0, this._alpha(dc,.9)); pg.addColorStop(1, this._alpha(dc,.35)); ctx.fillStyle=pg; ctx.fill(); ctx.strokeStyle=this._alpha(dc,.85); ctx.lineWidth=1; ctx.stroke(); ctx.strokeStyle=this._alpha(dc,.18); ctx.lineWidth=0.5; for (let ly=pTop+5; ly0.05) { ctx.fillStyle=this._alpha(dc, anim*.7); ctx.beginPath(); ctx.ellipse(x, pTop, pw/2, 4, 0, 0, Math.PI*2); ctx.fill(); } ctx.restore(); } const pulse = anim<0.1 ? 0.25+Math.sin(performance.now()/400)*.12 : anim*.4; const fg = ctx.createRadialGradient(x, y+th, 0, x, y+th, 18); fg.addColorStop(0, this._alpha(dc, pulse)); fg.addColorStop(1,'transparent'); ctx.fillStyle=fg; ctx.beginPath(); ctx.ellipse(x, y+th, 18, 9, 0, 0, Math.PI*2); ctx.fill(); } _drawLockedTile(x, y, col, row, hovered) { const ctx = this.ctx, tw = this.TW, th = this.TH, h = th*1.8; const now = performance.now()/1000; const pulse = 0.3 + Math.sin(now*1.5)*0.1; // Sealed wall face — dark hatched const fill = hovered ? '#1a1a2a' : '#0e0e1a'; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x+tw/2, y+th/2); ctx.lineTo(x, y+th); ctx.lineTo(x-tw/2, y+th/2); ctx.closePath(); ctx.fillStyle=fill; ctx.fill(); // Hatch pattern ctx.save(); ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x+tw/2, y+th/2); ctx.lineTo(x, y+th); ctx.lineTo(x-tw/2, y+th/2); ctx.closePath(); ctx.clip(); ctx.strokeStyle=`rgba(80,80,120,${0.15+pulse*0.1})`; ctx.lineWidth=1; for (let i=-tw; i i.id === wpId); if (wp) { ctx.font='700 9px "Orbitron",monospace'; ctx.fillStyle=this._alpha(accent,.5); ctx.textAlign='right'; ctx.fillText(`🎨 ${wp.name}`, W-14, 36); } } } // ─── Wallpaper panel ────────────────────────────────────────────────────── _buildWallpaperPanel() { document.getElementById('sb-wp-panel')?.remove(); const wallpapers = this.catalog.filter(i => i.subtype === 'wallpaper'); const owned = this.sbData.ownedWallpapers || []; const rooms = (this.layout.rooms || []).filter(r => !r.unlock || (this.sbData.unlockedRooms||[]).includes(r.id)); const panel = document.createElement('div'); panel.id = 'sb-wp-panel'; panel.style.cssText = ` display:none; position:absolute; bottom:36px; left:0; right:0; background:linear-gradient(180deg,transparent,rgba(7,9,24,.96)); border-top:1px solid rgba(0,212,255,.25); padding:12px 16px 10px; z-index:500; font-family:'Orbitron',monospace; pointer-events:all; `; // Room selector row const roomTabs = rooms.map(r => `` ).join(''); // Wallpaper swatches const swatches = wallpapers.map(wp => { const isOwned = owned.includes(wp.id); const p = wp.preview || {}; const bg = p.wallColorTop || p.floorColorEven || '#1a2840'; const fg = p.doorColor || p.wallColor || '#00ffcc'; return `
${isOwned ? '' : '
🔒
'}
${wp.name}
`; }).join(''); panel.innerHTML = `
WALLPAPER Apply to: ${roomTabs}
${swatches}
Hover a swatch to preview
`; const container = this.canvas.parentElement; container.style.position = 'relative'; container.appendChild(panel); this._wpPanel = panel; } _toggleWallpaperPanel() { if (!this._wpPanel) this._buildWallpaperPanel(); this._wpPanelOpen = !this._wpPanelOpen; this._wpPanel.style.display = this._wpPanelOpen ? 'block' : 'none'; if (this._wpPanelOpen) { this._wpSelected = null; this._wpRoomTarget = null; } } _wpSetRoom(roomId) { this._wpRoomTarget = roomId; // Highlight active tab this._wpPanel.querySelectorAll('button[data-room]').forEach(b => { b.style.background = b.dataset.room === roomId ? 'rgba(0,212,255,.35)' : 'rgba(0,212,255,.12)'; }); } _wpPreview(wpId) { const owned = this.sbData.ownedWallpapers || []; if (!owned.includes(wpId)) return; this._wpSelected = wpId; const wp = this.catalog.find(i => i.id === wpId); const nameEl = document.getElementById('sb-wp-preview-name'); const applyBtn = document.getElementById('sb-wp-apply-btn'); if (nameEl && wp) nameEl.textContent = wp.name + ' — ' + (wp.description || ''); if (applyBtn) applyBtn.style.display = 'inline-block'; } _wpApply(wpId) { this._wpSelected = null; const socket = window.game?.socket || window.gameInitializer?.socket; if (!socket) { // Offline: apply locally only if (this._wpRoomTarget) { if (!this.sbData.roomWallpapers) this.sbData.roomWallpapers = {}; if (wpId) this.sbData.roomWallpapers[this._wpRoomTarget] = wpId; else delete this.sbData.roomWallpapers[this._wpRoomTarget]; } else { this.sbData.wallpaper = wpId; } return; } socket.emit('set_wallpaper', { wallpaperId: wpId, roomId: this._wpRoomTarget || null }); // Optimistic update if (this._wpRoomTarget) { if (!this.sbData.roomWallpapers) this.sbData.roomWallpapers = {}; if (wpId) this.sbData.roomWallpapers[this._wpRoomTarget] = wpId; else delete this.sbData.roomWallpapers[this._wpRoomTarget]; } else { this.sbData.wallpaper = wpId; } const applyBtn = document.getElementById('sb-wp-apply-btn'); if (applyBtn) applyBtn.style.display = 'none'; } // ─── Interaction modals ─────────────────────────────────────────────────── _showInteractModal(title, icon, desc, color, buttons) { document.getElementById('sb-interact-modal')?.remove(); const modal = document.createElement('div'); modal.id = 'sb-interact-modal'; modal.style.cssText = ` position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); background:linear-gradient(135deg,#0d1525,#111c30);border:2px solid ${color}; border-radius:12px;padding:24px 28px;min-width:280px;max-width:360px;z-index:1000; box-shadow:0 0 40px ${color}55,0 8px 32px rgba(0,0,0,.8); font-family:'Orbitron',monospace;color:#fff;animation:sbModalIn .2s ease;pointer-events:all; `; const btnHtml = buttons.map(b=>` `).join(''); modal.innerHTML=`
${icon}
${title}
STARBASE STATION
${desc}
${btnHtml}
`; this.canvas.parentElement.appendChild(modal); setTimeout(() => modal.remove(), 8000); } _showLockedRoomModal(room) { const unlock = this.catalog.find(i => i.id === room.unlock); const name = unlock?.name || room.unlock; const price = unlock ? `${unlock.price} ${unlock.currency}` : '?'; const rarity = unlock?.rarity || 'unknown'; const color = '#ff8844'; this._showInteractModal( `${room.label} — Locked`, '🔒', `This wing requires the ${name} (${rarity}) to unlock.

` + (unlock?.categories?.includes('shop') ? `Purchase for ${price} in the Trade Post, or earn it as a dungeon drop.` : `Earn this by clearing dungeons or completing quests.`), color, unlock?.categories?.includes('shop') ? [{ label: 'Visit Trade Post', action: 'shop' }] : [] ); } _handleModalAction(action) { const map={shop:'[data-tab="shop"]',crafting:'[data-tab="crafting"]', quests:'[data-tab="quests"]',skills:'[data-tab="skills"]',base:'[data-tab="base"]'}; if (map[action]) { const b=document.querySelector(map[action]); if(b){b.click();return;} } for (const b of document.querySelectorAll('[onclick]')) { if ((b.getAttribute('onclick')||'').toLowerCase().includes(action)) { b.click(); return; } } } // ─── Color utilities ────────────────────────────────────────────────────── _hexToRgb(hex) { hex=hex.replace('#',''); if (hex.length===3) hex=hex.split('').map(c=>c+c).join(''); return { r:parseInt(hex.slice(0,2),16), g:parseInt(hex.slice(2,4),16), b:parseInt(hex.slice(4,6),16) }; } _darken(hex, f) { const {r,g,b}=this._hexToRgb(hex); return `rgb(${Math.round(r*f)},${Math.round(g*f)},${Math.round(b*f)})`; } _lighten(hex, f) { const {r,g,b}=this._hexToRgb(hex); return `rgb(${Math.min(255,Math.round(r+(255-r)*f))},${Math.min(255,Math.round(g+(255-g)*f))},${Math.min(255,Math.round(b+(255-b)*f))})`; } _alpha(hex, a) { if (hex.startsWith('rgba')||hex.startsWith('rgb(')) return hex.replace(/[\d.]+\)$/,`${a})`); const {r,g,b}=this._hexToRgb(hex); return `rgba(${r},${g},${b},${a})`; } } // ─── JSON loader ────────────────────────────────────────────────────────────── async function loadStarbaseWorld(canvasId) { let layout; try { const resp = await fetch(`data/starbase-layout.json?v=${Date.now()}`); if (!resp.ok) throw new Error(resp.status); layout = await resp.json(); } catch(e) { console.warn('[STARBASE] Using default layout:', e.message); layout = StarbaseWorld.defaultLayout(); } // Fetch player starbase data from server let sbData = { wallpaper: null, ownedWallpapers: [], unlockedRooms: [] }; let catalog = []; const socket = window.game?.socket || window.gameInitializer?.socket; if (socket) { await new Promise(resolve => { const timer = setTimeout(resolve, 3000); socket.once('starbase_data', data => { clearTimeout(timer); if (data.success) { sbData = data.starbase; catalog = data.catalog || []; } resolve(); }); socket.emit('get_starbase_data'); }); } if (window.starbaseWorld) { window.starbaseWorld.stop(); window.starbaseWorld = null; } window.starbaseWorld = new StarbaseWorld(canvasId, layout, sbData, catalog); // Listen for server wallpaper confirmations if (socket) { socket.off('wallpaper_set'); socket.on('wallpaper_set', data => { if (data.success && window.starbaseWorld) { window.starbaseWorld.sbData = data.starbase; } }); } return window.starbaseWorld; } // ─── Helpers ────────────────────────────────────────────────────────────────── function mulberry32(a) { return function() { a|=0; a=a+0x6D2B79F5|0; let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; } (function(){ if (document.getElementById('sb-anim-style')) return; const s=document.createElement('style'); s.id='sb-anim-style'; s.textContent=` @keyframes sbModalIn { from{opacity:0;transform:translate(-50%,-46%)} to {opacity:1;transform:translate(-50%,-50%)} } `; document.head.appendChild(s); })();