Download game.js from HelloSun/dafuweng: direct link, hf CLI and curl.
- Browser
- Download file 59.2 kB
-
https://huggingface.co/spaces/HelloSun/dafuweng/resolve/main/game.js
- Command line
-
hf download hf://spaces/HelloSun/dafuweng/game.js
-
curl -L -o game.js https://huggingface.co/spaces/HelloSun/dafuweng/resolve/main/game.js
59.2 kB
| (() => { | |
| "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 = ` | |
| <div class="player-avatar"><img src="${meta.avatar}" alt="${meta.name}頭像"></div> | |
| <div class="player-main"> | |
| <div class="player-name-row"><b>${meta.name}</b><span class="player-tag">當前</span></div> | |
| <div class="player-cash" data-cash>$25,000</div> | |
| <div class="player-meta" data-meta>📍 起點 · 🏠 0</div> | |
| </div> | |
| <span class="player-jail" data-jail></span> | |
| `; | |
| 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 = ` | |
| <span class="tile-icon">${tile.icon}</span> | |
| <span class="tile-name">${tile.name}</span> | |
| <span class="tile-price">${tilePriceText(tile)}</span> | |
| <span class="tile-houses" data-houses></span> | |
| <span class="tile-owner" data-owner></span> | |
| <span class="tile-tokens" data-tokens></span> | |
| `; | |
| 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 = `<img src="${player.avatar}" alt="${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 ? '<div class="auto-note">自動代玩中,將自動決定…</div>' : ""; | |
| 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 = ` | |
| <div class="action-head"> | |
| <span class="action-icon">${tile.icon}</span> | |
| <div class="action-title"><b>${tile.name}</b><small>${group.label} · 售價 $${fmt(tile.price)}</small></div> | |
| </div> | |
| <p class="action-desc">${description}</p> | |
| <div class="action-buttons"> | |
| <button type="button" class="primary" data-choice="buy" ${pending.canBuy ? "" : "disabled"}>${pending.canBuy ? `買下 $${fmt(tile.price)}` : "現金不足"}</button> | |
| <button type="button" class="ghost" data-choice="pass">放棄</button> | |
| </div> | |
| ${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 = ` | |
| <div class="action-head"> | |
| <span class="action-icon">${tile.icon}</span> | |
| <div class="action-title"><b>${tile.name}</b><small>${GROUPS[tile.group].label} · 第 ${level} 級房屋</small></div> | |
| </div> | |
| <p class="action-desc">目前租金 <b>$${fmt(currentRent)}</b>,升級後 <b>$${fmt(nextRent)}</b>。升級費 $${fmt(cost)}。</p> | |
| <div class="action-buttons"> | |
| <button type="button" class="primary" data-choice="upgrade" ${pending.canUpgrade ? "" : "disabled"}>${pending.canUpgrade ? `升級 $${fmt(cost)}` : "現金不足"}</button> | |
| <button type="button" class="ghost" data-choice="skip">這次不升級</button> | |
| </div> | |
| ${autoNote} | |
| `; | |
| } else if (pending.kind === "jail") { | |
| html = ` | |
| <div class="action-head"> | |
| <span class="action-icon">🚨</span> | |
| <div class="action-title"><b>監獄中</b><small>還要停留 ${player.jailTurns} 回合</small></div> | |
| </div> | |
| <p class="action-desc">支付 <b>$${fmt(BAIL)}</b> 保釋金可以立刻出獄;也可以服刑一回合。</p> | |
| <div class="action-buttons"> | |
| <button type="button" class="primary" data-choice="bail" ${pending.canBail ? "" : "disabled"}>${pending.canBail ? `保釋 $${fmt(BAIL)}` : "現金不足"}</button> | |
| <button type="button" class="ghost" data-choice="serve">服刑一回合</button> | |
| </div> | |
| ${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: `<p>${card.text}</p><div class="card-effect">${card.effectLabel}</div>`, | |
| 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) => ` | |
| <div class="final-row"> | |
| <img src="${player.avatar}" alt="${player.name}頭像"> | |
| <b>${player.name}</b> | |
| <span>$${fmt(netWorth(player))}</span> | |
| </div> | |
| `).join(""); | |
| addLog(`遊戲結束:${winner.name} 獲勝!`, "bonus"); | |
| playWin(); | |
| showModal({ | |
| kicker: "GAME OVER", | |
| title: `${winner.name} 獲勝!`, | |
| image: winner.avatar, | |
| html: `<p>${reason}</p><div class="final-scores">${rows}</div>`, | |
| 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 ? `<div class="modal-art"><img src="${image}" alt=""></div>` : ""} | |
| ${kicker ? `<div class="modal-kicker">${kicker}</div>` : ""} | |
| <h2 id="modalTitle">${title}</h2> | |
| ${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) => ( | |
| `<span class="group-chip" style="--chip:${GROUPS[key].color};--chip-soft:${GROUPS[key].soft}">${GROUPS[key].label}</span>` | |
| )).join(""); | |
| const html = ` | |
| <div class="rules-grid"> | |
| <div class="rule-row"><span class="rule-icon">🎲</span><div><b>擲骰前進</b>:兩顆骰子相加就是步數。經過起點領 $2,000,擲出雙骰可以再來一回合。</div></div> | |
| <div class="rule-row"><span class="rule-icon">🏠</span><div><b>買地收租</b>:踩到無主地產可以買下;對手踩到就要付租金。收集完整同色 3 塊地,租金 ×2。</div></div> | |
| <div class="rule-row"><span class="rule-icon">🔨</span><div><b>升級房屋</b>:擁有完整色組後,踩到自己的地產可以升級,最多 3 級,租金會愈來愈高。</div></div> | |
| <div class="rule-row"><span class="rule-icon">🚉</span><div><b>車站與公用</b>:車站租金依擁有的車站數量增加;公用事業租金 = 骰子點數 × 倍率。</div></div> | |
| <div class="rule-row"><span class="rule-icon">🎁</span><div><b>機會與命運</b>:抽卡可能加錢、罰款、移動或拿到免獄卡。稅金與捐款會進入彩池。</div></div> | |
| <div class="rule-row"><span class="rule-icon">🚨</span><div><b>監獄</b>:進監獄後暫停一回合,可以付 $1,500 保釋金立刻出獄。免獄卡會自動使用。</div></div> | |
| <div class="rule-row"><span class="rule-icon">🏆</span><div><b>勝利條件</b>:讓對手破產,或 30 回合後以總身家(現金+地產+房屋)決勝。</div></div> | |
| <div class="rule-row"><span class="rule-icon">🤖</span><div><b>自動代玩</b>:按右側「自動代玩」讓兩位角色自己決策,可調 1×~3× 速度,隨時停止接手。</div></div> | |
| </div> | |
| <div class="group-legend">${chips}</div> | |
| <p style="font-size:10px;color:#a1845d;margin-top:10px;">圖像由 Z-Image-Turbo OpenVINO INT4 生成,統一 Q 版黏土玩具風格,尺寸 256×256。</p> | |
| `; | |
| 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: "<p>目前的小鎮進度會消失,確定要重新開始嗎?</p>", | |
| 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(); | |
| })(); | |