1048 lines
44 KiB
JavaScript
1048 lines
44 KiB
JavaScript
// Production - The Idle Game: Main Application Orchestrator
|
|
// Fine-grained DOM updates without full innerHTML teardowns
|
|
|
|
import { GState } from './core/state.js';
|
|
import { SimulationEngine } from './core/engine.js';
|
|
import { SyncManager } from './core/sync.js';
|
|
import { fmtCurrency, fmtNum, fmtRate, getNotation, setNotation } from './core/formatters.js';
|
|
import { PRODUCTS, SECTORS, getProduct } from './data/products.js';
|
|
import { mountProductionFloor, updateProductionFloor, calculateMultiCost } from './ui/productionView.js';
|
|
import { renderSectorTabsHtml } from './ui/production/sectorBar.js';
|
|
import { mountUnitDetailView, updateUnitDetailView } from './ui/unitDetailView.js';
|
|
import { mountContractsBoard, updateContractsBoard } from './ui/contractsView.js';
|
|
import { mountBoardroom, updateBoardroom } from './ui/boardroomView.js';
|
|
import { mountInspector, updateInspector } from './ui/inspectorView.js';
|
|
import { mountAchievements, updateAchievements } from './ui/achievementsView.js';
|
|
|
|
class ProductionApp {
|
|
constructor() {
|
|
this.sm = GState;
|
|
this.engine = new SimulationEngine(this.sm);
|
|
this.sync = new SyncManager(this.sm);
|
|
this.lastFrameTime = performance.now();
|
|
this.lastSaveTime = Date.now();
|
|
this.currentMountedTab = null;
|
|
this.currentMountedSector = null;
|
|
this.currentMountedProduct = null;
|
|
}
|
|
|
|
async init() {
|
|
// Check for offline progress
|
|
const now = Date.now();
|
|
const elapsedSeconds = (now - this.sm.state.lastTick) / 1000;
|
|
if (elapsedSeconds > 5) {
|
|
const result = this.engine.processOffline(elapsedSeconds);
|
|
if (result.earned > 0) {
|
|
this.showToast(`Executive Briefing: Your automated lines generated ${fmtCurrency(result.earned)} during ${Math.round(result.seconds / 60)}m away!`, 'success');
|
|
}
|
|
}
|
|
|
|
this.setupDomEvents();
|
|
|
|
// Initial Mount (builds DOM nodes ONCE)
|
|
this.mountActiveView();
|
|
this.mountInspectorPane();
|
|
this.updateHud();
|
|
|
|
// Check if logged in and sync cloud state
|
|
if (this.sync.isLoggedIn()) {
|
|
this.sync.fetchCloudState().then(cloud => {
|
|
if (cloud && cloud.lastTick > this.sm.state.lastTick) {
|
|
this.sm.state = { ...this.sm.state, ...cloud };
|
|
this.sm.save();
|
|
this.mountActiveView();
|
|
this.mountInspectorPane();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Main game loop (ticks at 60fps, fine-grained updates without innerHTML)
|
|
const loop = (currentTime) => {
|
|
const dt = Math.min((currentTime - this.lastFrameTime) / 1000, 0.5);
|
|
this.lastFrameTime = currentTime;
|
|
|
|
this.engine.tick(dt);
|
|
this.updateHud();
|
|
this.updateActiveView();
|
|
this.updateInspectorPane();
|
|
|
|
// Auto-save every 5 seconds
|
|
if (Date.now() - this.lastSaveTime > 5000) {
|
|
this.sm.save();
|
|
this.lastSaveTime = Date.now();
|
|
}
|
|
|
|
requestAnimationFrame(loop);
|
|
};
|
|
requestAnimationFrame(loop);
|
|
}
|
|
|
|
setupDomEvents() {
|
|
// Navigation Tabs (Desktop sidebar + Mobile bottom nav)
|
|
document.querySelectorAll('.nav-btn, .nav-tab').forEach(btn => {
|
|
btn.onclick = () => {
|
|
const tab = btn.dataset.tab;
|
|
if (!tab) return;
|
|
this.sm.state.activeTab = tab;
|
|
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
|
|
b.classList.toggle('active', b.dataset.tab === tab);
|
|
});
|
|
this.mountActiveView();
|
|
};
|
|
});
|
|
|
|
// Buy Multiplier Bar
|
|
document.querySelectorAll('.btn-multi-pill').forEach(btn => {
|
|
btn.onclick = () => {
|
|
const mode = btn.dataset.mode;
|
|
this.sm.state.buyMode = mode;
|
|
document.querySelectorAll('.btn-multi-pill').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
this.mountActiveView(); // Remount button labels for 1x/10x/100x
|
|
};
|
|
});
|
|
|
|
// Sidebar Sector Buttons
|
|
document.querySelectorAll('.sidebar-sector-btn').forEach(btn => {
|
|
btn.onclick = () => {
|
|
const sector = btn.dataset.sector;
|
|
this.sm.state.activeTab = 'production';
|
|
this.sm.state.selectedSector = sector;
|
|
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
|
|
b.classList.toggle('active', b.dataset.tab === 'production');
|
|
});
|
|
this.mountActiveView();
|
|
};
|
|
});
|
|
|
|
// User Profile & Settings
|
|
const userBadge = document.getElementById('user-badge');
|
|
if (userBadge) {
|
|
userBadge.onclick = () => this.openAuthModal();
|
|
}
|
|
|
|
// View Mode Toggle (Standard / Compact)
|
|
const viewModeBtn = document.getElementById('btn-toggle-view-mode');
|
|
if (viewModeBtn) {
|
|
viewModeBtn.onclick = () => this.toggleFloorViewMode();
|
|
}
|
|
this.updateViewModeButtonUi();
|
|
|
|
const settingsBtn = document.getElementById('btn-open-settings');
|
|
if (settingsBtn) {
|
|
settingsBtn.onclick = () => this.toggleSettings();
|
|
}
|
|
|
|
// Dark / Light Theme Toggle
|
|
this.initTheme();
|
|
const themeBtn = document.getElementById('btn-theme-toggle');
|
|
if (themeBtn) {
|
|
themeBtn.onclick = () => this.toggleTheme();
|
|
}
|
|
|
|
// Global Keyboard Shortcut: ESC returns to production floor if on unit_detail
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && this.sm.state.activeTab === 'unit_detail') {
|
|
this.handleAction({ type: 'CLOSE_UNIT_DETAIL' });
|
|
}
|
|
});
|
|
|
|
// Auth & Profile Modal Event Listeners
|
|
const closeAuth = document.getElementById('btn-close-auth');
|
|
if (closeAuth) closeAuth.onclick = () => this.closeAuthModal();
|
|
|
|
const closeAuthX = document.getElementById('btn-close-auth-x');
|
|
if (closeAuthX) closeAuthX.onclick = () => this.closeAuthModal();
|
|
|
|
const loginBtn = document.getElementById('btn-login-submit');
|
|
if (loginBtn) loginBtn.onclick = () => this.handleLogin();
|
|
|
|
const registerBtn = document.getElementById('btn-register-submit');
|
|
if (registerBtn) registerBtn.onclick = () => this.handleRegister();
|
|
|
|
const logoutBtn = document.getElementById('btn-logout-submit');
|
|
if (logoutBtn) logoutBtn.onclick = () => this.handleLogout();
|
|
|
|
const cloudSyncBtn = document.getElementById('btn-cloud-sync-now');
|
|
if (cloudSyncBtn) cloudSyncBtn.onclick = () => this.handleCloudSyncNow();
|
|
|
|
// Savegame Management: Export & Import
|
|
const exportFileBtn = document.getElementById('btn-export-file');
|
|
if (exportFileBtn) exportFileBtn.onclick = () => this.handleExportSaveFile();
|
|
|
|
const exportCopyBtn = document.getElementById('btn-export-clipboard');
|
|
if (exportCopyBtn) exportCopyBtn.onclick = () => this.handleExportSaveClipboard();
|
|
|
|
const importFileInput = document.getElementById('import-file-input');
|
|
if (importFileInput) importFileInput.onchange = (e) => this.handleImportSaveFile(e);
|
|
|
|
const importClipBtn = document.getElementById('btn-import-clipboard');
|
|
if (importClipBtn) importClipBtn.onclick = () => this.toggleImportTextBox();
|
|
|
|
const importApplyBtn = document.getElementById('btn-import-text-apply');
|
|
if (importApplyBtn) importApplyBtn.onclick = () => this.handleApplyImportText();
|
|
|
|
const importCancelBtn = document.getElementById('btn-import-text-cancel');
|
|
if (importCancelBtn) importCancelBtn.onclick = () => this.toggleImportTextBox(false);
|
|
}
|
|
|
|
initTheme() {
|
|
const savedTheme = localStorage.getItem('production_theme') ||
|
|
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
this.applyTheme(savedTheme);
|
|
}
|
|
|
|
toggleTheme() {
|
|
const current = document.documentElement.getAttribute('data-theme') || 'light';
|
|
const next = current === 'dark' ? 'light' : 'dark';
|
|
this.applyTheme(next);
|
|
this.showToast(`Theme switched to ${next.toUpperCase()} mode`, 'info');
|
|
}
|
|
|
|
applyTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
localStorage.setItem('production_theme', theme);
|
|
const iconEl = document.getElementById('theme-toggle-icon');
|
|
const textEl = document.getElementById('theme-toggle-text');
|
|
if (iconEl) {
|
|
iconEl.setAttribute('icon', theme === 'dark' ? 'lucide:sun' : 'lucide:moon');
|
|
}
|
|
if (textEl) {
|
|
textEl.textContent = theme === 'dark' ? 'Light' : 'Dark';
|
|
}
|
|
}
|
|
|
|
handleAction(action) {
|
|
const state = this.sm.state;
|
|
|
|
switch (action.type) {
|
|
case 'SELECT_SECTOR':
|
|
state.selectedSector = action.sector;
|
|
if (state.activeTab !== 'production') {
|
|
state.activeTab = 'production';
|
|
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
|
|
b.classList.toggle('active', b.dataset.tab === 'production');
|
|
});
|
|
}
|
|
this.mountActiveView();
|
|
break;
|
|
|
|
case 'OPEN_UNIT_DETAIL': {
|
|
state.selectedProductId = action.productId;
|
|
state.activeTab = 'unit_detail';
|
|
this.mountActiveView();
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
break;
|
|
}
|
|
|
|
case 'CLOSE_UNIT_DETAIL': {
|
|
state.activeTab = 'production';
|
|
this.mountActiveView();
|
|
requestAnimationFrame(() => {
|
|
const cardEl = document.getElementById('card-' + state.selectedProductId);
|
|
if (cardEl) {
|
|
cardEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'SELECT_PRODUCT': {
|
|
state.selectedProductId = action.productId;
|
|
const targetP = getProduct(action.productId);
|
|
|
|
// If user selected a dependency or unit from inspector, ensure we navigate / go to that facility card
|
|
let needsViewMount = false;
|
|
if (state.activeTab !== 'production') {
|
|
state.activeTab = 'production';
|
|
document.querySelectorAll('.nav-btn, .nav-tab').forEach(b => {
|
|
b.classList.toggle('active', b.dataset.tab === 'production');
|
|
});
|
|
needsViewMount = true;
|
|
}
|
|
|
|
if (targetP && state.selectedSector !== 'all' && state.selectedSector !== targetP.sector) {
|
|
state.selectedSector = targetP.sector;
|
|
needsViewMount = true;
|
|
}
|
|
|
|
if (needsViewMount) {
|
|
this.mountActiveView();
|
|
}
|
|
this.mountInspectorPane();
|
|
|
|
// Smooth scroll to the facility card and highlight it
|
|
requestAnimationFrame(() => {
|
|
const cardEl = document.getElementById('card-' + action.productId);
|
|
if (cardEl) {
|
|
cardEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
cardEl.classList.remove('card-highlight-pulse');
|
|
void cardEl.offsetWidth; // Force CSS reflow to re-trigger animation
|
|
cardEl.classList.add('card-highlight-pulse');
|
|
setTimeout(() => {
|
|
cardEl.classList.remove('card-highlight-pulse');
|
|
}, 1600);
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'UNLOCK_PRODUCT': {
|
|
const p = getProduct(action.productId);
|
|
if (p && state.cash >= p.unlockCost && !state.products[p.id]?.unlocked) {
|
|
state.cash -= p.unlockCost;
|
|
if (!state.products[p.id]) state.products[p.id] = {};
|
|
state.products[p.id].unlocked = true;
|
|
state.products[p.id].lines = 1;
|
|
state.selectedProductId = p.id;
|
|
this.showToast(`Industrial Permit Acquired: ${p.name}!`, 'success');
|
|
this.sync.queueAction(action);
|
|
this.mountActiveView();
|
|
this.mountInspectorPane();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'BUY_LINES': {
|
|
const p = getProduct(action.productId);
|
|
const pState = state.products[action.productId];
|
|
if (p && pState) {
|
|
const calc = calculateMultiCost(p.baseFacilityCost, pState.lines, state.buyMode);
|
|
if (state.cash >= calc.cost) {
|
|
state.cash -= calc.cost;
|
|
pState.lines += calc.count;
|
|
this.showToast(`Expanded ${p.facilityName}: +${calc.count} lines!`, 'info');
|
|
this.sync.queueAction(action);
|
|
this.mountActiveView();
|
|
this.mountInspectorPane();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'HIRE_MANAGER': {
|
|
const p = getProduct(action.productId);
|
|
const pState = state.products[action.productId];
|
|
if (p && pState && !pState.manager && state.cash >= p.baseManagerCost) {
|
|
state.cash -= p.baseManagerCost;
|
|
pState.manager = true;
|
|
state.stats.managersHired = (state.stats.managersHired || 0) + 1;
|
|
this.showToast(`Operations Lead Hired: ${p.managerName}!`, 'success');
|
|
this.sync.queueAction(action);
|
|
this.mountActiveView();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'BUY_LOGISTICS': {
|
|
const p = getProduct(action.productId);
|
|
const pState = state.products[action.productId];
|
|
if (p && pState) {
|
|
const cost = p.baseLogisticsCost * Math.pow(1.15, pState.logistics || 0);
|
|
if (state.cash >= cost) {
|
|
state.cash -= cost;
|
|
pState.logistics = (pState.logistics || 0) + 1;
|
|
this.showToast(`Logistics Fleet Expanded for ${p.name}!`, 'info');
|
|
this.sync.queueAction(action);
|
|
this.mountActiveView();
|
|
this.mountInspectorPane();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'TOGGLE_LOGISTICS': {
|
|
const p = getProduct(action.productId);
|
|
const pState = state.products[action.productId];
|
|
if (p && pState) {
|
|
const nextVal = pState.logisticsEnabled === false ? true : false;
|
|
pState.logisticsEnabled = nextVal;
|
|
this.showToast(`${p.name} Wholesale Fleet: ${nextVal ? 'ENABLED' : 'PAUSED'}`, nextVal ? 'success' : 'info');
|
|
this.sync.queueAction(action);
|
|
this.updateActiveView();
|
|
this.updateInspectorPane();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'TOGGLE_DOWNSTREAM_SUPPLY': {
|
|
const p = getProduct(action.productId);
|
|
const pState = state.products[action.productId];
|
|
if (p && pState) {
|
|
const nextVal = pState.supplyDownstreamEnabled === false ? true : false;
|
|
pState.supplyDownstreamEnabled = nextVal;
|
|
this.showToast(`${p.name} Downstream Supply: ${nextVal ? 'ALLOWED' : 'BLOCKED'}`, nextVal ? 'success' : 'warning');
|
|
this.sync.queueAction(action);
|
|
this.updateActiveView();
|
|
this.updateInspectorPane();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'SET_PRODUCTION_TARGET': {
|
|
const p = getProduct(action.productId);
|
|
const pState = state.products[action.productId];
|
|
if (p && pState) {
|
|
const target = Math.max(0, Math.min(1.0, action.target));
|
|
pState.productionTarget = target;
|
|
const pct = Math.round(target * 100);
|
|
this.showToast(`${p.name} Production Target set to ${pct}%`, 'info');
|
|
this.sync.queueAction(action);
|
|
this.updateActiveView();
|
|
this.updateInspectorPane();
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'MANUAL_CYCLE': {
|
|
const p = getProduct(action.productId);
|
|
const pState = state.products[action.productId];
|
|
if (p && pState) {
|
|
const isRaw = !p.inputs || p.inputs.length === 0;
|
|
let canRun = true;
|
|
if (!isRaw) {
|
|
canRun = p.inputs.every(inp => (state.products[inp.id]?.stock || 0) >= inp.amount);
|
|
}
|
|
|
|
if (canRun) {
|
|
if (!isRaw) {
|
|
p.inputs.forEach(inp => {
|
|
state.products[inp.id].stock -= inp.amount;
|
|
});
|
|
}
|
|
const yieldAmt = p.outputAmount * Math.max(1, pState.lines);
|
|
pState.stock += yieldAmt;
|
|
pState.totalProduced = (pState.totalProduced || 0) + yieldAmt;
|
|
state.stats.totalProduced = (state.stats.totalProduced || 0) + yieldAmt;
|
|
this.showToast(`Batch Produced: +${yieldAmt} ${p.name}!`, 'info');
|
|
} else {
|
|
this.showToast(`Insufficient inputs to cycle batch!`, 'warning');
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'FULFILL_CONTRACT': {
|
|
const idx = (state.activeContracts || []).findIndex(c => c.uid === action.uid);
|
|
if (idx !== -1) {
|
|
const c = state.activeContracts[idx];
|
|
const pState = state.products[c.productId];
|
|
if (pState && pState.stock >= c.targetAmount) {
|
|
pState.stock -= c.targetAmount;
|
|
const subsidyBonus = 1.0 + 0.35 * (state.boardroomPerks['government_subsidies'] || 0);
|
|
const b2bRep = state.repAllocations?.b2b || 0;
|
|
const b2bBonus = 1.0 + 0.005 * b2bRep; // +0.5% per point
|
|
const totalPayout = c.rewardCash * subsidyBonus * b2bBonus;
|
|
state.cash += totalPayout;
|
|
state.lifetimeRevenue += totalPayout;
|
|
state.reputation = (state.reputation || 0) + c.rewardRep;
|
|
state.stats.contractsCompleted = (state.stats.contractsCompleted || 0) + 1;
|
|
|
|
state.activeContracts.splice(idx, 1);
|
|
state.contractCooldown = 10.0; // 10 second cooldown before new tender RFP appears
|
|
|
|
// Recompute readyContractsCount
|
|
let readyCount = 0;
|
|
for (const actC of state.activeContracts) {
|
|
const ps = state.products[actC.productId];
|
|
if (ps && (ps.stock || 0) >= actC.targetAmount) readyCount++;
|
|
}
|
|
state.readyContractsCount = readyCount;
|
|
|
|
this.showToast(`Contract Fulfilled! Earned ${fmtCurrency(totalPayout)} + ${c.rewardRep} Rep!`, 'success');
|
|
this.sync.queueAction(action);
|
|
this.mountActiveView();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'ALLOCATE_REP': {
|
|
if (!state.repAllocations) {
|
|
state.repAllocations = { b2b: 0, clients: 0, brand: 0 };
|
|
}
|
|
const allocs = state.repAllocations;
|
|
const dept = action.department;
|
|
const delta = action.delta;
|
|
|
|
if (delta === 'reset') {
|
|
const freed = (allocs.b2b || 0) + (allocs.clients || 0) + (allocs.brand || 0);
|
|
state.reputation = (state.reputation || 0) + freed;
|
|
state.repAllocations = { b2b: 0, clients: 0, brand: 0 };
|
|
this.showToast(`All Corporate Rep Points Recalled to Reserve Pool!`, 'info');
|
|
this.sync.queueAction(action);
|
|
this.mountActiveView();
|
|
} else if (dept && allocs[dept] !== undefined) {
|
|
const deltaInt = parseInt(delta, 10);
|
|
if (deltaInt > 0) {
|
|
const toAlloc = Math.min(deltaInt, state.reputation || 0);
|
|
if (toAlloc > 0) {
|
|
state.reputation -= toAlloc;
|
|
allocs[dept] += toAlloc;
|
|
this.showToast(`Allocated +${toAlloc} Rep to ${dept.toUpperCase()}!`, 'success');
|
|
this.sync.queueAction(action);
|
|
this.mountActiveView();
|
|
}
|
|
} else if (deltaInt < 0) {
|
|
const toFree = Math.min(Math.abs(deltaInt), allocs[dept] || 0);
|
|
if (toFree > 0) {
|
|
allocs[dept] -= toFree;
|
|
state.reputation = (state.reputation || 0) + toFree;
|
|
this.showToast(`Recalled -${toFree} Rep from ${dept.toUpperCase()}!`, 'info');
|
|
this.sync.queueAction(action);
|
|
this.mountActiveView();
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'TRIGGER_IPO': {
|
|
this.showConfirmModal({
|
|
title: 'Authorize Initial Public Offering (IPO)',
|
|
subtitle: 'Corporate Equity Transition',
|
|
icon: 'lucide:trending-up',
|
|
text: 'Confirm Corporate Initial Public Offering (IPO)?<br><br>All factory floors and cash will be liquidated in exchange for permanent <strong>Venture Shares ($V-Shares)</strong> and corporate prestige bonuses!',
|
|
okText: 'Execute IPO',
|
|
cancelText: 'Abort'
|
|
}).then(confirmed => {
|
|
if (confirmed) {
|
|
const success = this.sm.performIPO();
|
|
if (success) {
|
|
this.showToast(`IPO Successful! Welcome to Wall Street, CEO!`, 'success');
|
|
this.mountActiveView();
|
|
this.mountInspectorPane();
|
|
}
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'BUY_PERK': {
|
|
const perk = (state.boardroomPerks || {})[action.perkId] || 0;
|
|
import('./data/boardroom.js').then(({ BOARDROOM_PERKS }) => {
|
|
const def = BOARDROOM_PERKS.find(p => p.id === action.perkId);
|
|
if (def && perk < def.maxLevel) {
|
|
const cost = def.costShares[perk];
|
|
if (state.ventureShares >= cost) {
|
|
state.ventureShares -= cost;
|
|
state.boardroomPerks[action.perkId] = perk + 1;
|
|
this.showToast(`Boardroom R&D Unlocked: ${def.name}!`, 'success');
|
|
this.mountActiveView();
|
|
}
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'LIQUIDATE_STOCK': {
|
|
const p = getProduct(action.productId);
|
|
const pState = state.products[action.productId];
|
|
if (p && pState && pState.stock > 0) {
|
|
const ratio = action.ratio || 1.0;
|
|
const sold = Math.floor(pState.stock * ratio);
|
|
if (sold > 0) {
|
|
pState.stock -= sold;
|
|
const earned = sold * p.basePrice;
|
|
state.cash += earned;
|
|
state.lifetimeRevenue += earned;
|
|
this.showToast(`Liquidated ${fmtNum(sold, 0)} ${p.name} for ${fmtCurrency(earned)}!`, 'info');
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
updateHud() {
|
|
const state = this.sm.state;
|
|
|
|
const elCash = document.getElementById('hud-cash');
|
|
if (elCash) {
|
|
const val = fmtNum(state.cash);
|
|
if (elCash.textContent !== val) elCash.textContent = val;
|
|
}
|
|
|
|
const elRate = document.getElementById('hud-rate');
|
|
if (elRate) {
|
|
const val = fmtRate(this.engine.cashRate).replace('+$', '');
|
|
if (elRate.textContent !== val) elRate.textContent = val;
|
|
}
|
|
|
|
const elValuation = document.getElementById('hud-valuation');
|
|
if (elValuation) {
|
|
const val = fmtCurrency(state.valuation);
|
|
if (elValuation.textContent !== val) elValuation.textContent = val;
|
|
}
|
|
|
|
const elShares = document.getElementById('hud-shares');
|
|
if (elShares) {
|
|
const val = fmtNum(state.ventureShares, 0);
|
|
if (elShares.textContent !== val) elShares.textContent = val;
|
|
}
|
|
|
|
const elContractsBadge = document.getElementById('contracts-badge');
|
|
if (elContractsBadge) {
|
|
const readyCount = state.readyContractsCount || 0;
|
|
const textVal = String(readyCount);
|
|
if (elContractsBadge.textContent !== textVal) {
|
|
elContractsBadge.textContent = textVal;
|
|
}
|
|
elContractsBadge.style.display = readyCount > 0 ? 'inline-flex' : 'none';
|
|
}
|
|
|
|
const userBadge = document.getElementById('user-badge');
|
|
if (userBadge) {
|
|
userBadge.textContent = this.sync.isLoggedIn() ? `[${this.sync.getUsername()}]` : `[GUEST]`;
|
|
}
|
|
|
|
// Update sidebar sector rate badges
|
|
if (this.engine.sectorStats) {
|
|
for (const s of SECTORS) {
|
|
const stats = this.engine.sectorStats[s.id];
|
|
if (stats) {
|
|
const elRate = document.getElementById(`sb-rate-${s.id}`);
|
|
if (elRate) {
|
|
const val = `+$${fmtNum(stats.revenueRate, 2)}/s`;
|
|
if (elRate.textContent !== val) elRate.textContent = val;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
mountHeaderSectorBar() {
|
|
const headerSectorContainer = document.getElementById('header-sector-container');
|
|
if (!headerSectorContainer) return;
|
|
const selectedSector = this.sm.state.selectedSector || 'all';
|
|
headerSectorContainer.innerHTML = renderSectorTabsHtml(selectedSector, this.engine);
|
|
headerSectorContainer.querySelectorAll('.sector-pill').forEach(btn => {
|
|
btn.onclick = () => this.handleAction({ type: 'SELECT_SECTOR', sector: btn.dataset.sector });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mounts the main viewport view ONCE when the tab or sector changes
|
|
*/
|
|
mountActiveView() {
|
|
const container = document.getElementById('main-viewport');
|
|
if (!container) return;
|
|
|
|
const tab = this.sm.state.activeTab || 'production';
|
|
const sector = this.sm.state.selectedSector || 'all';
|
|
this.currentMountedTab = tab;
|
|
this.currentMountedSector = sector;
|
|
|
|
// Keep header sector tabs in sync with selectedSector
|
|
this.mountHeaderSectorBar();
|
|
|
|
if (tab === 'production') {
|
|
mountProductionFloor(container, this.sm.state, (a) => this.handleAction(a), this.engine);
|
|
} else if (tab === 'unit_detail') {
|
|
mountUnitDetailView(container, this.sm.state, (a) => this.handleAction(a), this.engine);
|
|
} else if (tab === 'contracts') {
|
|
mountContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
|
|
} else if (tab === 'boardroom') {
|
|
mountBoardroom(container, this.sm.state, (a) => this.handleAction(a));
|
|
} else if (tab === 'milestones') {
|
|
mountAchievements(container, this.sm.state, (a) => this.handleAction(a));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mounts the inspector panel ONCE when selected product changes (legacy stub)
|
|
*/
|
|
mountInspectorPane() {
|
|
const inspectorContainer = document.getElementById('desktop-inspector-panel');
|
|
if (inspectorContainer) {
|
|
this.currentMountedProduct = this.sm.state.selectedProductId;
|
|
mountInspector(inspectorContainer, this.sm.state, (a) => this.handleAction(a));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes fine-grained in-place element updates without innerHTML teardowns
|
|
*/
|
|
updateActiveView() {
|
|
const container = document.getElementById('main-viewport');
|
|
if (!container) return;
|
|
|
|
const tab = this.sm.state.activeTab || 'production';
|
|
if (tab !== this.currentMountedTab) {
|
|
this.mountActiveView();
|
|
return;
|
|
}
|
|
|
|
if (tab === 'production') {
|
|
updateProductionFloor(container, this.sm.state, this.engine);
|
|
} else if (tab === 'unit_detail') {
|
|
updateUnitDetailView(container, this.sm.state, this.engine);
|
|
} else if (tab === 'contracts') {
|
|
updateContractsBoard(container, this.sm.state, (a) => this.handleAction(a));
|
|
} else if (tab === 'boardroom') {
|
|
updateBoardroom(container, this.sm.state);
|
|
} else if (tab === 'milestones') {
|
|
updateAchievements(container, this.sm.state);
|
|
}
|
|
}
|
|
|
|
updateInspectorPane() {
|
|
const inspectorContainer = document.getElementById('desktop-inspector-panel');
|
|
if (!inspectorContainer) return;
|
|
|
|
if (this.sm.state.selectedProductId !== this.currentMountedProduct) {
|
|
this.mountInspectorPane();
|
|
return;
|
|
}
|
|
|
|
updateInspector(inspectorContainer, this.sm.state);
|
|
}
|
|
|
|
showToast(message, type = 'info') {
|
|
const container = document.getElementById('toast-container');
|
|
if (!container) return;
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast-msg toast-${type}`;
|
|
toast.innerHTML = `
|
|
<iconify-icon icon="${type === 'success' ? 'lucide:check-circle' : type === 'warning' ? 'lucide:alert-circle' : 'lucide:info'}"></iconify-icon>
|
|
<span>${message}</span>
|
|
`;
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.classList.add('fade-out');
|
|
setTimeout(() => toast.remove(), 400);
|
|
}, 3200);
|
|
}
|
|
|
|
showConfirmModal({ title = 'Executive Authorization', subtitle = 'Board Confirmation', icon = 'lucide:alert-triangle', text = '', okText = 'Confirm', cancelText = 'Cancel' } = {}) {
|
|
return new Promise((resolve) => {
|
|
const modal = document.getElementById('confirm-modal');
|
|
const elTitle = document.getElementById('confirm-modal-title');
|
|
const elSub = document.getElementById('confirm-modal-subtitle');
|
|
const elIcon = document.getElementById('confirm-modal-icon');
|
|
const elText = document.getElementById('confirm-modal-text');
|
|
const btnOk = document.getElementById('btn-confirm-ok');
|
|
const btnCancel = document.getElementById('btn-confirm-cancel');
|
|
|
|
if (!modal) return resolve(false);
|
|
|
|
if (elTitle) elTitle.textContent = title;
|
|
if (elSub) elSub.textContent = subtitle;
|
|
if (elIcon) elIcon.setAttribute('icon', icon);
|
|
if (elText) elText.innerHTML = text;
|
|
if (btnOk) btnOk.textContent = okText;
|
|
if (btnCancel) btnCancel.textContent = cancelText;
|
|
|
|
const cleanup = () => {
|
|
modal.classList.remove('open');
|
|
btnOk.onclick = null;
|
|
btnCancel.onclick = null;
|
|
};
|
|
|
|
btnOk.onclick = () => {
|
|
cleanup();
|
|
resolve(true);
|
|
};
|
|
|
|
btnCancel.onclick = () => {
|
|
cleanup();
|
|
resolve(false);
|
|
};
|
|
|
|
modal.classList.add('open');
|
|
});
|
|
}
|
|
|
|
openAuthModal() {
|
|
const modal = document.getElementById('auth-modal');
|
|
const errBox = document.getElementById('auth-error-msg');
|
|
if (errBox) {
|
|
errBox.style.display = 'none';
|
|
errBox.textContent = '';
|
|
}
|
|
|
|
// Toggle guest vs user view
|
|
const isLoggedIn = this.sync.isLoggedIn();
|
|
const guestSec = document.getElementById('auth-guest-section');
|
|
const userSec = document.getElementById('auth-user-section');
|
|
const nameEl = document.getElementById('profile-display-name');
|
|
const badgeEl = document.getElementById('profile-badge-pill');
|
|
const descEl = document.getElementById('profile-sync-desc');
|
|
const iconEl = document.getElementById('profile-status-icon');
|
|
|
|
if (isLoggedIn) {
|
|
if (guestSec) guestSec.style.display = 'none';
|
|
if (userSec) userSec.style.display = 'block';
|
|
if (nameEl) nameEl.textContent = this.sync.getUsername();
|
|
if (badgeEl) {
|
|
badgeEl.textContent = 'Cloud Synced';
|
|
badgeEl.className = 'badge-pill-soft badge-online';
|
|
}
|
|
if (descEl) descEl.textContent = 'Account active. Enterprise data synchronizes automatically with the cloud server.';
|
|
if (iconEl) iconEl.setAttribute('icon', 'lucide:shield-check');
|
|
} else {
|
|
if (guestSec) guestSec.style.display = 'flex';
|
|
if (userSec) userSec.style.display = 'none';
|
|
if (nameEl) nameEl.textContent = 'Executive Guest';
|
|
if (badgeEl) {
|
|
badgeEl.textContent = 'Offline Guest';
|
|
badgeEl.className = 'badge-pill-soft';
|
|
}
|
|
if (descEl) descEl.textContent = 'Progress is saved locally in this browser. Register or login to sync across devices.';
|
|
if (iconEl) iconEl.setAttribute('icon', 'lucide:shield');
|
|
}
|
|
|
|
// Hide raw import box if open
|
|
const importBox = document.getElementById('import-text-box');
|
|
if (importBox) importBox.style.display = 'none';
|
|
|
|
if (modal) modal.classList.add('open');
|
|
}
|
|
|
|
closeAuthModal() {
|
|
const modal = document.getElementById('auth-modal');
|
|
if (modal) modal.classList.remove('open');
|
|
}
|
|
|
|
showAuthError(msg) {
|
|
const errBox = document.getElementById('auth-error-msg');
|
|
if (errBox) {
|
|
errBox.textContent = msg;
|
|
errBox.style.display = 'block';
|
|
} else {
|
|
this.showToast(msg, 'warning');
|
|
}
|
|
}
|
|
|
|
async handleLogin() {
|
|
const u = document.getElementById('auth-username')?.value.trim();
|
|
const p = document.getElementById('auth-password')?.value;
|
|
if (!u || !p) return this.showAuthError('Please enter username and password');
|
|
|
|
try {
|
|
await this.sync.login(u, p);
|
|
this.showToast(`Logged in as ${u}`, 'success');
|
|
|
|
// Check for cloud state upon login
|
|
const cloud = await this.sync.fetchCloudState();
|
|
if (cloud && cloud.lastTick > this.sm.state.lastTick) {
|
|
this.sm.importState(cloud);
|
|
this.showToast('Restored latest game state from cloud!', 'info');
|
|
} else {
|
|
// If local save is newer or first login, push local state to cloud
|
|
await this.sync.saveCloudState(this.sm.state);
|
|
}
|
|
|
|
this.closeAuthModal();
|
|
this.updateHud();
|
|
this.mountActiveView();
|
|
} catch (err) {
|
|
this.showAuthError(err.message || 'Login failed');
|
|
}
|
|
}
|
|
|
|
async handleRegister() {
|
|
const u = document.getElementById('auth-username')?.value.trim();
|
|
const p = document.getElementById('auth-password')?.value;
|
|
const hp = document.getElementById('auth-website-hp')?.value;
|
|
if (!u || !p) return this.showAuthError('Please enter username and password');
|
|
|
|
try {
|
|
await this.sync.register(u, p, hp);
|
|
this.showToast(`Account created! Welcome ${u}`, 'success');
|
|
// Back up current game state to new account
|
|
await this.sync.saveCloudState(this.sm.state);
|
|
this.closeAuthModal();
|
|
this.updateHud();
|
|
} catch (err) {
|
|
this.showAuthError(err.message || 'Registration failed');
|
|
}
|
|
}
|
|
|
|
handleLogout() {
|
|
this.sync.logout();
|
|
this.showToast('Logged out. Playing as Guest.', 'info');
|
|
this.openAuthModal(); // Refresh modal to show guest state
|
|
this.updateHud();
|
|
}
|
|
|
|
async handleCloudSyncNow() {
|
|
if (!this.sync.isLoggedIn()) {
|
|
return this.showToast('You must be logged in to sync to the cloud', 'warning');
|
|
}
|
|
try {
|
|
const ok = await this.sync.saveCloudState(this.sm.state);
|
|
if (ok) {
|
|
this.showToast('Empire successfully synced to cloud server!', 'success');
|
|
} else {
|
|
this.showToast('Cloud sync failed. Please try again.', 'warning');
|
|
}
|
|
} catch (err) {
|
|
this.showToast('Sync error: ' + err.message, 'warning');
|
|
}
|
|
}
|
|
|
|
// Savegame File Export
|
|
handleExportSaveFile() {
|
|
try {
|
|
const jsonStr = this.sm.exportState();
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
const fileName = `production_save_${timestamp}.json`;
|
|
const blob = new Blob([jsonStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = fileName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
this.showToast(`Savegame downloaded: ${fileName}`, 'success');
|
|
} catch (err) {
|
|
console.error('Export error:', err);
|
|
this.showToast('Failed to export save: ' + err.message, 'warning');
|
|
}
|
|
}
|
|
|
|
// Savegame Clipboard Copy
|
|
async handleExportSaveClipboard() {
|
|
try {
|
|
const jsonStr = this.sm.exportState();
|
|
await navigator.clipboard.writeText(jsonStr);
|
|
this.showToast('Savegame copied to clipboard!', 'success');
|
|
} catch (err) {
|
|
// Fallback for browsers with restricted clipboard
|
|
const promptOk = window.prompt('Copy your savegame JSON string below:', this.sm.exportState());
|
|
if (promptOk !== null) {
|
|
this.showToast('Savegame copied!', 'success');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Toggle Paste Save container
|
|
toggleImportTextBox(show = null) {
|
|
const box = document.getElementById('import-text-box');
|
|
const area = document.getElementById('import-raw-input');
|
|
if (!box) return;
|
|
const willShow = show !== null ? show : (box.style.display === 'none' || !box.style.display);
|
|
box.style.display = willShow ? 'flex' : 'none';
|
|
if (willShow && area) {
|
|
area.value = '';
|
|
area.focus();
|
|
}
|
|
}
|
|
|
|
// Import from uploaded file
|
|
handleImportSaveFile(e) {
|
|
const file = e.target.files && e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async (event) => {
|
|
const text = event.target.result;
|
|
await this.executeSaveImport(text, `file "${file.name}"`);
|
|
e.target.value = ''; // Reset input to allow selecting same file again
|
|
};
|
|
reader.onerror = () => {
|
|
this.showToast('Could not read the selected file.', 'warning');
|
|
e.target.value = '';
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
// Import from pasted text
|
|
async handleApplyImportText() {
|
|
const raw = document.getElementById('import-raw-input')?.value.trim();
|
|
if (!raw) {
|
|
return this.showToast('Please paste a savegame JSON string first.', 'warning');
|
|
}
|
|
await this.executeSaveImport(raw, 'clipboard text');
|
|
this.toggleImportTextBox(false);
|
|
}
|
|
|
|
// Common confirmation & restore executor
|
|
async executeSaveImport(rawJson, sourceLabel = 'input') {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(rawJson);
|
|
} catch (err) {
|
|
return this.showToast('Invalid JSON savegame: syntax error.', 'warning');
|
|
}
|
|
|
|
const compName = parsed.companyName || 'Unknown Enterprise';
|
|
const cashAmt = parsed.cash !== undefined ? fmtCurrency(parsed.cash) : '$0.00';
|
|
const ipoCount = parsed.ipoCount || 0;
|
|
|
|
const confirmed = await this.showConfirmModal({
|
|
title: 'Restore Savegame',
|
|
subtitle: 'Data Override Confirmation',
|
|
icon: 'lucide:download',
|
|
text: `Are you sure you want to load savegame from <strong>${sourceLabel}</strong>?<br><br>` +
|
|
`<strong>Company:</strong> ${compName}<br>` +
|
|
`<strong>Capital:</strong> ${cashAmt}<br>` +
|
|
`<strong>IPO Prestige:</strong> ${ipoCount} iterations<br><br>` +
|
|
`<span style="color: var(--amber-dark);">Warning: This will replace your current game state immediately!</span>`,
|
|
okText: 'Restore & Load',
|
|
cancelText: 'Cancel'
|
|
});
|
|
|
|
if (confirmed) {
|
|
const res = this.sm.importState(parsed);
|
|
if (res.success) {
|
|
// If logged in, also sync the newly restored state to the cloud
|
|
if (this.sync.isLoggedIn()) {
|
|
this.sync.saveCloudState(this.sm.state);
|
|
}
|
|
this.closeAuthModal();
|
|
this.mountActiveView();
|
|
this.mountInspectorPane();
|
|
this.updateHud();
|
|
this.showToast(`Savegame successfully restored: ${res.companyName}!`, 'success');
|
|
} else {
|
|
this.showToast(`Failed to restore save: ${res.error}`, 'warning');
|
|
}
|
|
}
|
|
}
|
|
|
|
toggleFloorViewMode() {
|
|
const current = this.sm.state.floorViewMode || 'standard';
|
|
const next = current === 'standard' ? 'compact' : 'standard';
|
|
this.sm.state.floorViewMode = next;
|
|
this.sm.save();
|
|
this.updateViewModeButtonUi();
|
|
this.showToast(`Overview Layout: ${next === 'compact' ? 'COMPACT' : 'STANDARD'}`, 'info');
|
|
if (this.sm.state.activeTab === 'production') {
|
|
this.mountActiveView();
|
|
}
|
|
}
|
|
|
|
updateViewModeButtonUi() {
|
|
const isCompact = this.sm.state.floorViewMode === 'compact';
|
|
const iconEl = document.getElementById('view-mode-icon');
|
|
const textEl = document.getElementById('view-mode-text');
|
|
if (iconEl) {
|
|
iconEl.setAttribute('icon', isCompact ? 'lucide:layout-list' : 'lucide:layout-grid');
|
|
}
|
|
if (textEl) {
|
|
textEl.textContent = isCompact ? 'Compact' : 'Standard';
|
|
}
|
|
}
|
|
|
|
toggleSettings() {
|
|
const current = getNotation();
|
|
const next = current === 'standard' ? 'scientific' : 'standard';
|
|
setNotation(next);
|
|
this.showToast(`Number Notation: ${next.toUpperCase()}`, 'info');
|
|
this.mountActiveView();
|
|
this.mountInspectorPane();
|
|
}
|
|
}
|
|
|
|
// Bootstrap
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
const app = new ProductionApp();
|
|
app.init();
|
|
window.App = app;
|
|
});
|