Game-Server/Client/js/systems/StarbaseWorld.js
2026-03-11 00:32:45 -03:00

1060 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<this.COLS&&row<this.ROWS) ? {col,row} : null;
}
// ─── Game loop ────────────────────────────────────────────────────────────
start() {
if (this.running) return;
this.running = true;
this.lastTime = performance.now();
const loop = ts => {
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; ly<pBottom; ly+=5) {
ctx.beginPath(); ctx.moveTo(x-pw/2, ly); ctx.lineTo(x+pw/2, ly-th*.25); ctx.stroke();
}
if (anim>0.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<tw*2; i+=8) {
ctx.beginPath(); ctx.moveTo(x-tw/2+i, y); ctx.lineTo(x-tw/2+i+tw, y+th); ctx.stroke();
}
ctx.restore();
// Lock icon in centre
ctx.font='14px serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillStyle=`rgba(100,100,160,${pulse})`;
ctx.fillText('🔒', x, y+th/2);
// Glowing border
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.strokeStyle=`rgba(80,80,200,${pulse})`; ctx.lineWidth=1.5; ctx.stroke();
}
_drawRoomLabels() {
if (!this.layout.rooms?.length) return;
const ctx = this.ctx;
for (const room of this.layout.rooms) {
const b = room.bounds;
const iso = this._isoProject(b.col + b.cols/2, b.row + b.rows/2 + 0.5);
const locked = room.unlock && !(this.sbData.unlockedRooms||[]).includes(room.id);
ctx.save();
ctx.font = '700 9px "Orbitron",monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
if (locked) {
ctx.fillStyle = 'rgba(80,80,160,.55)';
ctx.fillText('[ LOCKED ]', iso.x+1, iso.y+1);
ctx.fillStyle = 'rgba(120,120,220,.45)';
ctx.fillText('[ LOCKED ]', iso.x, iso.y);
} else {
ctx.fillStyle = 'rgba(0,0,0,.55)';
ctx.fillText(room.label.toUpperCase(), iso.x+1, iso.y+1);
ctx.fillStyle = 'rgba(180,210,255,.22)';
ctx.fillText(room.label.toUpperCase(), iso.x, iso.y);
}
ctx.restore();
}
}
// ─── Object drawing ───────────────────────────────────────────────────────
_drawObject(obj, cx, cy) {
const ctx = this.ctx, now = performance.now()/1000;
const bob = Math.sin(now*2+obj.col)*3;
const cy2 = cy - 28 - bob;
// ── Construction animation overlay ────────────────────────────────
const bldState = this._buildingStates?.[obj.id];
const isConstructing = bldState?.constructing;
const isUpgrading = bldState?.upgrading;
const justCompleted = bldState?.completedAt && (Date.now() - bldState.completedAt) < 3000;
const grad = ctx.createRadialGradient(cx, cy+this.TH/2, 0, cx, cy+this.TH/2, 30);
grad.addColorStop(0, obj.glowColor.replace('.6','.35'));
grad.addColorStop(1,'transparent');
ctx.fillStyle=grad;
ctx.beginPath(); ctx.ellipse(cx, cy+this.TH/2, 28, 14, 0, 0, Math.PI*2); ctx.fill();
// Dim box if under construction
if (isConstructing || isUpgrading) ctx.globalAlpha = 0.5;
this._drawIsoBox(cx, cy, 36, 36, 20, obj.color);
ctx.globalAlpha = 1;
ctx.font='22px serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(obj.icon, cx, cy2);
ctx.font='600 9px "Orbitron",monospace'; ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillStyle='rgba(0,0,0,.8)'; ctx.fillText(obj.label, cx+1, cy2+20);
ctx.fillStyle=obj.color; ctx.fillText(obj.label, cx, cy2+19);
// ── Spinning construction ring ─────────────────────────────────────
if (isConstructing || isUpgrading) {
const angle = (now * 2) % (Math.PI * 2);
const ringR = 22;
ctx.save();
ctx.strokeStyle = isUpgrading ? '#ffd700' : '#00ff88';
ctx.lineWidth = 2.5;
ctx.setLineDash([10, 6]);
ctx.lineDashOffset = -now * 20;
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.arc(cx, cy - 10, ringR, angle, angle + Math.PI * 1.4);
ctx.stroke();
// Small progress arc
const progress = bldState.progress || 0;
ctx.setLineDash([]);
ctx.strokeStyle = isUpgrading ? 'rgba(255,215,0,.2)' : 'rgba(0,255,136,.2)';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(cx, cy - 10, ringR, -Math.PI/2, -Math.PI/2 + Math.PI*2*progress);
ctx.stroke();
// Label
ctx.font = '700 8px "Orbitron",monospace';
ctx.fillStyle = isUpgrading ? '#ffd700' : '#00ff88';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(isUpgrading ? '↑ UPG' : '⚙ BLD', cx, cy - 10);
ctx.restore();
}
// ── Completion pulse flash ─────────────────────────────────────────
if (justCompleted) {
const age = (Date.now() - bldState.completedAt) / 3000;
const pulseAlpha = Math.max(0, 0.7 - age);
ctx.save();
ctx.globalAlpha = pulseAlpha;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(cx, cy - 10, 22 + age * 20, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
/** Call this from the game update loop to set building states for animation */
setBuildingStates(states) {
this._buildingStates = states || {};
}
_drawIsoBox(cx, cy, w, d, h, color) {
const ctx=this.ctx, hw=w/2, hd=d/4;
ctx.beginPath();
ctx.moveTo(cx, cy-h); ctx.lineTo(cx+hw, cy-h+hd); ctx.lineTo(cx, cy-h+hd*2); ctx.lineTo(cx-hw, cy-h+hd);
ctx.closePath(); ctx.fillStyle=color; ctx.globalAlpha=0.9; ctx.fill(); ctx.globalAlpha=1;
ctx.beginPath();
ctx.moveTo(cx-hw, cy-h+hd); ctx.lineTo(cx, cy-h+hd*2); ctx.lineTo(cx, cy+hd*2); ctx.lineTo(cx-hw, cy+hd);
ctx.closePath(); ctx.fillStyle=this._darken(color,.4); ctx.fill();
ctx.beginPath();
ctx.moveTo(cx+hw, cy-h+hd); ctx.lineTo(cx, cy-h+hd*2); ctx.lineTo(cx, cy+hd*2); ctx.lineTo(cx+hw, cy+hd);
ctx.closePath(); ctx.fillStyle=this._darken(color,.25); ctx.fill();
ctx.strokeStyle=color; ctx.lineWidth=0.8; ctx.globalAlpha=0.5;
ctx.beginPath();
ctx.moveTo(cx, cy-h); ctx.lineTo(cx+hw, cy-h+hd); ctx.lineTo(cx+hw, cy+hd);
ctx.lineTo(cx, cy+hd*2); ctx.lineTo(cx-hw, cy+hd); ctx.lineTo(cx-hw, cy-h+hd);
ctx.closePath(); ctx.stroke(); ctx.globalAlpha=1;
}
// ─── Player drawing ───────────────────────────────────────────────────────
_drawPlayer() {
const ctx=this.ctx, p=this.player, cx=p.x, cy=p.y, now=performance.now()/1000;
ctx.fillStyle='rgba(0,0,0,.4)';
ctx.beginPath(); ctx.ellipse(cx,cy+2,10,5,0,0,Math.PI*2); ctx.fill();
const bobY=p.moving?Math.sin(now*12)*2:0, bodyY=cy-28+bobY;
const legSw=p.moving?Math.sin(now*10)*5:0, armSw=p.moving?Math.sin(now*10)*6:0;
ctx.strokeStyle='#00d4ff'; ctx.lineWidth=3; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(cx-3,bodyY+18); ctx.lineTo(cx-3+legSw,bodyY+26); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx+3,bodyY+18); ctx.lineTo(cx+3-legSw,bodyY+26); ctx.stroke();
ctx.strokeStyle='#0099cc'; ctx.lineWidth=2.5;
ctx.beginPath(); ctx.moveTo(cx-7,bodyY+7); ctx.lineTo(cx-9-armSw,bodyY+14); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx+7,bodyY+7); ctx.lineTo(cx+9+armSw,bodyY+14); ctx.stroke();
const bg=ctx.createLinearGradient(cx-8,bodyY,cx+8,bodyY+18);
bg.addColorStop(0,'#1a6a8a'); bg.addColorStop(1,'#0a3a5a');
ctx.fillStyle=bg;
ctx.beginPath(); ctx.roundRect(cx-7,bodyY+3,14,16,3); ctx.fill();
ctx.strokeStyle='#00d4ff'; ctx.lineWidth=1; ctx.stroke();
ctx.strokeStyle='rgba(0,212,255,.6)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(cx,bodyY+5); ctx.lineTo(cx,bodyY+17); ctx.stroke();
const hg=ctx.createRadialGradient(cx-2,bodyY-3,1,cx,bodyY,9);
hg.addColorStop(0,'#2a8aaa'); hg.addColorStop(1,'#0a2540');
ctx.fillStyle=hg;
ctx.beginPath(); ctx.arc(cx,bodyY,9,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='#00d4ff'; ctx.lineWidth=1.5; ctx.stroke();
ctx.fillStyle='rgba(0,212,255,.35)';
ctx.beginPath(); ctx.ellipse(cx,bodyY+1,5,4,0,0,Math.PI*2); ctx.fill();
ctx.strokeStyle='rgba(0,212,255,.7)'; ctx.lineWidth=0.8; ctx.stroke();
ctx.fillStyle='rgba(255,255,255,.3)';
ctx.beginPath(); ctx.ellipse(cx-1.5,bodyY-.5,1.5,1,-.5,0,Math.PI*2); ctx.fill();
ctx.font='700 8px "Orbitron",monospace'; ctx.textAlign='center'; ctx.textBaseline='bottom';
ctx.fillStyle='rgba(0,0,0,.7)'; ctx.fillText(p.name,cx+1,bodyY-12);
ctx.fillStyle='#00d4ff'; ctx.fillText(p.name,cx, bodyY-13);
}
// ─── HUD ──────────────────────────────────────────────────────────────────
_drawHUD() {
const ctx=this.ctx, W=this.canvas.width, H=this.canvas.height;
const accent=this.layout.style?.wallColor||'#00d4ff';
ctx.fillStyle='rgba(7,9,18,.88)';
ctx.fillRect(0, H-32, W, 32);
ctx.font='10px "Orbitron",monospace';
ctx.fillStyle=this._alpha(accent,.65);
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('[ CLICK FLOOR TO WALK • CLICK STATION TO INTERACT • CLICK 🔒 TO UNLOCK ROOM ]', W/2, H-16);
ctx.font='700 11px "Orbitron",monospace'; ctx.fillStyle=accent;
ctx.textAlign='left'; ctx.fillText('◈ '+(this.layout.name||'STARBASE').toUpperCase(), 14, 20);
ctx.font='700 9px "Orbitron",monospace'; ctx.fillStyle=this._alpha(accent,.35);
ctx.textAlign='right'; ctx.fillText(`${this.COLS}×${this.ROWS}`, W-14, 20);
// Active wallpaper badge
const wpId = this.sbData.wallpaper;
if (wpId) {
const wp = this.catalog.find(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 =>
`<button data-room="${r.id}" onclick="starbaseWorld._wpSetRoom('${r.id}')"
style="background:rgba(0,212,255,.12);border:1px solid rgba(0,212,255,.3);
color:#00d4ff;padding:4px 10px;border-radius:4px;cursor:pointer;
font-family:'Orbitron',monospace;font-size:9px;margin-right:6px">
${r.label}
</button>`
).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 `
<div onclick="starbaseWorld._wpPreview('${wp.id}')"
title="${wp.name}${isOwned ? '' : ' (not owned)'}"
style="display:inline-flex;flex-direction:column;align-items:center;
gap:3px;cursor:${isOwned?'pointer':'default'};opacity:${isOwned?1:0.38};
margin-right:8px;vertical-align:top">
<div style="width:36px;height:36px;border-radius:4px;border:2px solid ${fg};
background:${bg};position:relative;overflow:hidden">
${isOwned ? '' : '<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:14px">🔒</div>'}
<div style="position:absolute;bottom:0;left:0;right:0;height:12px;
background:${p.wallColor||'#00d4ff'}33"></div>
</div>
<span style="font-size:7px;color:${isOwned?fg:'#555'};max-width:40px;
text-align:center;line-height:1.2">${wp.name}</span>
</div>`;
}).join('');
panel.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:10px;color:#00d4ff;font-weight:700">WALLPAPER</span>
<span style="font-size:9px;color:rgba(0,212,255,.5)">Apply to:</span>
<button onclick="starbaseWorld._wpSetRoom(null)"
style="background:rgba(0,212,255,.2);border:1px solid rgba(0,212,255,.5);
color:#00d4ff;padding:4px 10px;border-radius:4px;cursor:pointer;
font-family:'Orbitron',monospace;font-size:9px">All Rooms</button>
${roomTabs}
<button onclick="starbaseWorld._wpApply(null)"
style="margin-left:auto;background:rgba(255,80,80,.15);border:1px solid rgba(255,80,80,.4);
color:#ff6060;padding:4px 12px;border-radius:4px;cursor:pointer;
font-family:'Orbitron',monospace;font-size:9px">Reset</button>
<button onclick="starbaseWorld._toggleWallpaperPanel()"
style="background:none;border:none;color:rgba(255,255,255,.4);cursor:pointer;font-size:16px">✕</button>
</div>
<div style="overflow-x:auto;white-space:nowrap;padding-bottom:4px">${swatches}</div>
<div style="margin-top:8px;display:flex;align-items:center;gap:10px">
<span id="sb-wp-preview-name" style="font-size:9px;color:rgba(0,212,255,.6)">Hover a swatch to preview</span>
<button id="sb-wp-apply-btn" onclick="starbaseWorld._wpApply(starbaseWorld._wpSelected)"
style="display:none;background:rgba(0,212,255,.2);border:1px solid #00d4ff;
color:#00d4ff;padding:5px 16px;border-radius:5px;cursor:pointer;
font-family:'Orbitron',monospace;font-size:9px;font-weight:700">Apply</button>
</div>
`;
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=>`
<button onclick="starbaseWorld._handleModalAction('${b.action}');document.getElementById('sb-interact-modal').remove();"
style="background:${color}22;border:1px solid ${color};color:${color};
padding:8px 18px;border-radius:6px;cursor:pointer;
font-family:'Orbitron',monospace;font-size:10px;font-weight:700;transition:background .2s;"
onmouseover="this.style.background='${color}44'" onmouseout="this.style.background='${color}22'">${b.label}</button>
`).join('');
modal.innerHTML=`
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px">
<span style="font-size:2em">${icon}</span>
<div>
<div style="font-size:13px;font-weight:700;color:${color};letter-spacing:1px">${title}</div>
<div style="font-size:9px;color:rgba(255,255,255,.4);margin-top:2px">STARBASE STATION</div>
</div>
<button onclick="document.getElementById('sb-interact-modal').remove()"
style="margin-left:auto;background:none;border:none;color:rgba(255,255,255,.4);cursor:pointer;font-size:18px">&times;</button>
</div>
<div style="font-size:11px;color:rgba(184,197,214,.9);line-height:1.6;margin-bottom:18px">${desc}</div>
<div style="display:flex;flex-wrap:wrap;gap:8px">${btnHtml}</div>
`;
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 <strong style="color:${color}">${name}</strong> (${rarity}) to unlock.<br><br>` +
(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);
})();