You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
290 lines
8.0 KiB
290 lines
8.0 KiB
/** |
|
* @fileoverview Main application orchestrator. |
|
* Initializes game state, wires up UI events, and coordinates modules. |
|
*/ |
|
|
|
import { init_game, set_price_per_cup, calculate_supply_cost, calculate_cost_per_cup, make_lemonade, set_weather, calculate_maximum_cups_available } from './game.js'; |
|
import { sprites, cups, render, whenSpritesReady, setActiveCustomer, getRandomCustomerKey } from './canvasController.js'; |
|
import { createReactiveState, updateBindings } from './binding.js'; |
|
|
|
// Initialize game state |
|
let gameState = createReactiveState(init_game()); |
|
updateBindings(gameState); |
|
|
|
// Weather icon element |
|
const weatherIcon = document.querySelector('.weather_icon'); |
|
|
|
function updateWeatherIcon() { |
|
if (weatherIcon) { |
|
weatherIcon.src = `${gameState.weather}.png`; |
|
weatherIcon.alt = gameState.weather; |
|
} |
|
} |
|
|
|
updateWeatherIcon(); |
|
|
|
let isAnimating = false; |
|
|
|
function generateSalesAttempts(maxCups, cupsSold) { |
|
const attempts = Array(maxCups).fill(false); |
|
for (let i = 0; i < cupsSold; i++) attempts[i] = true; |
|
// Fisher-Yates shuffle |
|
for (let i = attempts.length - 1; i > 0; i--) { |
|
const j = Math.floor(Math.random() * (i + 1)); |
|
[attempts[i], attempts[j]] = [attempts[j], attempts[i]]; |
|
} |
|
return attempts; |
|
} |
|
|
|
/** |
|
* Fades a customer in or out using requestAnimationFrame. |
|
* @param {string} spriteKey - Customer sprite key |
|
* @param {number} posIndex - Position index (0=left, 1=right) |
|
* @param {number} fromAlpha - Starting alpha |
|
* @param {number} toAlpha - Ending alpha |
|
* @param {number} duration - Duration in ms |
|
* @param {Function} onComplete - Callback when complete |
|
*/ |
|
function fadeCustomer(spriteKey, posIndex, fromAlpha, toAlpha, duration, onComplete) { |
|
const startTime = performance.now(); |
|
function step() { |
|
const elapsed = performance.now() - startTime; |
|
const progress = Math.min(elapsed / duration, 1); |
|
const alpha = fromAlpha + (toAlpha - fromAlpha) * progress; |
|
setActiveCustomer(spriteKey, posIndex, alpha); |
|
render(); |
|
if (progress < 1) { |
|
requestAnimationFrame(step); |
|
} else { |
|
onComplete(); |
|
} |
|
} |
|
step(); |
|
} |
|
|
|
function animateSales(attempts, onComplete) { |
|
let i = 0; |
|
let cupIndex = 0; |
|
let posIndex = 0; |
|
|
|
function nextAttempt() { |
|
if (i >= attempts.length) { |
|
setActiveCustomer(null, 0, 0); |
|
sprites.maker.frameIndex = 0; |
|
render(); |
|
onComplete(); |
|
return; |
|
} |
|
|
|
// Pick random customer, alternate position |
|
const customerKey = getRandomCustomerKey(); |
|
posIndex = (posIndex + 1) % 2; |
|
|
|
// Fade in customer |
|
fadeCustomer(customerKey, posIndex, 0, 1, 500, () => { |
|
if (attempts[i]) { |
|
// Buy: maker active, empty cup, wait, refill, maker idle, fade out |
|
sprites.maker.frameIndex = 1; |
|
cups[cupIndex].empty(); |
|
render(); |
|
setTimeout(() => { |
|
sprites.maker.frameIndex = 0; |
|
cups[cupIndex].fill(); |
|
render(); |
|
cupIndex = (cupIndex + 1) % cups.length; |
|
// Fade out customer |
|
fadeCustomer(customerKey, posIndex, 1, 0, 500, () => { |
|
i++; |
|
nextAttempt(); |
|
}); |
|
}, 600); |
|
} else { |
|
// Pass: brief pause then fade out |
|
setTimeout(() => { |
|
fadeCustomer(customerKey, posIndex, 1, 0, 500, () => { |
|
i++; |
|
nextAttempt(); |
|
}); |
|
}, 500); |
|
} |
|
}); |
|
} |
|
|
|
nextAttempt(); |
|
} |
|
|
|
function fillAllCups() { |
|
cups.forEach(cup => cup.fill()); |
|
render(); |
|
} |
|
|
|
function resetCups() { |
|
cups.forEach(cup => cup.empty()); |
|
render(); |
|
} |
|
|
|
// Wait for all sprites to load, then render once |
|
whenSpritesReady(() => { |
|
render(); |
|
resetCups(); // Start with empty cups |
|
}); |
|
|
|
// UI Elements |
|
const goShoppingBtn = document.querySelector('.go_shopping_btn'); |
|
const shoppingModal = document.querySelector('.shopping_modal'); |
|
const shoppingModalClose = document.querySelector('.shopping_modal_close'); |
|
|
|
const priceInlineInput = document.querySelector('.price_inline_input'); |
|
const startDayBtn = document.querySelector('.start_day_btn'); |
|
|
|
// Shopping modal - quantity inputs and dynamic pricing |
|
const shopQtyInputs = document.querySelectorAll('.shop_qty_input'); |
|
const shopBuyBtns = document.querySelectorAll('.shop_item_btn'); |
|
|
|
function updateShopPrice(item) { |
|
const input = document.querySelector(`.shop_qty_input[data-item="${item}"]`); |
|
const priceDisplay = document.querySelector(`.shop_item_price[data-price="${item}"]`); |
|
const qty = parseInt(input.value) || 0; |
|
const cost = calculate_supply_cost(item, qty); |
|
priceDisplay.textContent = '$' + cost.toFixed(2); |
|
} |
|
|
|
// Update prices when quantity changes |
|
shopQtyInputs.forEach(input => { |
|
input.addEventListener('input', () => { |
|
updateShopPrice(input.dataset.item); |
|
}); |
|
}); |
|
|
|
// Buy button handlers |
|
shopBuyBtns.forEach(btn => { |
|
btn.addEventListener('click', () => { |
|
const item = btn.dataset.item; |
|
const input = document.querySelector(`.shop_qty_input[data-item="${item}"]`); |
|
const qty = parseInt(input.value) || 0; |
|
const cost = calculate_supply_cost(item, qty); |
|
|
|
if (cost > gameState.player_money) { |
|
alert("Not enough money!"); |
|
return; |
|
} |
|
|
|
setState({ |
|
player_money: gameState.player_money - cost, |
|
supplies: { |
|
...gameState.supplies, |
|
[item]: gameState.supplies[item] + qty |
|
} |
|
}); |
|
}); |
|
}); |
|
|
|
// Event handlers |
|
if (goShoppingBtn) { |
|
goShoppingBtn.addEventListener('click', () => { |
|
// Update all prices when modal opens |
|
['lemons', 'sugar', 'ice', 'cups'].forEach(updateShopPrice); |
|
shoppingModal.classList.add('open'); |
|
}); |
|
|
|
shoppingModalClose.addEventListener('click', () => { |
|
shoppingModal.classList.remove('open'); |
|
}) |
|
} |
|
|
|
// Inline price editing |
|
if (priceInlineInput) { |
|
// Save price on blur or Enter |
|
function savePrice() { |
|
const value = parseFloat(priceInlineInput.value) || 0; |
|
const newState = set_price_per_cup(gameState, value); |
|
setState(newState); |
|
priceInlineInput.value = gameState.price_per_cup.toFixed(2); |
|
} |
|
|
|
priceInlineInput.addEventListener('blur', savePrice); |
|
priceInlineInput.addEventListener('keydown', (e) => { |
|
if (e.key === 'Enter') { |
|
priceInlineInput.blur(); |
|
} |
|
}); |
|
|
|
// Select all text on focus for easy editing |
|
priceInlineInput.addEventListener('focus', () => { |
|
priceInlineInput.select(); |
|
}); |
|
|
|
// Initialize with current value |
|
priceInlineInput.value = gameState.price_per_cup.toFixed(2); |
|
} |
|
|
|
// Start Day button |
|
if (startDayBtn) { |
|
startDayBtn.addEventListener('click', startDay); |
|
} |
|
|
|
// Recipe stepper buttons |
|
document.querySelectorAll('.stepper_btn').forEach(btn => { |
|
btn.addEventListener('click', () => { |
|
const ingredient = btn.dataset.ingredient; |
|
const isPlus = btn.classList.contains('stepper_plus'); |
|
const currentValue = gameState.recipe[ingredient]; |
|
const newValue = isPlus ? currentValue + 1 : Math.max(0, currentValue - 1); |
|
|
|
const newRecipe = { ...gameState.recipe, [ingredient]: newValue }; |
|
const result = calculate_cost_per_cup(gameState, newRecipe); |
|
setState({ |
|
recipe: newRecipe, |
|
cost_per_cup: result.cost_per_cup |
|
}); |
|
}); |
|
}); |
|
|
|
// Export for debugging in console |
|
window.gameState = gameState; |
|
window.sprites = sprites; |
|
window.cups = cups; |
|
|
|
function setState(newState) { |
|
Object.assign(gameState, newState); |
|
if (newState.weather) { |
|
updateWeatherIcon(); |
|
} |
|
} |
|
|
|
function startDay() { |
|
if (gameState.supplies.cups <= 0) { |
|
alert('You need cups to sell lemonade!'); |
|
return; |
|
} |
|
|
|
const { recipe } = gameState; |
|
if (recipe.lemons <= 0) { |
|
alert('Your recipe needs lemons!'); |
|
return; |
|
} |
|
|
|
if (isAnimating) return; |
|
isAnimating = true; |
|
startDayBtn.disabled = true; |
|
|
|
// Fill all cups at start |
|
fillAllCups(); |
|
|
|
const maxCups = calculate_maximum_cups_available(gameState.supplies, gameState.recipe); |
|
const result = make_lemonade(gameState); |
|
const cupsSold = result.cups_sold; |
|
|
|
const attempts = generateSalesAttempts(maxCups, cupsSold); |
|
|
|
animateSales(attempts, () => { |
|
setState(result); |
|
|
|
// Randomize weather for next day |
|
const newWeatherState = set_weather(gameState); |
|
setState({ weather: newWeatherState.weather }); |
|
|
|
isAnimating = false; |
|
startDayBtn.disabled = false; |
|
}); |
|
}
|
|
|