(() => { "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 = `
${meta.name}頭像
${meta.name}當前
$25,000
📍 起點 · 🏠 0
`; els.playersRow.appendChild(card); return card; }); } function buildBoard() { const fragment = document.createDocumentFragment(); state.tileEls = []; TILES.forEach((tile, index) => { const [row, col] = tileGridPosition(index); const button = document.createElement("button"); button.type = "button"; button.className = `tile tile-${tile.type} pos-${index}${tile.group ? ` has-strip group-${tile.group}` : ""}`; button.style.gridRow = String(row); button.style.gridColumn = String(col); button.dataset.tileId = String(index); if (tile.group) button.style.setProperty("--group-color", GROUPS[tile.group].color); button.title = tileTooltip(tile); button.setAttribute("aria-label", tileTooltip(tile)); button.innerHTML = ` ${tile.icon} ${tile.name} ${tilePriceText(tile)} `; fragment.appendChild(button); state.tileEls.push(button); }); els.tilesLayer.appendChild(fragment); } function renderBoard() { const current = currentPlayer(); state.tileEls.forEach((el, index) => { const ownerId = ownerOf(index); const owner = ownerId === null ? null : state.players[ownerId]; el.classList.toggle("owned", Boolean(owner)); const isCurrentTile = Boolean(current && current.pos === index); el.classList.toggle("is-current", isCurrentTile); if (isCurrentTile && current) el.style.setProperty("--current-color", current.color); if (!isCurrentTile) el.style.removeProperty("--current-color"); if (owner) { el.style.setProperty("--owner-color", owner.color); el.style.setProperty("--owner-soft", owner.soft); } else { el.style.removeProperty("--owner-color"); el.style.removeProperty("--owner-soft"); } el.querySelector("[data-owner]").textContent = owner ? `P${owner.id + 1}` : ""; const level = owner ? (owner.houses[index] || 0) : 0; el.querySelector("[data-houses]").textContent = level ? "🏠".repeat(level) : ""; const tokenLayer = el.querySelector("[data-tokens]"); tokenLayer.innerHTML = ""; state.players.forEach((player) => { if (player.pos !== index) return; const token = document.createElement("span"); token.className = `token${player.id === state.current ? " is-current" : ""}`; token.style.setProperty("--pcolor", player.color); token.innerHTML = `${player.name}`; token.title = player.name; tokenLayer.appendChild(token); }); }); } function setDieValue(die, value) { die.innerHTML = ""; const pips = PIP_MAP[value] || []; for (let i = 0; i < 9; i += 1) { const cell = document.createElement("span"); if (pips.includes(i)) cell.className = "pip"; die.appendChild(cell); } die.setAttribute("aria-label", `骰子 ${value} 點`); } function renderUI() { const player = currentPlayer(); if (!player) return; const switched = state.shownCurrent !== player.id; state.shownCurrent = player.id; els.currentAvatar.src = player.avatar; els.currentAvatar.alt = `${player.name}頭像`; els.centerAvatar.src = player.avatar; els.centerAvatar.alt = `${player.name}頭像`; els.currentName.textContent = player.name; els.currentName.style.color = player.color; els.centerName.textContent = player.name; els.currentRing.style.setProperty("--ring", player.color); els.turnTag.textContent = `P${player.id + 1}`; els.turnTag.style.background = player.soft; els.turnTag.style.color = player.color; els.currentMoney.textContent = `$${fmt(player.money)}`; els.currentCash.textContent = `$${fmt(player.money)}`; els.currentProps.textContent = String(player.props.length); els.currentCards.textContent = String(player.cards.length); els.currentSub.textContent = player.jailTurns > 0 ? `🚨 監獄中 · 剩餘 ${player.jailTurns} 回合` : `📍 ${TILES[player.pos].name} · 🏠 ${player.props.length} 處地產`; els.roundNum.textContent = String(state.round); els.potValue.textContent = `$${fmt(state.pot)}`; state.players.forEach((p, index) => { const card = state.playerCards[index]; if (!card) return; card.classList.toggle("is-current", p.id === state.current); card.querySelector("[data-cash]").textContent = `$${fmt(p.money)}`; card.querySelector("[data-meta]").textContent = `📍 ${TILES[p.pos].name} · 🏠 ${p.props.length}`; card.querySelector("[data-jail]").textContent = p.jailTurns > 0 ? "🚨" : ""; }); if (state.phase !== "rolling") { setDieValue(els.die1, state.dice[0]); setDieValue(els.die2, state.dice[1]); } const showSum = state.rolled && state.phase !== "rolling"; els.diceSum.textContent = showSum ? String(state.dice[0] + state.dice[1]) : "—"; els.diceSum.classList.toggle("show", showSum); const canRoll = state.phase === "idle" && !state.over && !state.auto && !state.busy; els.rollBtn.disabled = !canRoll; els.rollBtn.classList.toggle("is-auto", state.auto); if (state.over) { els.rollBtn.textContent = "🏁 遊戲結束"; } else if (state.auto) { els.rollBtn.textContent = state.phase === "idle" ? "🤖 自動代玩中" : "🤖 代玩進行中"; } else if (state.phase === "idle") { els.rollBtn.textContent = "🎲 擲骰子"; } else if (state.phase === "rolling") { els.rollBtn.textContent = "🎲 擲骰中…"; } else if (state.phase === "moving") { els.rollBtn.textContent = "🚶 移動中…"; } else if (state.phase === "action") { els.rollBtn.textContent = "🤔 請做決定"; } else if (state.phase === "jail") { els.rollBtn.textContent = "🚨 監獄中"; } else if (state.phase === "card") { els.rollBtn.textContent = "🎁 抽卡中…"; } else if (state.phase === "ending") { els.rollBtn.textContent = "🔄 換人中…"; } else { els.rollBtn.textContent = "⏳ 結算中…"; } els.autoBtn.classList.toggle("is-on", state.auto); els.autoBtn.setAttribute("aria-pressed", String(state.auto)); els.autoIcon.textContent = state.auto ? "⏸" : "▶"; els.autoTitle.textContent = state.auto ? "停止代玩" : "自動代玩"; els.autoSub.textContent = state.auto ? `進行中 · ${state.speed}× 速度` : "讓小鎮自己跑起來"; if (state.over) { els.centerHint.textContent = "遊戲結束,查看最後結果。"; } else if (state.auto) { els.centerHint.textContent = `自動代玩中 · ${state.speed}× · 隨時可以按停止。`; } else if (state.phase === "idle") { els.centerHint.textContent = "按一下擲骰子,開始小鎮冒險。"; } else if (state.phase === "rolling") { els.centerHint.textContent = "骰子轉呀轉,看看這次走幾步。"; } else if (state.phase === "moving") { els.centerHint.textContent = "一步一步往前進…"; } else if (state.phase === "action") { els.centerHint.textContent = "選一個動作,繼續你的回合。"; } else if (state.phase === "jail") { els.centerHint.textContent = "支付保釋金,或乖乖服刑一回合。"; } else if (state.phase === "card") { els.centerHint.textContent = "卡片效果發動中…"; } else if (state.phase === "ending") { els.centerHint.textContent = "換下一位玩家上場。"; } else { els.centerHint.textContent = "正在結算這塊地…"; } if (switched) { [els.currentAvatar, els.centerAvatar].forEach((img) => { try { img.animate( [ { opacity: 0.15, transform: "scale(.88) rotate(-4deg)" }, { opacity: 1, transform: "scale(1) rotate(0deg)" } ], { duration: 360, easing: "cubic-bezier(.2,.8,.2,1)" } ); } catch (error) { void error; } }); } } function renderActionPanel() { const pending = state.pending; els.boardCenter.classList.toggle("has-action", Boolean(pending)); if (!pending) { els.actionPanel.classList.add("is-hidden"); els.actionPanel.innerHTML = ""; return; } const player = state.players[pending.playerId]; const tile = TILES[pending.tileId]; const autoNote = state.auto ? '
自動代玩中,將自動決定…
' : ""; let html = ""; if (pending.kind === "buy") { const group = GROUPS[tile.group]; const description = tile.type === "station" ? "買下後,對手踩到會依你擁有的車站數量支付租金。" : tile.type === "utility" ? "買下後,對手踩到會支付「骰子點數 × 倍率」的租金。" : `買下後,對手踩到需支付基本租金 $${fmt(propertyRent(tile, player, 0))};收集完整「${group.label}」色組,租金 ×2。`; html = `
${tile.icon}
${tile.name}${group.label} · 售價 $${fmt(tile.price)}

${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 = `
${tile.icon}
${tile.name}${GROUPS[tile.group].label} · 第 ${level} 級房屋

目前租金 $${fmt(currentRent)},升級後 $${fmt(nextRent)}。升級費 $${fmt(cost)}。

${autoNote} `; } else if (pending.kind === "jail") { html = `
🚨
監獄中還要停留 ${player.jailTurns} 回合

支付 $${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}

${card.effectLabel}
`, actions: [{ label: "知道了", value: "ok", primary: true }], dismissible: true, autoClose: 1500, autoCloseOnAuto: true, className: `event-modal ${kind}` }); if (state.over) return; await applyCard(card, player, depth); } async function applyCard(card, player, depth) { const effect = card.effect; if (effect.type === "gain") { player.money += effect.amount; addLog(`${player.name} 獲得 $${fmt(effect.amount)}。`, "bonus"); toast(`+$${fmt(effect.amount)}`, "gold"); playTone(860, 0.1, "sine"); } else if (effect.type === "payPot") { if (!(await payToPot(player, effect.amount, card.title))) return; } else if (effect.type === "payOpponent") { const opponent = otherPlayer(player); if (!pay(player, opponent, effect.amount)) return; addLog(`${player.name} 支付 ${opponent.name} $${fmt(effect.amount)}。`, "rent"); toast(`支付 ${opponent.name} $${fmt(effect.amount)}`, "bad"); } else if (effect.type === "gainFromOpponent") { const opponent = otherPlayer(player); if (!pay(opponent, player, effect.amount)) return; addLog(`${player.name} 從 ${opponent.name} 獲得 $${fmt(effect.amount)}。`, "bonus"); toast(`+$${fmt(effect.amount)}`, "gold"); } else if (effect.type === "freeJail") { player.cards.push("freeJail"); addLog(`${player.name} 獲得一張免獄卡。`, "bonus"); toast("獲得免獄卡", "gold"); playTone(900, 0.1, "sine"); } else if (effect.type === "repair") { const houseCount = player.props.reduce((sum, id) => sum + (player.houses[id] || 0), 0); if (houseCount === 0) { addLog(`${player.name} 沒有房屋,免付維修費。`, "info"); } else { const total = houseCount * effect.amount; if (!(await payToPot(player, total, card.title))) return; addLog(`${player.name} 共有 ${houseCount} 棟房屋,支付 $${fmt(total)}。`, "rent"); } } else if (effect.type === "moveTo") { await moveTo(effect.amount, true); if (!state.over) await resolveLanding(depth + 1); return; } else if (effect.type === "moveSteps") { await moveSteps(effect.amount, effect.amount > 0); if (!state.over) await resolveLanding(depth + 1); return; } else if (effect.type === "nearestStation") { const target = findNextStation(player.pos); await moveTo(target, true); if (!state.over) await resolveLanding(depth + 1); return; } else if (effect.type === "jail") { await sendToJail(player); return; } renderBoard(); renderUI(); } async function finishTurn(allowDouble = true) { if (state.over) return; const player = currentPlayer(); const isDouble = allowDouble && state.dice[0] === state.dice[1] && player.jailTurns === 0; state.phase = "ending"; renderUI(); await wait(480); if (state.over) return; if (isDouble) { addLog(`${player.name} 擲出雙骰,再來一回合!`, "bonus"); state.phase = "idle"; state.busy = false; renderUI(); await beginTurn(true); return; } state.current = 1 - state.current; if (state.current === 0) { if (state.round >= MAX_ROUNDS) { endByRounds(); return; } state.round += 1; } state.busy = false; await beginTurn(false); } async function beginTurn(extraTurn = false) { if (state.over) return; state.busy = false; const player = currentPlayer(); state.phase = "idle"; renderUI(); addLog(extraTurn ? `${player.name} 的追加回合。` : `— 第 ${state.round} 回合 · ${player.name} 的回合 —`, "turn"); if (player.jailTurns > 0) { if (player.cards.includes("freeJail")) { player.cards = player.cards.filter((card) => card !== "freeJail"); player.jailTurns = 0; addLog(`${player.name} 使用免獄卡,直接出獄。`, "bonus"); toast("免獄卡發動!", "gold"); } else { state.phase = "jail"; renderUI(); if (state.auto) { await wait(620); if (state.over) return; if (player.money >= 5000) { pay(player, null, BAIL); player.jailTurns = 0; addLog(`${player.name} 支付 $${fmt(BAIL)} 保釋金,立刻出獄。`, "info"); toast(`${player.name} 保釋出獄`, "gold"); } else { player.jailTurns -= 1; addLog(`${player.name} 現金不足,只能留在監獄一回合。`, "jail"); await wait(400); await finishTurn(false); return; } } else { const choice = await promptJail(player); if (state.over) return; if (choice === "bail" && player.money >= BAIL) { pay(player, null, BAIL); player.jailTurns = 0; addLog(`${player.name} 支付 $${fmt(BAIL)} 保釋金,立刻出獄。`, "info"); toast(`${player.name} 保釋出獄`, "gold"); playTone(720, 0.1, "sine"); } else { player.jailTurns -= 1; addLog(`${player.name} 選擇服刑,暫停一回合。`, "jail"); await wait(350); await finishTurn(false); return; } } } } state.phase = "idle"; renderUI(); if (state.auto) scheduleAutoRoll(); } function bankrupt(loser) { if (state.over) return; state.over = true; state.phase = "over"; state.auto = false; clearAutoTimers(); renderUI(); const winner = state.players.find((player) => player.id !== loser.id); addLog(`${loser.name} 現金不足,宣告破產!`, "danger"); showGameOver(winner, `${loser.name} 破產了,${winner.name} 成為 Q版小鎮的大富翁!`); } function endByRounds() { state.over = true; state.phase = "over"; state.auto = false; clearAutoTimers(); renderUI(); const first = state.players[0]; const second = state.players[1]; const firstWorth = netWorth(first); const secondWorth = netWorth(second); const winner = firstWorth >= secondWorth ? first : second; showGameOver( winner, `30 回合結束,以總身家決勝(${first.name} $${fmt(firstWorth)}/${second.name} $${fmt(secondWorth)})。` ); } function showGameOver(winner, reason) { if (state.gameOverShown) return; state.gameOverShown = true; state.over = true; state.phase = "over"; state.auto = false; clearAutoTimers(); renderUI(); const rows = state.players.map((player) => `
${player.name}頭像 ${player.name} $${fmt(netWorth(player))}
`).join(""); addLog(`遊戲結束:${winner.name} 獲勝!`, "bonus"); playWin(); showModal({ kicker: "GAME OVER", title: `${winner.name} 獲勝!`, image: winner.avatar, html: `

${reason}

${rows}
`, actions: [{ label: "再玩一次", value: "restart", primary: true }], dismissible: false, className: "gameover-modal" }).then((value) => { if (value === "restart") window.location.reload(); }); } function showModal(options = {}) { if (state.modalOpen) closeModal(null); const { title = "", kicker = "", image = "", html = "", actions = [{ label: "知道了", value: "ok", primary: true }], dismissible = true, autoClose = 0, autoCloseOnAuto = false, className = "" } = options; state.modalOpen = true; state.modalAutoCloseable = Boolean(autoCloseOnAuto); state.modalAutoCloseDelay = autoClose || 1200; clearTimeout(state.modalTimer); state.modalTimer = null; els.modal.classList.remove("is-hidden"); els.modal.classList.toggle("no-dismiss", !dismissible); const card = els.modal.querySelector(".modal-card"); card.className = `modal-card ${className}`.trim(); els.modalBody.innerHTML = ` ${image ? `` : ""} ${kicker ? `` : ""}

${title}

${html} `; const actionWrap = document.createElement("div"); actionWrap.className = "modal-actions"; actions.forEach((action) => { const button = document.createElement("button"); button.type = "button"; button.className = action.primary ? "primary" : action.secondary ? "secondary" : "ghost"; button.textContent = action.label; button.addEventListener("click", () => closeModal(action.value)); actionWrap.appendChild(button); }); els.modalBody.appendChild(actionWrap); return new Promise((resolve) => { state.modalResolve = resolve; if (autoClose && state.auto) scheduleModalAutoClose(autoClose); }); } function scheduleModalAutoClose(ms) { clearTimeout(state.modalTimer); state.modalTimer = setTimeout(() => { state.modalTimer = null; if (state.modalOpen) closeModal("ok"); }, Math.max(250, Math.round(ms / state.speed))); } function closeModal(value) { if (!state.modalOpen) return; state.modalOpen = false; clearTimeout(state.modalTimer); state.modalTimer = null; els.modal.classList.add("is-hidden"); const resolve = state.modalResolve; state.modalResolve = null; if (resolve) resolve(value); setTimeout(() => { if (state.auto && !state.over && !state.modalOpen && state.phase === "idle") scheduleAutoRoll(); }, 0); } function openRules() { if (state.busy) { toast("等這一回合結束再看玩法。"); return; } const wasAuto = state.auto; if (wasAuto) { state.auto = false; clearAutoTimers(); renderUI(); } const chips = ["pink", "blue", "green", "purple"].map((key) => ( `${GROUPS[key].label}` )).join(""); const html = `
🎲
擲骰前進:兩顆骰子相加就是步數。經過起點領 $2,000,擲出雙骰可以再來一回合。
🏠
買地收租:踩到無主地產可以買下;對手踩到就要付租金。收集完整同色 3 塊地,租金 ×2。
🔨
升級房屋:擁有完整色組後,踩到自己的地產可以升級,最多 3 級,租金會愈來愈高。
🚉
車站與公用:車站租金依擁有的車站數量增加;公用事業租金 = 骰子點數 × 倍率。
🎁
機會與命運:抽卡可能加錢、罰款、移動或拿到免獄卡。稅金與捐款會進入彩池。
🚨
監獄:進監獄後暫停一回合,可以付 $1,500 保釋金立刻出獄。免獄卡會自動使用。
🏆
勝利條件:讓對手破產,或 30 回合後以總身家(現金+地產+房屋)決勝。
🤖
自動代玩:按右側「自動代玩」讓兩位角色自己決策,可調 1×~3× 速度,隨時停止接手。
${chips}

圖像由 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(); })();