dafuweng / game.js
HelloSun's picture
Launch Q-style Monopoly game with generated assets
e787c81 verified
Raw History Blame Contribute Delete
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();
})();