(() => { "use strict"; const START_CASH = 25000; const PASS_START = 2000; const START_BONUS = 1000; const MAX_ROUNDS = 30; const BAIL = 1500; const MAX_HOUSES = 3; const BOARD_SIZE = 32; const GROUPS = { pink: { label: "草莓", color: "#ff7d9f", soft: "#ffe4eb" }, blue: { label: "海洋", color: "#5b96ff", soft: "#e5efff" }, green: { label: "森林", color: "#44c58a", soft: "#e0f9ed" }, purple: { label: "夢幻", color: "#ad7bff", soft: "#f0e7ff" }, station: { label: "車站", color: "#f3a33a", soft: "#fff0d8" }, utility: { label: "公用", color: "#2fc0b4", soft: "#ddfbf7" }, special: { label: "特殊", color: "#c8b28b", soft: "#f7efdf" } }; const PLAYER_META = [ { name: "阿富", avatar: "assets/avatar_afei.png", color: "#ff6a49", soft: "#fff0eb" }, { name: "小滿", avatar: "assets/avatar_xiaoman.png", color: "#4f7dff", soft: "#edf2ff" } ]; const TILES = [ { type: "start", name: "起點", icon: "🏁" }, { type: "property", group: "pink", name: "棉花糖巷", icon: "🍬", price: 800, baseRent: 160 }, { type: "chance", name: "機會", icon: "🎁" }, { type: "property", group: "pink", name: "草莓莊園", icon: "🍓", price: 1000, baseRent: 200 }, { type: "station", group: "station", name: "溫泉站", icon: "♨️", price: 1500 }, { type: "property", group: "pink", name: "櫻桃小屋", icon: "🍒", price: 1200, baseRent: 240 }, { type: "tax", name: "甜點稅", icon: "🍰", amount: 800 }, { type: "utility", group: "utility", name: "泡泡水廠", icon: "🫧", price: 1600 }, { type: "jail", name: "監獄", icon: "🚨" }, { type: "property", group: "blue", name: "書店街", icon: "📚", price: 1400, baseRent: 280 }, { type: "fate", name: "命運", icon: "🔮" }, { type: "property", group: "blue", name: "藍莓工坊", icon: "🫐", price: 1600, baseRent: 320 }, { type: "station", group: "station", name: "風車站", icon: "🌬️", price: 1800 }, { type: "property", group: "blue", name: "圖書館", icon: "🏛️", price: 1800, baseRent: 360 }, { type: "charity", name: "公益捐款", icon: "💝", amount: 600 }, { type: "bank", name: "大富翁銀行", icon: "🏦", amount: 1200 }, { type: "free", name: "免費停車", icon: "🅿️" }, { type: "property", group: "green", name: "向日葵田", icon: "🌻", price: 2000, baseRent: 400 }, { type: "chance", name: "機會", icon: "🎁" }, { type: "property", group: "green", name: "森林小屋", icon: "🌲", price: 2200, baseRent: 440 }, { type: "station", group: "station", name: "彩虹站", icon: "🌈", price: 2200 }, { type: "property", group: "green", name: "蘋果園", icon: "🍎", price: 2400, baseRent: 480 }, { type: "tax", name: "豪華稅", icon: "💎", amount: 1200 }, { type: "chance", name: "機會", icon: "🎁" }, { type: "gojail", name: "前往監獄", icon: "🚔" }, { type: "property", group: "purple", name: "櫻花町", icon: "🌸", price: 2600, baseRent: 520 }, { type: "utility", group: "utility", name: "電力公司", icon: "⚡", price: 2400 }, { type: "property", group: "purple", name: "紫藤館", icon: "🏯", price: 2800, baseRent: 560 }, { type: "fate", name: "命運", icon: "🔮" }, { type: "property", group: "purple", name: "星空城堡", icon: "🏰", price: 3000, baseRent: 600 }, { type: "station", group: "station", name: "雲朵機場", icon: "✈️", price: 2800 }, { type: "chance", name: "機會", icon: "🎁" } ]; TILES.forEach((tile, index) => { tile.id = index; }); const CHANCE_CARDS = [ { title: "中獎啦!", text: "彩券中了二獎,獲得 $2,000。", effectLabel: "+ $2,000", effect: { type: "gain", amount: 2000 } }, { title: "小鎮紅利", text: "銀行發放小鎮紅利 $1,200。", effectLabel: "+ $1,200", effect: { type: "gain", amount: 1200 } }, { title: "免費停車", text: "直接前往免費停車場,把彩池帶回家。", effectLabel: "前往免費停車", effect: { type: "moveTo", amount: 16 } }, { title: "搭上順風車", text: "一路順風,前進 3 格。", effectLabel: "前進 3 格", effect: { type: "moveSteps", amount: 3 } }, { title: "回到起點", text: "小鎮觀光巴士帶你回到起點,順便領取 $2,000。", effectLabel: "回到起點", effect: { type: "moveTo", amount: 0 } }, { title: "前進車站", text: "搭上下一班車,前往最近的車站。", effectLabel: "前往最近車站", effect: { type: "nearestStation" } }, { title: "監獄風雲", text: "不小心走錯路,直接前往監獄。", effectLabel: "前往監獄", effect: { type: "jail" } }, { title: "免獄卡", text: "獲得一張免獄卡,下次入獄時自動使用。", effectLabel: "獲得免獄卡", effect: { type: "freeJail" } }, { title: "房屋維修", text: "小鎮房屋大檢查,每棟房屋支付 $300 維修費。", effectLabel: "每棟房屋 -$300", effect: { type: "repair", amount: 300 } }, { title: "對手請客", text: "對手請你喝一杯小鎮特調,獲得 $500。", effectLabel: "+ $500", effect: { type: "gainFromOpponent", amount: 500 } } ]; const FATE_CARDS = [ { title: "維修費", text: "小屋屋頂漏水,支付維修費 $800。", effectLabel: "- $800", effect: { type: "payPot", amount: 800 } }, { title: "請客", text: "請對手吃甜點,支付 $600。", effectLabel: "- $600", effect: { type: "payOpponent", amount: 600 } }, { title: "前進 3 格", text: "命運指引你向前,前進 3 格。", effectLabel: "前進 3 格", effect: { type: "moveSteps", amount: 3 } }, { title: "後退 3 格", text: "走錯方向了,後退 3 格。", effectLabel: "後退 3 格", effect: { type: "moveSteps", amount: -3 } }, { title: "銀行利息", text: "存款利息入帳,獲得 $1,500。", effectLabel: "+ $1,500", effect: { type: "gain", amount: 1500 } }, { title: "手機壞了", text: "手機掉進水溝,支付 $500 修理費。", effectLabel: "- $500", effect: { type: "payPot", amount: 500 } }, { title: "房屋稅", text: "每棟房屋繳交 $200 房屋稅。", effectLabel: "每棟房屋 -$200", effect: { type: "repair", amount: 200 } }, { title: "免獄卡", text: "獲得一張免獄卡,下次入獄時自動使用。", effectLabel: "獲得免獄卡", effect: { type: "freeJail" } }, { title: "旅遊券", text: "抽中旅遊券,獲得 $1,000。", effectLabel: "+ $1,000", effect: { type: "gain", amount: 1000 } }, { title: "罰單", text: "亂丟垃圾被開罰單,支付 $700。", effectLabel: "- $700", effect: { type: "payPot", amount: 700 } } ]; const PIP_MAP = { 1: [4], 2: [0, 8], 3: [0, 4, 8], 4: [0, 2, 6, 8], 5: [0, 2, 4, 6, 8], 6: [0, 2, 3, 5, 6, 8] }; const els = { board: document.getElementById("board"), boardCenter: document.getElementById("boardCenter"), boardScroll: document.querySelector(".board-scroll"), tilesLayer: document.getElementById("tilesLayer"), rollBtn: document.getElementById("rollBtn"), die1: document.getElementById("die1"), die2: document.getElementById("die2"), diceSum: document.getElementById("diceSum"), centerAvatar: document.getElementById("centerAvatar"), centerName: document.getElementById("centerName"), turnTag: document.getElementById("turnTag"), centerHint: document.getElementById("centerHint"), roundNum: document.getElementById("roundNum"), potValue: document.getElementById("potValue"), actionPanel: document.getElementById("actionPanel"), centerNews: document.getElementById("centerNews"), newsText: document.getElementById("newsText"), currentAvatar: document.getElementById("currentAvatar"), currentRing: document.getElementById("currentRing"), currentName: document.getElementById("currentName"), currentMoney: document.getElementById("currentMoney"), currentSub: document.getElementById("currentSub"), currentCash: document.getElementById("currentCash"), currentProps: document.getElementById("currentProps"), currentCards: document.getElementById("currentCards"), playersRow: document.getElementById("playersRow"), autoBtn: document.getElementById("autoBtn"), autoIcon: document.getElementById("autoIcon"), autoTitle: document.getElementById("autoTitle"), autoSub: document.getElementById("autoSub"), speedSelect: document.getElementById("speedSelect"), soundBtn: document.getElementById("soundBtn"), rulesBtn: document.getElementById("rulesBtn"), restartBtn: document.getElementById("restartBtn"), clearLogBtn: document.getElementById("clearLogBtn"), logList: document.getElementById("logList"), toast: document.getElementById("toast"), modal: document.getElementById("modal"), modalBody: document.getElementById("modalBody"), modalClose: document.getElementById("modalClose") }; const state = { players: [], tileEls: [], playerCards: [], current: 0, round: 1, phase: "idle", auto: false, speed: 2, busy: false, over: false, pot: 0, dice: [1, 1], rolled: false, pending: null, pendingAutoTimer: null, autoTimer: null, modalOpen: false, modalResolve: null, modalTimer: null, modalAutoCloseable: false, modalAutoCloseDelay: 0, shownCurrent: -1, gameOverShown: false, sound: false, toastTimer: null }; let audioCtx = null; function fmt(value) { return Math.round(Number(value) || 0).toLocaleString("en-US"); } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(28, Math.round(ms / state.speed)))); } function randDie() { return 1 + Math.floor(Math.random() * 6); } function currentPlayer() { return state.players[state.current]; } function otherPlayer(player) { return state.players[player.id === 0 ? 1 : 0]; } function makePlayers() { return PLAYER_META.map((meta, id) => ({ id, name: meta.name, avatar: meta.avatar, color: meta.color, soft: meta.soft, money: START_CASH, pos: 0, props: [], houses: {}, jailTurns: 0, cards: [] })); } function ownerOf(tileId) { for (const player of state.players) { if (player.props.includes(tileId)) return player.id; } return null; } function ownsFullGroup(player, group) { if (!["pink", "blue", "green", "purple"].includes(group)) return false; return TILES.filter((tile) => tile.group === group).every((tile) => player.props.includes(TILES.indexOf(tile))); } function upgradeCost(tile) { return Math.round((tile.price || 0) * 0.6); } function propertyRent(tile, owner, level) { const full = ownsFullGroup(owner, tile.group); let rent = (tile.baseRent || 0) * (full ? 2 : 1); if (level > 0) rent = Math.round(rent * (1 + level * 1.15)); return Math.round(rent); } function calcRent(tile, owner, diceSum) { if (tile.type === "station") { const count = owner.props.filter((id) => TILES[id].type === "station").length; return [0, 400, 900, 1600, 2500][count] || 0; } if (tile.type === "utility") { const count = owner.props.filter((id) => TILES[id].type === "utility").length; return diceSum * (count >= 2 ? 200 : 80); } return propertyRent(tile, owner, owner.houses[tile.id] || 0); } function netWorth(player) { return player.money + player.props.reduce((sum, id) => { const tile = TILES[id]; return sum + (tile.price || 0) + (player.houses[id] || 0) * upgradeCost(tile); }, 0); } function tilePriceText(tile) { if (tile.price) return `$${fmt(tile.price)}`; switch (tile.type) { case "start": return "+$2,000"; case "tax": return `-$${fmt(tile.amount)}`; case "charity": return `-$${fmt(tile.amount)}`; case "bank": return `+$${fmt(tile.amount)}`; case "free": return "彩池"; case "jail": return "探監"; case "gojail": return "進監獄"; case "chance": case "fate": return "抽卡"; default: return ""; } } function tileTooltip(tile) { let text = tile.name; if (tile.price) text += ` · 售價 $${fmt(tile.price)}`; if (tile.baseRent) text += ` · 基本租金 $${fmt(tile.baseRent)}`; if (tile.type === "station") text += " · 租金依車站數量"; if (tile.type === "utility") text += " · 租金 = 骰子點數 × 倍率"; if (tile.amount) text += ` · ${tile.type === "bank" ? "領取" : "支付"} $${fmt(tile.amount)}`; return text; } function tileGridPosition(index) { if (index === 0) return [9, 9]; if (index < 8) return [9, 9 - index]; if (index === 8) return [9, 1]; if (index < 16) return [9 - (index - 8), 1]; if (index === 16) return [1, 1]; if (index < 24) return [1, 1 + (index - 16)]; if (index === 24) return [1, 9]; return [1 + (index - 24), 9]; } function buildPlayerCards() { els.playersRow.innerHTML = ""; state.playerCards = PLAYER_META.map((meta, index) => { const card = document.createElement("article"); card.className = "player-card"; card.dataset.playerCard = String(index); card.style.setProperty("--pcolor", meta.color); card.style.setProperty("--psoft", meta.soft); card.innerHTML = `
${description}
${autoNote} `; } else if (pending.kind === "upgrade") { const level = player.houses[tile.id] || 0; const nextLevel = level + 1; const cost = upgradeCost(tile); const currentRent = propertyRent(tile, player, level); const nextRent = propertyRent(tile, player, nextLevel); html = `目前租金 $${fmt(currentRent)},升級後 $${fmt(nextRent)}。升級費 $${fmt(cost)}。
${autoNote} `; } else if (pending.kind === "jail") { html = `支付 $${fmt(BAIL)} 保釋金可以立刻出獄;也可以服刑一回合。
${autoNote} `; } els.actionPanel.innerHTML = html; els.actionPanel.classList.remove("is-hidden"); els.actionPanel.querySelectorAll("[data-choice]").forEach((button) => { button.addEventListener("click", () => { if (button.disabled) return; resolvePending(button.dataset.choice); }); }); } function addLog(text, type = "info") { const item = document.createElement("li"); item.className = `log-item log-${type}`; item.textContent = text; els.logList.prepend(item); if (type !== "turn") els.newsText.textContent = text; while (els.logList.children.length > 60) { els.logList.lastElementChild.remove(); } } function toast(text, type = "") { clearTimeout(state.toastTimer); els.toast.textContent = text; els.toast.className = `toast show${type ? ` is-${type}` : ""}`; state.toastTimer = setTimeout(() => { els.toast.classList.remove("show"); }, 2200); } function ensureAudio() { if (!state.sound) return null; if (!audioCtx) { const AudioCtor = window.AudioContext || window.webkitAudioContext; if (!AudioCtor) return null; try { audioCtx = new AudioCtor(); } catch (error) { void error; return null; } } if (audioCtx.state === "suspended") { audioCtx.resume().catch(() => {}); } return audioCtx; } function playTone(freq = 440, duration = 0.08, type = "sine", volume = 0.03) { const ctx = ensureAudio(); if (!ctx) return; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = type; osc.frequency.value = freq; gain.gain.setValueAtTime(volume, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + duration); } function playWin() { [523, 659, 784, 1047].forEach((freq, index) => { setTimeout(() => playTone(freq, 0.16, "triangle", 0.035), index * 120); }); } function updateSoundButton() { els.soundBtn.textContent = state.sound ? "🔊" : "🔇"; els.soundBtn.classList.toggle("is-on", state.sound); els.soundBtn.setAttribute("aria-pressed", String(state.sound)); els.soundBtn.setAttribute("aria-label", state.sound ? "關閉音效" : "開啟音效"); els.soundBtn.title = state.sound ? "關閉音效" : "開啟音效"; } function savePrefs() { try { localStorage.setItem("dafuweng-speed", String(state.speed)); localStorage.setItem("dafuweng-sound", state.sound ? "1" : "0"); } catch (error) { void error; } } function loadPrefs() { try { const savedSpeed = Number(localStorage.getItem("dafuweng-speed")); if ([1, 2, 3].includes(savedSpeed)) state.speed = savedSpeed; state.sound = localStorage.getItem("dafuweng-sound") === "1"; } catch (error) { void error; } els.speedSelect.value = String(state.speed); updateSoundButton(); } function clearAutoTimers() { clearTimeout(state.autoTimer); clearTimeout(state.pendingAutoTimer); clearTimeout(state.modalTimer); state.autoTimer = null; state.pendingAutoTimer = null; state.modalTimer = null; } function scheduleAutoRoll() { clearTimeout(state.autoTimer); state.autoTimer = null; if (!state.auto || state.over || state.modalOpen || state.phase !== "idle" || state.busy) return; state.autoTimer = setTimeout(() => { state.autoTimer = null; if (state.auto && !state.over && !state.modalOpen && state.phase === "idle" && !state.busy) { rollDice(); } }, Math.max(180, Math.round(620 / state.speed))); } function maybeAuto() { if (!state.auto || state.over) return; if (state.pending) schedulePendingAuto(); if (state.phase === "idle") scheduleAutoRoll(); if (state.modalOpen && state.modalAutoCloseable) scheduleModalAutoClose(state.modalAutoCloseDelay); } function toggleAuto(force) { if (state.over) return; const next = typeof force === "boolean" ? force : !state.auto; if (next === state.auto) return; state.auto = next; if (!state.auto) clearAutoTimers(); renderUI(); renderActionPanel(); if (state.auto) { playTone(660, 0.08, "sine"); maybeAuto(); } else { playTone(320, 0.07, "sine"); } } function schedulePendingAuto() { clearTimeout(state.pendingAutoTimer); state.pendingAutoTimer = null; if (!state.auto || !state.pending || state.over) return; state.pendingAutoTimer = setTimeout(() => { state.pendingAutoTimer = null; if (!state.auto || !state.pending || state.over) return; resolvePending(autoChoice(state.pending)); }, Math.max(220, Math.round(700 / state.speed))); } function autoChoice(pending) { const player = state.players[pending.playerId]; const tile = TILES[pending.tileId]; if (pending.kind === "buy") { return pending.canBuy && player.money - tile.price >= 3500 ? "buy" : "pass"; } if (pending.kind === "upgrade") { return pending.canUpgrade && player.money - upgradeCost(tile) >= 5000 ? "upgrade" : "skip"; } if (pending.kind === "jail") { return pending.canBail && player.money >= 5000 ? "bail" : "serve"; } return "pass"; } function resolvePending(choice) { const pending = state.pending; if (!pending) return; state.pending = null; clearTimeout(state.pendingAutoTimer); state.pendingAutoTimer = null; state.phase = "resolving"; renderActionPanel(); renderUI(); pending.resolve(choice); } function promptPurchase(player, tile) { return new Promise((resolve) => { state.pending = { kind: "buy", playerId: player.id, tileId: tile.id, canBuy: player.money >= tile.price, resolve }; state.phase = "action"; renderActionPanel(); renderUI(); if (state.auto) schedulePendingAuto(); }); } function promptUpgrade(player, tile) { return new Promise((resolve) => { state.pending = { kind: "upgrade", playerId: player.id, tileId: tile.id, canUpgrade: player.money >= upgradeCost(tile), resolve }; state.phase = "action"; renderActionPanel(); renderUI(); if (state.auto) schedulePendingAuto(); }); } function promptJail(player) { return new Promise((resolve) => { state.pending = { kind: "jail", playerId: player.id, tileId: player.pos, canBail: player.money >= BAIL, resolve }; state.phase = "jail"; renderActionPanel(); renderUI(); if (state.auto) schedulePendingAuto(); }); } function animateDice(die1, die2) { els.die1.classList.add("rolling"); els.die2.classList.add("rolling"); const interval = setInterval(() => { setDieValue(els.die1, randDie()); setDieValue(els.die2, randDie()); }, 62); return wait(760).then(() => { clearInterval(interval); els.die1.classList.remove("rolling"); els.die2.classList.remove("rolling"); setDieValue(els.die1, die1); setDieValue(els.die2, die2); }); } async function rollDice() { if (state.busy || state.phase !== "idle" || state.over) return; const player = currentPlayer(); state.busy = true; state.phase = "rolling"; const die1 = randDie(); const die2 = randDie(); state.dice = [die1, die2]; state.rolled = true; renderUI(); playTone(540, 0.07, "triangle"); await animateDice(die1, die2); if (state.over) { state.busy = false; return; } addLog(`${player.name} 擲出 ${die1} + ${die2} = ${die1 + die2} 點。`, "dice"); if (die1 === die2) { addLog("雙骰!本回合結束後可以再擲一次。", "bonus"); toast("雙骰!再來一次", "gold"); playTone(880, 0.1, "sine"); } state.phase = "moving"; renderUI(); await moveSteps(die1 + die2, true); if (state.over) { state.busy = false; renderUI(); return; } await resolveLanding(0); if (state.over) { state.busy = false; renderUI(); return; } await finishTurn(true); } async function moveSteps(steps, collectStart = true) { const player = currentPlayer(); const direction = steps >= 0 ? 1 : -1; const count = Math.abs(steps); for (let i = 0; i < count; i += 1) { player.pos = (player.pos + direction + BOARD_SIZE) % BOARD_SIZE; if (direction > 0 && collectStart && player.pos === 0) { player.money += PASS_START; addLog(`${player.name} 經過起點,領取 $${fmt(PASS_START)}。`, "bonus"); toast(`${player.name} 經過起點 +$${fmt(PASS_START)}`, "gold"); playTone(920, 0.08, "sine"); } renderBoard(); renderUI(); await wait(95); } } async function moveTo(target, collectStart = true) { const player = currentPlayer(); if (player.pos === target) return; const steps = (target - player.pos + BOARD_SIZE) % BOARD_SIZE; await moveSteps(steps, collectStart); } function findNextStation(pos) { for (let step = 1; step <= BOARD_SIZE; step += 1) { const index = (pos + step) % BOARD_SIZE; if (TILES[index].type === "station") return index; } return 0; } async function resolveLanding(depth = 0) { if (state.over) return; const player = currentPlayer(); const tile = TILES[player.pos]; state.phase = "resolving"; renderUI(); addLog(`${player.name} 停在「${tile.name}」。`, "move"); await wait(220); if (state.over) return; if (tile.type === "start") { player.money += START_BONUS; addLog(`${player.name} 正好停在起點,額外領取 $${fmt(START_BONUS)}。`, "bonus"); toast(`停在起點 +$${fmt(START_BONUS)}`, "gold"); playTone(880, 0.1, "sine"); return; } if (tile.type === "jail") { addLog(`${player.name} 只是來監獄探監,平安無事。`, "info"); return; } if (tile.type === "free") { if (state.pot > 0) { const amount = state.pot; player.money += amount; state.pot = 0; addLog(`${player.name} 在免費停車場拿走彩池 $${fmt(amount)}!`, "bonus"); toast(`免費停車 +$${fmt(amount)}`, "gold"); playTone(760, 0.14, "sine"); } else { player.money += 500; addLog(`${player.name} 在免費停車場休息,領取小鎮補助 $500。`, "bonus"); toast("免費停車 +$500", "gold"); } return; } if (tile.type === "gojail") { await sendToJail(player); return; } if (tile.type === "tax" || tile.type === "charity") { await payToPot(player, tile.amount, tile.name); return; } if (tile.type === "bank") { player.money += tile.amount; addLog(`${player.name} 到大富翁銀行領取紅利 $${fmt(tile.amount)}。`, "bonus"); toast(`銀行紅利 +$${fmt(tile.amount)}`, "gold"); playTone(780, 0.1, "sine"); return; } if (tile.type === "chance" || tile.type === "fate") { if (depth > 2) { addLog("這次卡片沒有再連鎖,直接結束結算。", "info"); return; } await drawCard(tile.type, depth); return; } if (tile.type === "property" || tile.type === "station" || tile.type === "utility") { await handleProperty(player, tile); } } async function handleProperty(player, tile) { const ownerId = ownerOf(tile.id); if (ownerId === null) { if (state.auto) { await wait(450); if (state.over) return; const shouldBuy = player.money >= tile.price && player.money - tile.price >= 3500; if (shouldBuy) { buyProperty(player, tile); } else { addLog(`${player.name} 覺得「${tile.name}」太貴,決定先不買。`, "info"); toast(`${player.name} 放棄購買`, ""); } return; } const choice = await promptPurchase(player, tile); if (state.over) return; if (choice === "buy") { buyProperty(player, tile); } else { addLog(`${player.name} 放棄購買「${tile.name}」。`, "info"); } return; } if (ownerId === player.id) { const level = player.houses[tile.id] || 0; const canUpgrade = tile.type === "property" && ownsFullGroup(player, tile.group) && level < MAX_HOUSES && player.money >= upgradeCost(tile); if (canUpgrade) { if (state.auto) { await wait(350); if (state.over) return; if (player.money - upgradeCost(tile) >= 5000) { upgradeProperty(player, tile); } else { addLog(`${player.name} 保留現金,這次不升級「${tile.name}」。`, "info"); } return; } const choice = await promptUpgrade(player, tile); if (state.over) return; if (choice === "upgrade") { upgradeProperty(player, tile); } else { addLog(`${player.name} 這次不升級「${tile.name}」。`, "info"); } return; } addLog(`${player.name} 回到自己的「${tile.name}」,休息一下。`, "info"); await wait(300); return; } const owner = state.players[ownerId]; const rent = calcRent(tile, owner, state.dice[0] + state.dice[1]); addLog(`${player.name} 踩到 ${owner.name} 的「${tile.name}」,需付租金 $${fmt(rent)}。`, "rent"); await wait(350); if (state.over) return; const paid = pay(player, owner, rent); if (paid) { toast(`${player.name} 支付租金 $${fmt(rent)}`, "bad"); playTone(240, 0.12, "sawtooth", 0.025); } } function buyProperty(player, tile) { if (player.money < tile.price) { addLog(`${player.name} 現金不足,無法買下「${tile.name}」。`, "danger"); return; } player.money -= tile.price; player.props.push(tile.id); player.houses[tile.id] = 0; addLog(`${player.name} 買下「${tile.name}」!`, "buy"); toast(`${player.name} 買下 ${tile.name}`, "good"); playTone(690, 0.09, "sine"); if (tile.type === "property" && ownsFullGroup(player, tile.group)) { addLog(`${player.name} 收集完整「${GROUPS[tile.group].label}」色組,租金 ×2!`, "bonus"); toast(`${GROUPS[tile.group].label}色組完成!租金 ×2`, "gold"); } renderBoard(); renderUI(); } function upgradeProperty(player, tile) { const cost = upgradeCost(tile); if (player.money < cost) { addLog(`${player.name} 現金不足,無法升級「${tile.name}」。`, "danger"); return; } player.money -= cost; const level = (player.houses[tile.id] || 0) + 1; player.houses[tile.id] = level; addLog(`${player.name} 把「${tile.name}」升級到 ${level} 級房屋,租金提升!`, "buy"); toast(`${tile.name} 升級到 ${level} 級`, "good"); playTone(820, 0.1, "triangle"); renderBoard(); renderUI(); } function pay(from, to, amount) { const total = Math.max(0, Math.round(amount)); const paid = Math.min(from.money, total); from.money -= paid; if (to === "pot") { state.pot += paid; } else if (to && to !== from) { to.money += paid; } const ok = paid >= total; if (!ok) bankrupt(from); return ok; } async function payToPot(player, amount, label) { const ok = pay(player, "pot", amount); if (ok) { addLog(`${player.name} 支付「${label}」$${fmt(amount)},彩池增加到 $${fmt(state.pot)}。`, "rent"); toast(`${label} -$${fmt(amount)}`, "bad"); playTone(260, 0.1, "sawtooth", 0.022); renderUI(); } return ok; } async function sendToJail(player) { if (player.cards.includes("freeJail")) { player.cards = player.cards.filter((card) => card !== "freeJail"); addLog(`${player.name} 使用免獄卡,免去牢獄之災!`, "bonus"); toast("免獄卡發動!", "gold"); playTone(920, 0.12, "sine"); renderUI(); return; } player.pos = 8; player.jailTurns = 1; addLog(`${player.name} 被送進監獄,下一回合暫停。`, "jail"); toast(`${player.name} 被關進監獄`, "bad"); playTone(180, 0.16, "sawtooth", 0.025); renderBoard(); renderUI(); await wait(500); } async function drawCard(kind, depth) { const player = currentPlayer(); const deck = kind === "chance" ? CHANCE_CARDS : FATE_CARDS; const card = deck[Math.floor(Math.random() * deck.length)]; state.phase = "card"; renderUI(); addLog(`${player.name} 抽到${kind === "chance" ? "機會" : "命運"}卡:「${card.title}」`, "card"); playTone(740, 0.09, "triangle"); await showModal({ kicker: kind === "chance" ? "機會 CHANCE" : "命運 FATE", title: card.title, image: kind === "chance" ? "assets/chance_chest.png" : "assets/fate_cat.png", html: `${card.text}
${reason}
圖像由 Z-Image-Turbo OpenVINO INT4 生成,統一 Q 版黏土玩具風格,尺寸 256×256。
`; showModal({ kicker: "HOW TO PLAY", title: "Q版小鎮玩法", html, actions: [{ label: "開始玩", value: "ok", primary: true }], dismissible: true, className: "rules-modal" }).then(() => { if (wasAuto && !state.over) { state.auto = true; renderUI(); maybeAuto(); } }); } function openRestart() { const wasAuto = state.auto; if (wasAuto) { state.auto = false; clearAutoTimers(); renderUI(); } showModal({ kicker: "NEW GAME", title: "重新開始?", html: "目前的小鎮進度會消失,確定要重新開始嗎?
", actions: [ { label: "繼續玩", value: "cancel" }, { label: "重新開始", value: "restart", primary: true } ], dismissible: true }).then((value) => { if (value === "restart") { window.location.reload(); } else if (wasAuto && !state.over) { state.auto = true; renderUI(); maybeAuto(); } }); } function bindEvents() { els.rollBtn.addEventListener("click", () => rollDice()); els.autoBtn.addEventListener("click", () => toggleAuto()); els.speedSelect.addEventListener("change", () => { const next = Number(els.speedSelect.value); state.speed = [1, 2, 3].includes(next) ? next : 2; savePrefs(); renderUI(); if (state.auto) maybeAuto(); }); els.soundBtn.addEventListener("click", () => { state.sound = !state.sound; updateSoundButton(); savePrefs(); if (state.sound) { ensureAudio(); playTone(660, 0.08, "sine"); } }); els.rulesBtn.addEventListener("click", openRules); els.restartBtn.addEventListener("click", openRestart); els.clearLogBtn.addEventListener("click", () => { els.logList.innerHTML = ""; addLog("事件紀錄已清除。", "info"); }); els.modalClose.addEventListener("click", () => closeModal(null)); els.modal.addEventListener("click", (event) => { if (event.target === els.modal && !els.modal.classList.contains("no-dismiss")) { closeModal(null); } }); document.addEventListener("keydown", (event) => { if (event.code === "Escape" && state.modalOpen) { if (!els.modal.classList.contains("no-dismiss")) closeModal(null); return; } const tag = event.target && event.target.tagName; if (["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(tag)) return; if (event.code === "Space" && !state.modalOpen && state.phase === "idle" && !state.auto && !state.over) { event.preventDefault(); rollDice(); } }); } function resetGame() { clearAutoTimers(); if (state.modalOpen) closeModal(null); state.players = makePlayers(); state.current = 0; state.round = 1; state.phase = "idle"; state.auto = false; state.busy = false; state.over = false; state.pot = 0; state.dice = [1, 1]; state.rolled = false; state.pending = null; state.shownCurrent = -1; state.gameOverShown = false; els.logList.innerHTML = ""; els.actionPanel.classList.add("is-hidden"); els.actionPanel.innerHTML = ""; els.boardCenter.classList.remove("has-action"); els.newsText.textContent = "歡迎來到 Q版小鎮!"; renderBoard(); renderUI(); addLog("歡迎來到 Q版小鎮!收集色組、升級房屋,讓對手破產。", "info"); addLog("按「自動代玩」可以讓阿富和小滿自己玩。", "info"); centerBoardScroll(); void beginTurn(false); } function centerBoardScroll() { if (!els.boardScroll) return; requestAnimationFrame(() => { if (els.boardScroll.scrollWidth > els.boardScroll.clientWidth) { els.boardScroll.scrollLeft = (els.boardScroll.scrollWidth - els.boardScroll.clientWidth) / 2; } }); } function init() { loadPrefs(); buildPlayerCards(); buildBoard(); bindEvents(); resetGame(); window.addEventListener("resize", centerBoardScroll); } init(); })();