1002 lines
47 KiB
JavaScript
1002 lines
47 KiB
JavaScript
/**
|
||
* 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;
|
||
|
||
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();
|
||
|
||
this._drawIsoBox(cx, cy, 36, 36, 20, obj.color);
|
||
|
||
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);
|
||
}
|
||
|
||
_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">×</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);
|
||
})();
|