513 lines
20 KiB
JavaScript
513 lines
20 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 { PRODUCTS, SECTORS, getProduct } from './data/products.js';
|
|
import { mountProductionFloor, updateProductionFloor, calculateMultiCost } from './ui/productionView.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();
|
|
}
|
|
|
|
const settingsBtn = document.getElementById('btn-open-settings');
|
|
if (settingsBtn) {
|
|
settingsBtn.onclick = () => this.toggleSettings();
|
|
}
|
|
|
|
// Auth Modal Close & Submit
|
|
const closeAuth = document.getElementById('btn-close-auth');
|
|
if (closeAuth) closeAuth.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();
|
|
}
|
|
|
|
handleAction(action) {
|
|
const state = this.sm.state;
|
|
|
|
switch (action.type) {
|
|
case 'SELECT_SECTOR':
|
|
state.selectedSector = action.sector;
|
|
this.mountActiveView();
|
|
break;
|
|
|
|
case 'SELECT_PRODUCT':
|
|
state.selectedProductId = action.productId;
|
|
this.mountInspectorPane();
|
|
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 '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 totalPayout = c.rewardCash * subsidyBonus;
|
|
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);
|
|
this.showToast(`Contract Fulfilled! Earned ${fmtCurrency(totalPayout)} + ${c.rewardRep} Rep!`, 'success');
|
|
this.sync.queueAction(action);
|
|
this.mountActiveView();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'TRIGGER_IPO': {
|
|
const confirmed = confirm('Confirm Corporate Initial Public Offering (IPO)?\n\nYour factories and cash will reset in exchange for permanent Venture Shares ($V-Shares) and corporate equity!');
|
|
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 count = (state.activeContracts || []).length;
|
|
elContractsBadge.textContent = count;
|
|
elContractsBadge.style.display = count > 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
if (tab === 'production') {
|
|
mountProductionFloor(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
|
|
*/
|
|
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 === '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);
|
|
}
|
|
|
|
openAuthModal() {
|
|
const modal = document.getElementById('auth-modal');
|
|
if (modal) modal.classList.add('open');
|
|
}
|
|
|
|
closeAuthModal() {
|
|
const modal = document.getElementById('auth-modal');
|
|
if (modal) modal.classList.remove('open');
|
|
}
|
|
|
|
async handleLogin() {
|
|
const u = document.getElementById('auth-username')?.value.trim();
|
|
const p = document.getElementById('auth-password')?.value;
|
|
if (!u || !p) return alert('Please enter username and password');
|
|
|
|
try {
|
|
await this.sync.login(u, p);
|
|
this.showToast(`Logged in as ${u}`, 'success');
|
|
this.closeAuthModal();
|
|
this.updateHud();
|
|
} catch (err) {
|
|
alert(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 alert('Please enter username and password');
|
|
|
|
try {
|
|
await this.sync.register(u, p, hp);
|
|
this.showToast(`Account created! Welcome ${u}`, 'success');
|
|
this.closeAuthModal();
|
|
this.updateHud();
|
|
} catch (err) {
|
|
alert(err.message || 'Registration failed');
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|