共享会话
製作一個html+css+js程式,實現Vino Square(維諾廣場),一款抽象策略遊戲
分享于 2026年8月28日 05:03製作一個html+css+js程式,實現Vino Square(維諾廣場),一款抽象策略遊戲。 一,html結構
中間是棋盤區,裡面是一個大小為480*480的svg棋盤。左右或上下是玩家控制區,如果是電腦端,則右邊是玩家一控制區,左邊是玩家二控制區,如果是手機端,則下面是玩家一控制區,上面是玩家二控制區。
棋盤區的棋盤應始終保持1:1的高寬比。當電腦端時,每個控制區的寬度至少是240,多則填滿餘下空隙。當手機端時,每個控制區的高度至少是160,多則填滿餘下空隙。
控制區的結構為:分數(字體要大,居中,保證能顯示三位數),棋子及數量。有三種棋子,棋子應放進一個正方形的框中,如果是電腦端,則三個棋子縱向排列,玩家一的棋子數量在棋子右邊,玩家二在左邊,如果是手機端,則三個棋子横向排列,棋子數量統一在棋子下方。
二,遊戲規則
遊戲目標是圍成盡可能多的平方數,分數高者獲勝。
棋子分為三種:fino(法諾),vino(維諾),mimo(米諾)。其中法諾是五個正方形連成v形,即一個正方形的右邊和下邊都有兩個相同的正方形與之相連,維諾是三個正方形連成v形,米諾是一個正方形。
每方有五個法諾,七個維諾,三個米諾。
棋盤是十乘十正方形棋子。
在遊戲時,雙方輪流放置一個棋子,要先放完法諾才能開始放維諾,放完維諾才能放米諾。棋子之間不能重疊。
當一方不能合規落下一個棋子,則他將直接輸掉遊戲。
計分規則是,形成一個二乘二的矩形計4分,形成三乘三的矩形計9分,四乘四或五乘五等依次類推。同樣棋型可以重覆計分,比如二乘三矩形可計兩次4分,三乘三矩形可計四次4分和一次9分。同時棋盤上只要有一個正方形就計1分,則可認為每方只要落完棋子就有49分的基礎分(55+37+1*3=49)。
當一局結束後,雙方交換先後手再玩一局,之後統計兩局的總分,分高者獲勝。
三,控制說明
當輪到一方時,他的分數區的背景將變色,以表示當前輪到他行動。
法諾和維諾棋子有四個旋轉方向,等於是把棋子的框分成四個象限,當點擊其中一個象限,v的尖角就朝向它。
當點擊一個法諾或維諾棋子時,它將被選中,同時會朝向所點擊的象限方向。如果再次點擊相同的象限,則取消選取。
當點擊一個米諾棋子時,它將被選中,如果再次點擊則取消取。
當有一個棋子被選中時,點擊棋盤的一個格子,則這個棋子將落在所點擊的地方。如果是法諾或維諾則以v形尖角的格子為落子的地方。
當發生棋子重疊或超出棋盤邊界,則本次落子不能執行,並彈出2.4s的消息框提示。
在落子之後立即計分,分數將直接發生變化。
當一局結束後,彈出一個窗口(右上角有關閉叉),展示雙方得分,並詢問是否進行下一局交換先後手,或是結束遊戲。
四,工具欄
棋盤區的下方有一排工具欄,不要覆蓋到棋盤上。工具欄的按鈕有:回退、前進、AI設置、導出文本棋譜、下載svg、恢復窗口,自動旋轉。
每一個按鈕都是一個svg圖標。
目前除了回退和前進,其它的工具的功能暫時不用實現。
五,開始頁面
打開頁面時,有主標題,副標題,中英語言切換,規則介紹,開始按鈕。
主標題為「維諾廣場」(英文為Vino Square),副標題為「法諾、維諾、米諾在廣場上玩」(英文為Fino, Vino, Mino are playing on the square.)(英文需要你幫我修改以使其語法正確)。
當點擊語言切換,整個頁面和遊戲中的消息框等,都將把語言切換成相應語言。一開始默認是英文。
規則介紹將把前面的規則寫上,加入英文翻譯。
點開始遊戲按鈕則立即開始遊戲,第一局將是玩家一先手。
QuanAI以下是可直接執行的單檔版本,CSS 與 JavaScript 均已包含在 HTML 內。英文副標題已修正為較自然的:
“Fino, Vino, and Mino Play on the Square.”
將程式碼儲存為 vino-square.html 後直接用瀏覽器開啟即可。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
>
<meta name="theme-color" content="#0b111b">
<title>Vino Square</title>
<style>
:root {
color-scheme: dark;
--bg: #0b111b;
--bg-soft: #111a28;
--panel: rgba(20, 30, 44, 0.92);
--panel-strong: #172233;
--panel-border: rgba(255, 255, 255, 0.1);
--text: #f7f2e9;
--muted: #aab5c4;
--faint: #778496;
--board: #f2ead8;
--board-grid: rgba(43, 48, 58, 0.38);
--board-border: #8b826f;
--wine: #ba5a79;
--wine-light: #f2a7bd;
--p1: #ff806f;
--p1-dark: #bd493f;
--p1-soft: rgba(255, 128, 111, 0.18);
--p2: #60acf8;
--p2-dark: #3474b5;
--p2-soft: rgba(96, 172, 248, 0.18);
--valid: #52d79a;
--invalid: #ff5570;
--radius: 22px;
--shadow: 0 22px 70px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--bg);
}
body {
min-width: 320px;
min-height: 100dvh;
margin: 0;
overflow-x: hidden;
color: var(--text);
background:
radial-gradient(circle at 15% 10%, rgba(186, 90, 121, 0.16), transparent 32rem),
radial-gradient(circle at 88% 82%, rgba(67, 132, 205, 0.16), transparent 34rem),
linear-gradient(145deg, #090e16, #101927 55%, #0b111b);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", "Noto Sans TC", "PingFang TC", "Microsoft JhengHei",
Arial, sans-serif;
}
button {
color: inherit;
font: inherit;
}
button:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(242, 167, 189, 0.82);
outline-offset: 3px;
}
[hidden] {
display: none !important;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.app-shell {
position: relative;
min-height: 100dvh;
isolation: isolate;
}
.ambient-shape {
position: fixed;
z-index: -1;
width: 22rem;
height: 22rem;
pointer-events: none;
border: 1px solid rgba(255, 255, 255, 0.035);
transform: rotate(28deg);
}
.ambient-shape.one {
top: -10rem;
left: -8rem;
border-radius: 38%;
background: rgba(186, 90, 121, 0.035);
}
.ambient-shape.two {
right: -10rem;
bottom: -12rem;
border-radius: 42%;
background: rgba(96, 172, 248, 0.035);
}
/* ---------- Language switch ---------- */
.lang-switch {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 999px;
background: rgba(3, 8, 14, 0.42);
}
.lang-option {
min-width: 42px;
padding: 6px 10px;
border: 0;
border-radius: 999px;
color: var(--muted);
background: transparent;
cursor: pointer;
transition:
color 160ms ease,
background 160ms ease,
transform 160ms ease;
}
.lang-option:hover {
color: var(--text);
}
.lang-option.active {
color: #1d1720;
background: linear-gradient(135deg, #f4dfc7, #f1afc2);
box-shadow: 0 4px 15px rgba(186, 90, 121, 0.22);
}
.lang-switch.compact .lang-option {
min-width: 34px;
padding: 4px 7px;
font-size: 0.75rem;
}
/* ---------- Start page ---------- */
.start-screen {
display: grid;
min-height: 100dvh;
place-items: center;
padding: clamp(18px, 4vw, 56px);
}
.start-card {
width: min(100%, 980px);
padding: clamp(22px, 4vw, 48px);
overflow: hidden;
border: 1px solid var(--panel-border);
border-radius: clamp(24px, 4vw, 38px);
background:
linear-gradient(145deg, rgba(28, 40, 57, 0.94), rgba(13, 21, 33, 0.94));
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.start-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 26px;
}
.brand-signature {
display: flex;
align-items: center;
gap: 12px;
color: var(--muted);
font-size: 0.76rem;
font-weight: 750;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.brand-mark {
position: relative;
width: 35px;
height: 35px;
transform: rotate(-5deg);
}
.brand-mark span {
position: absolute;
width: 13px;
height: 13px;
border-radius: 4px;
background: var(--wine-light);
box-shadow: 0 4px 12px rgba(186, 90, 121, 0.3);
}
.brand-mark span:nth-child(1) {
top: 0;
left: 0;
}
.brand-mark span:nth-child(2) {
top: 0;
left: 17px;
}
.brand-mark span:nth-child(3) {
top: 17px;
left: 0;
}
.hero {
max-width: 800px;
margin: 0 auto 30px;
text-align: center;
}
.hero h1 {
margin: 0;
font-size: clamp(3rem, 8vw, 6.5rem);
font-weight: 850;
line-height: 0.96;
letter-spacing: -0.055em;
text-wrap: balance;
}
.hero h1::after {
display: block;
width: min(180px, 36vw);
height: 5px;
margin: 22px auto 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--p2), var(--wine-light), var(--p1));
content: "";
}
.subtitle {
max-width: 640px;
margin: 22px auto 0;
color: var(--muted);
font-size: clamp(1rem, 2.4vw, 1.35rem);
line-height: 1.65;
text-wrap: balance;
}
.rules-section {
margin-top: 30px;
}
.section-heading {
display: flex;
align-items: center;
gap: 14px;
margin: 0 0 16px;
color: var(--text);
font-size: clamp(1.25rem, 3vw, 1.7rem);
}
.section-heading::after {
flex: 1;
height: 1px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.18), transparent);
content: "";
}
.rules-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.rule-block {
position: relative;
min-height: 150px;
padding: 20px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
}
.rule-block.wide {
grid-column: 1 / -1;
}
.rule-index {
position: absolute;
top: 12px;
right: 16px;
color: rgba(255, 255, 255, 0.06);
font-size: 2.5rem;
font-weight: 900;
line-height: 1;
}
.rule-block h3 {
position: relative;
z-index: 1;
margin: 0 0 10px;
font-size: 1rem;
letter-spacing: 0.02em;
}
.rule-block p,
.rule-block li {
position: relative;
z-index: 1;
color: var(--muted);
font-size: 0.91rem;
line-height: 1.65;
}
.rule-block p {
margin: 0;
}
.rule-block ul {
margin: 0;
padding-left: 1.2rem;
}
.rule-block li + li {
margin-top: 6px;
}
.rule-block strong {
color: var(--text);
}
.start-actions {
display: flex;
justify-content: center;
margin-top: 30px;
}
.primary-button,
.secondary-button {
min-height: 48px;
padding: 12px 22px;
border-radius: 14px;
cursor: pointer;
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.primary-button {
border: 0;
color: #20151a;
font-weight: 800;
background: linear-gradient(135deg, #f4dfc7, #efa3bb);
box-shadow: 0 14px 30px rgba(186, 90, 121, 0.25);
}
.primary-button:hover {
transform: translateY(-2px);
box-shadow: 0 18px 38px rgba(186, 90, 121, 0.34);
}
.start-button {
min-width: min(100%, 260px);
min-height: 58px;
font-size: 1.06rem;
}
.secondary-button {
border: 1px solid rgba(255, 255, 255, 0.14);
color: var(--text);
background: rgba(255, 255, 255, 0.05);
}
.secondary-button:hover {
border-color: rgba(255, 255, 255, 0.27);
transform: translateY(-1px);
}
/* ---------- Game layout: mobile first ---------- */
.game-screen {
min-height: 100dvh;
padding: 8px;
}
.game-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows:
minmax(160px, 1fr)
auto
minmax(160px, 1fr);
gap: 8px;
width: 100%;
min-height: calc(100dvh - 16px);
}
.player-panel {
--owner-color: var(--p1);
--owner-soft: var(--p1-soft);
position: relative;
display: grid;
grid-template-rows: 52px minmax(92px, 1fr);
min-width: 0;
min-height: 160px;
padding: 8px 12px;
overflow: hidden;
border: 1px solid var(--panel-border);
border-radius: var(--radius);
background:
linear-gradient(145deg, rgba(25, 37, 53, 0.96), rgba(15, 24, 36, 0.96));
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.2);
}
.player-panel::before {
position: absolute;
top: 0;
right: 18%;
left: 18%;
height: 2px;
border-radius: 999px;
background: var(--owner-color);
opacity: 0.65;
content: "";
}
.player-panel.player-one {
--owner-color: var(--p1);
--owner-soft: var(--p1-soft);
}
.player-panel.player-two {
--owner-color: var(--p2);
--owner-soft: var(--p2-soft);
}
.score-card {
position: relative;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
min-width: 0;
padding: 4px 12px;
border: 1px solid transparent;
border-radius: 15px;
background: rgba(255, 255, 255, 0.035);
transition:
background 200ms ease,
border-color 200ms ease,
box-shadow 200ms ease;
}
.player-one .score-card.active {
border-color: rgba(255, 128, 111, 0.55);
background: linear-gradient(135deg, rgba(255, 128, 111, 0.3), rgba(255, 128, 111, 0.1));
box-shadow: inset 0 0 24px rgba(255, 128, 111, 0.08);
}
.player-two .score-card.active {
border-color: rgba(96, 172, 248, 0.55);
background: linear-gradient(135deg, rgba(96, 172, 248, 0.3), rgba(96, 172, 248, 0.1));
box-shadow: inset 0 0 24px rgba(96, 172, 248, 0.08);
}
.player-name {
min-width: 0;
overflow: hidden;
color: var(--text);
font-size: 0.86rem;
font-weight: 780;
text-overflow: ellipsis;
white-space: nowrap;
}
.score-center {
display: flex;
flex-direction: column;
align-items: center;
min-width: 76px;
line-height: 1;
}
.score-value {
max-width: 100%;
font-size: clamp(2rem, 10vw, 2.5rem);
font-weight: 850;
font-variant-numeric: tabular-nums;
letter-spacing: -0.045em;
}
.score-label {
margin-top: 1px;
color: var(--muted);
font-size: 0.61rem;
font-weight: 750;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.turn-dot {
justify-self: end;
width: 10px;
height: 10px;
border: 2px solid var(--owner-color);
border-radius: 50%;
background: transparent;
opacity: 0.35;
transition: opacity 180ms ease, background 180ms ease;
}
.score-card.active .turn-dot {
background: var(--owner-color);
opacity: 1;
box-shadow: 0 0 12px var(--owner-color);
}
.pieces-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center;
gap: 7px;
min-width: 0;
}
.piece-item {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
color: var(--muted);
}
.piece-name {
max-width: 100%;
margin-bottom: 3px;
overflow: hidden;
font-size: 0.7rem;
font-weight: 720;
text-overflow: ellipsis;
white-space: nowrap;
}
.piece-frame {
position: relative;
display: grid;
width: clamp(54px, 15vw, 68px);
aspect-ratio: 1;
place-items: center;
padding: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 13px;
background:
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.06), transparent 70%),
rgba(3, 8, 14, 0.34);
cursor: pointer;
touch-action: manipulation;
transition:
border-color 160ms ease,
opacity 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.piece-frame::before {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background:
linear-gradient(
to right,
transparent calc(50% - 0.5px),
rgba(255, 255, 255, 0.075) calc(50% - 0.5px),
rgba(255, 255, 255, 0.075) calc(50% + 0.5px),
transparent calc(50% + 0.5px)
),
linear-gradient(
to bottom,
transparent calc(50% - 0.5px),
rgba(255, 255, 255, 0.075) calc(50% - 0.5px),
rgba(255, 255, 255, 0.075) calc(50% + 0.5px),
transparent calc(50% + 0.5px)
);
content: "";
}
.piece-frame svg {
position: relative;
z-index: 1;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.piece-frame:hover {
border-color: rgba(255, 255, 255, 0.28);
transform: translateY(-1px);
}
.piece-item.available .piece-frame {
border-color: color-mix(in srgb, var(--owner-color) 48%, transparent);
}
.piece-item.selected .piece-frame {
border-color: var(--owner-color);
opacity: 1;
transform: translateY(-2px);
box-shadow:
0 0 0 3px var(--owner-soft),
0 10px 22px rgba(0, 0, 0, 0.24);
}
.piece-item.locked:not(.selected) .piece-frame {
opacity: 0.42;
filter: saturate(0.55);
}
.piece-item.not-current:not(.selected) .piece-frame {
opacity: 0.58;
}
.piece-item.depleted .piece-frame {
opacity: 0.2;
filter: grayscale(0.8);
}
.piece-count {
margin-top: 3px;
color: var(--text);
font-size: 0.82rem;
font-weight: 790;
font-variant-numeric: tabular-nums;
}
.piece-item.depleted .piece-count {
color: var(--faint);
}
.mini-cell.owner-1 {
fill: var(--p1);
stroke: var(--p1-dark);
}
.mini-cell.owner-2 {
fill: var(--p2);
stroke: var(--p2-dark);
}
.mini-cell {
stroke-width: 2;
}
.mini-cell.anchor {
stroke: #fff8ed;
stroke-width: 3;
}
.anchor-dot {
fill: #fff8ed;
opacity: 0.92;
}
/* ---------- Board ---------- */
.board-zone {
display: grid;
align-self: center;
justify-self: center;
width: min(100%, 480px);
min-width: 0;
gap: 9px;
}
.round-strip {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 5px 7px 5px 12px;
border: 1px solid var(--panel-border);
border-radius: 15px;
background: rgba(17, 26, 40, 0.88);
box-shadow: 0 9px 25px rgba(0, 0, 0, 0.18);
}
.round-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 760;
white-space: nowrap;
}
.turn-pill,
.result-reopen {
justify-self: center;
min-width: 0;
max-width: 100%;
padding: 6px 11px;
overflow: hidden;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 780;
text-overflow: ellipsis;
white-space: nowrap;
}
.turn-pill {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.045);
}
.turn-pill.owner-1 {
color: #ffd4cd;
border-color: rgba(255, 128, 111, 0.3);
background: rgba(255, 128, 111, 0.12);
}
.turn-pill.owner-2 {
color: #cce7ff;
border-color: rgba(96, 172, 248, 0.3);
background: rgba(96, 172, 248, 0.12);
}
.result-reopen {
border: 1px solid rgba(242, 167, 189, 0.35);
color: #ffe0e9;
background: rgba(186, 90, 121, 0.16);
cursor: pointer;
}
.board-shell {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 18px;
background: var(--board);
box-shadow:
0 22px 50px rgba(0, 0, 0, 0.3),
0 0 0 5px rgba(255, 255, 255, 0.025);
}
#boardSvg {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1;
cursor: crosshair;
touch-action: manipulation;
user-select: none;
}
.board-base {
fill: var(--board);
}
.board-border {
fill: none;
stroke: var(--board-border);
stroke-width: 3;
}
.grid-lines {
fill: none;
stroke: var(--board-grid);
stroke-width: 1.25;
vector-effect: non-scaling-stroke;
}
.placed-cell {
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
.placed-cell.owner-1 {
fill: var(--p1);
stroke: var(--p1-dark);
}
.placed-cell.owner-2 {
fill: var(--p2);
stroke: var(--p2-dark);
}
.preview-cell {
stroke-width: 2.5;
vector-effect: non-scaling-stroke;
}
.preview-cell.valid {
fill: rgba(82, 215, 154, 0.56);
stroke: #229a68;
}
.preview-cell.invalid {
fill: rgba(255, 85, 112, 0.52);
stroke: #c72d48;
}
.preview-cell.anchor-preview {
stroke: #fff;
stroke-width: 3.5;
}
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(4px, 1.5vw, 8px);
min-height: 50px;
padding: 5px 7px;
border: 1px solid var(--panel-border);
border-radius: 16px;
background: rgba(17, 26, 40, 0.9);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18);
}
.tool-button {
position: relative;
display: grid;
width: clamp(34px, 10vw, 42px);
aspect-ratio: 1;
place-items: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 11px;
color: var(--muted);
background: rgba(255, 255, 255, 0.035);
cursor: pointer;
transition:
color 150ms ease,
border-color 150ms ease,
background 150ms ease,
transform 150ms ease;
}
.tool-button svg {
width: 21px;
height: 21px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
.tool-button:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.tool-button:disabled {
cursor: not-allowed;
opacity: 0.28;
}
.tool-button::after {
position: absolute;
z-index: 20;
bottom: calc(100% + 8px);
left: 50%;
padding: 5px 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
color: var(--text);
background: #111a27;
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.3);
content: attr(data-tooltip);
font-size: 0.68rem;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 4px);
transition: opacity 130ms ease, transform 130ms ease;
white-space: nowrap;
}
.tool-button:hover::after,
.tool-button:focus-visible::after {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
z-index: 150;
right: 16px;
bottom: 20px;
left: 16px;
width: fit-content;
max-width: min(92vw, 520px);
margin: auto;
padding: 12px 18px;
border: 1px solid rgba(255, 128, 149, 0.38);
border-radius: 13px;
color: #fff1f4;
background: rgba(89, 25, 40, 0.94);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.38);
font-size: 0.9rem;
line-height: 1.45;
opacity: 0;
pointer-events: none;
transform: translateY(14px);
transition: opacity 180ms ease, transform 180ms ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed;
z-index: 100;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
background: rgba(3, 7, 12, 0.78);
backdrop-filter: blur(9px);
}
.result-modal {
position: relative;
width: min(100%, 580px);
max-height: calc(100dvh - 36px);
padding: 64px 24px 24px;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 26px;
background:
linear-gradient(150deg, rgba(29, 42, 59, 0.99), rgba(14, 22, 34, 0.99));
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.55);
}
.modal-lang {
position: absolute;
top: 16px;
left: 18px;
}
.modal-close {
position: absolute;
top: 16px;
right: 18px;
display: grid;
width: 36px;
height: 36px;
place-items: center;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 10px;
color: var(--muted);
background: rgba(255, 255, 255, 0.045);
cursor: pointer;
}
.modal-close:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.09);
}
.modal-close svg {
width: 19px;
height: 19px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.modal-eyebrow {
margin: 0 0 8px;
color: var(--wine-light);
font-size: 0.73rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.result-modal h2 {
margin: 0;
font-size: clamp(1.65rem, 5vw, 2.45rem);
line-height: 1.12;
letter-spacing: -0.035em;
text-wrap: balance;
}
.modal-summary {
margin: 12px 0 0;
color: var(--muted);
line-height: 1.65;
}
.modal-section-label {
margin: 24px 0 10px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.modal-score-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.modal-score {
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 16px;
background: rgba(255, 255, 255, 0.035);
text-align: center;
}
.modal-score.player-one {
border-color: rgba(255, 128, 111, 0.24);
background: rgba(255, 128, 111, 0.08);
}
.modal-score.player-two {
border-color: rgba(96, 172, 248, 0.24);
background: rgba(96, 172, 248, 0.08);
}
.modal-score span {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
.modal-score strong {
display: block;
margin-top: 4px;
font-size: clamp(2rem, 8vw, 3rem);
font-variant-numeric: tabular-nums;
letter-spacing: -0.04em;
}
.modal-breakdown {
margin-top: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 15px;
}
.result-table {
width: 100%;
border-collapse: collapse;
font-size: 0.86rem;
font-variant-numeric: tabular-nums;
}
.result-table th,
.result-table td {
padding: 11px 13px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
text-align: right;
}
.result-table th:first-child,
.result-table td:first-child {
text-align: left;
}
.result-table th {
color: var(--muted);
font-size: 0.72rem;
}
.result-table tfoot td {
color: var(--text);
font-weight: 850;
background: rgba(255, 255, 255, 0.04);
}
.result-table tr:last-child td {
border-bottom: 0;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}
/* ---------- Desktop layout ---------- */
@media (min-width: 1024px) {
.game-screen {
padding: clamp(8px, 1.2vw, 18px);
}
.game-layout {
grid-template-columns: minmax(240px, 1fr) 480px minmax(240px, 1fr);
grid-template-rows: minmax(0, 1fr);
gap: clamp(8px, 1.2vw, 18px);
min-height: calc(100dvh - clamp(16px, 2.4vw, 36px));
}
.player-two {
grid-column: 1;
grid-row: 1;
}
.board-zone {
grid-column: 2;
grid-row: 1;
width: 480px;
}
.player-one {
grid-column: 3;
grid-row: 1;
}
.player-panel {
grid-template-rows: minmax(112px, 0.26fr) minmax(360px, 1fr);
min-width: 240px;
min-height: 0;
padding: 18px;
}
.score-card {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 112px;
padding: 14px;
text-align: center;
}
.player-name {
margin-bottom: 4px;
font-size: 0.94rem;
}
.score-center {
min-width: 130px;
}
.score-value {
font-size: clamp(3rem, 5vw, 4.2rem);
}
.score-label {
font-size: 0.65rem;
}
.turn-dot {
position: absolute;
top: 14px;
right: 14px;
}
.pieces-list {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: repeat(3, minmax(104px, 1fr));
gap: 5px;
}
.piece-item {
display: grid;
grid-template-columns: minmax(42px, 1fr) 84px minmax(42px, 1fr);
grid-template-rows: auto 84px;
align-items: center;
width: 100%;
column-gap: 10px;
}
.piece-name {
grid-column: 2;
grid-row: 1;
justify-self: center;
margin-bottom: 5px;
font-size: 0.78rem;
}
.piece-frame {
grid-column: 2;
grid-row: 2;
width: 84px;
border-radius: 15px;
}
.piece-count {
grid-row: 2;
margin: 0;
font-size: 1.18rem;
}
.player-one .piece-count {
grid-column: 3;
justify-self: start;
}
.player-two .piece-count {
grid-column: 1;
justify-self: end;
}
.tool-button {
width: 42px;
}
}
@media (max-width: 620px) {
.start-card {
padding: 20px 16px 24px;
}
.start-topline {
align-items: flex-start;
}
.brand-signature {
max-width: 160px;
font-size: 0.65rem;
}
.rules-grid {
grid-template-columns: minmax(0, 1fr);
}
.rule-block.wide {
grid-column: auto;
}
.modal-actions {
flex-direction: column-reverse;
}
.modal-actions button {
width: 100%;
}
}
@media (max-width: 370px) {
.round-strip {
gap: 4px;
padding-left: 8px;
}
.round-label,
.turn-pill,
.result-reopen {
font-size: 0.68rem;
}
.lang-switch.compact .lang-option {
min-width: 30px;
padding-right: 5px;
padding-left: 5px;
font-size: 0.67rem;
}
.player-panel {
padding-right: 8px;
padding-left: 8px;
}
.piece-name {
font-size: 0.63rem;
}
.score-value {
font-size: 2rem;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<div class="app-shell">
<div class="ambient-shape one" aria-hidden="true"></div>
<div class="ambient-shape two" aria-hidden="true"></div>
<!-- Start page -->
<section id="startScreen" class="start-screen">
<div class="start-card">
<div class="start-topline">
<div class="brand-signature">
<span class="brand-mark" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</span>
<span id="startEyebrow"></span>
</div>
<div class="lang-switch" role="group">
<button type="button" class="lang-option" data-set-lang="en">EN</button>
<button type="button" class="lang-option" data-set-lang="zh">中文</button>
</div>
</div>
<header class="hero">
<h1 id="mainTitle"></h1>
<p id="subtitle" class="subtitle"></p>
</header>
<section class="rules-section">
<h2 id="rulesTitle" class="section-heading"></h2>
<div id="rulesContent"></div>
</section>
<div class="start-actions">
<button id="startButton" type="button" class="primary-button start-button"></button>
</div>
</div>
</section>
<!-- Game page -->
<section id="gameScreen" class="game-screen" hidden>
<main class="game-layout">
<!-- Player 2: left on desktop, top on mobile -->
<aside id="player2Panel" class="player-panel player-two">
<div id="scoreCard2" class="score-card">
<span id="playerName2" class="player-name"></span>
<div class="score-center">
<strong id="score2" class="score-value">0</strong>
<span id="scoreLabel2" class="score-label"></span>
</div>
<span class="turn-dot" aria-hidden="true"></span>
</div>
<div class="pieces-list">
<div class="piece-item" data-player="2" data-type="fino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="2" data-type="fino"></button>
<span class="piece-count"></span>
</div>
<div class="piece-item" data-player="2" data-type="vino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="2" data-type="vino"></button>
<span class="piece-count"></span>
</div>
<div class="piece-item" data-player="2" data-type="mino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="2" data-type="mino"></button>
<span class="piece-count"></span>
</div>
</div>
</aside>
<!-- Board -->
<section class="board-zone">
<div class="round-strip">
<span id="roundText" class="round-label"></span>
<span id="turnText" class="turn-pill"></span>
<button
id="viewResultButton"
type="button"
class="result-reopen"
hidden
></button>
<div class="lang-switch compact" role="group">
<button type="button" class="lang-option" data-set-lang="en">EN</button>
<button type="button" class="lang-option" data-set-lang="zh">中文</button>
</div>
</div>
<div class="board-shell">
<svg
id="boardSvg"
width="480"
height="480"
viewBox="0 0 480 480"
preserveAspectRatio="xMidYMid meet"
role="img"
>
<title id="boardSvgTitle"></title>
<defs>
<clipPath id="boardClip">
<rect x="0" y="0" width="480" height="480" rx="17"></rect>
</clipPath>
</defs>
<rect class="board-base" x="0" y="0" width="480" height="480"></rect>
<g id="placedLayer" clip-path="url(#boardClip)"></g>
<g id="previewLayer" clip-path="url(#boardClip)"></g>
<g id="gridLayer" pointer-events="none"></g>
<rect
class="board-border"
x="1.5"
y="1.5"
width="477"
height="477"
rx="16"
pointer-events="none"
></rect>
</svg>
</div>
<!-- SVG icon toolbar -->
<nav class="toolbar">
<button
id="undoButton"
type="button"
class="tool-button"
data-label-key="toolUndo"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 7H4V2"></path>
<path d="M4.4 6.7A9 9 0 1 1 4 16"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
id="redoButton"
type="button"
class="tool-button"
data-label-key="toolRedo"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 7h5V2"></path>
<path d="M19.6 6.7A9 9 0 1 0 20 16"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolAI"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3v3"></path>
<circle cx="12" cy="2.5" r="1"></circle>
<rect x="5" y="6" width="14" height="13" rx="3"></rect>
<circle cx="9.5" cy="11.5" r="1"></circle>
<circle cx="14.5" cy="11.5" r="1"></circle>
<path d="M9 16h6"></path>
<path d="M3 10v5M21 10v5"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolExport"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 3h7l4 4v14H7z"></path>
<path d="M14 3v5h5"></path>
<path d="M10 12h5M10 16h5"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolDownloadSVG"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3v12"></path>
<path d="m8 11 4 4 4-4"></path>
<path d="M5 19h14"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolRestore"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="6" width="14" height="13" rx="2"></rect>
<path d="M8 6V4h12v11h-2"></path>
<path d="M8 10h6v5H8z"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolAutoRotate"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 8a8 8 0 0 1 13-2"></path>
<path d="M18 2v5h-5"></path>
<path d="M19 16a8 8 0 0 1-13 2"></path>
<path d="M6 22v-5h5"></path>
</svg>
<span class="sr-only"></span>
</button>
</nav>
</section>
<!-- Player 1: right on desktop, bottom on mobile -->
<aside id="player1Panel" class="player-panel player-one">
<div id="scoreCard1" class="score-card">
<span id="playerName1" class="player-name"></span>
<div class="score-center">
<strong id="score1" class="score-value">0</strong>
<span id="scoreLabel1" class="score-label"></span>
</div>
<span class="turn-dot" aria-hidden="true"></span>
</div>
<div class="pieces-list">
<div class="piece-item" data-player="1" data-type="fino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="1" data-type="fino"></button>
<span class="piece-count"></span>
</div>
<div class="piece-item" data-player="1" data-type="vino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="1" data-type="vino"></button>
<span class="piece-count"></span>
</div>
<div class="piece-item" data-player="1" data-type="mino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="1" data-type="mino"></button>
<span class="piece-count"></span>
</div>
</div>
</aside>
</main>
</section>
</div>
<!-- 2.4-second message box -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<!-- Round/final result dialog -->
<div id="modalBackdrop" class="modal-backdrop" hidden>
<section
class="result-modal"
role="dialog"
aria-modal="true"
aria-labelledby="modalTitle"
>
<div class="lang-switch compact modal-lang" role="group">
<button type="button" class="lang-option" data-set-lang="en">EN</button>
<button type="button" class="lang-option" data-set-lang="zh">中文</button>
</div>
<button id="modalClose" type="button" class="modal-close">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6 18 18M18 6 6 18"></path>
</svg>
<span class="sr-only"></span>
</button>
<p id="modalEyebrow" class="modal-eyebrow"></p>
<h2 id="modalTitle"></h2>
<p id="modalSummary" class="modal-summary"></p>
<p id="modalSectionLabel" class="modal-section-label"></p>
<div id="modalScores" class="modal-score-grid"></div>
<div id="modalBreakdown" class="modal-breakdown" hidden></div>
<div class="modal-actions">
<button id="modalSecondary" type="button" class="secondary-button"></button>
<button id="modalPrimary" type="button" class="primary-button"></button>
</div>
</section>
</div>
<script>
"use strict";
const SVG_NS = "http://www.w3.org/2000/svg";
const BOARD_SIZE = 10;
const CELL_SIZE = 48;
const PIECE_ORDER = ["fino", "vino", "mino"];
const INITIAL_COUNTS = Object.freeze({
fino: 5,
vino: 7,
mino: 3
});
const I18N = {
en: {
documentTitle: "Vino Square",
languageLabel: "Language",
startEyebrow: "Abstract strategy game",
title: "Vino Square",
subtitle: "Fino, Vino, and Mino Play on the Square.",
rulesTitle: "How to play",
startGame: "Start Game",
player1: "Player 1",
player2: "Player 2",
score: "Score",
pieceFino: "Fino",
pieceVino: "Vino",
pieceMino: "Mino",
roundOf: "Round {round} of 2",
turn: "{player}'s turn",
roundCompleteStatus: "Round {round} complete",
matchCompleteStatus: "Match complete",
viewRoundResult: "View round result",
viewFinalResult: "View final result",
boardReady: "Ready to start.",
boardAria: "10 by 10 Vino Square board. {status}",
pieceCountAria: "{player}, {piece}, {count} remaining.",
toolUndo: "Undo",
toolRedo: "Redo",
toolAI: "AI settings",
toolExport: "Export text game record",
toolDownloadSVG: "Download SVG",
toolRestore: "Restore window",
toolAutoRotate: "Auto rotate",
notTurn: "It is {player}'s turn.",
mustFinish: "You must place all {piece} pieces first.",
noPieces: "No {piece} pieces remain.",
outOfBounds: "That piece would extend beyond the board.",
overlap: "That piece would overlap an occupied cell.",
selectPiece: "Select a piece before choosing a board cell.",
comingSoon: "This tool is reserved for a future version.",
roundCompleteEyebrow: "Round {round} complete",
winsRound: "{player} wins the round",
tiedRound: "The round is tied",
noLegalSummary:
"{loser} has no legal placement for {piece}, so {winner} wins this round immediately.",
allPlacedWinSummary:
"All pieces have been placed. {winner} has the higher score.",
allPlacedTieSummary:
"All pieces have been placed with equal scores.",
roundScores: "Round scores",
nextRound: "Play Round 2 · {player} starts",
endGame: "End Game",
finishMatch: "Finish Match",
reviewBoard: "Review Board",
finalEyebrow: "Match complete",
winsMatch: "{player} wins Vino Square",
tiedMatch: "The match is tied",
totalSummaryOne: "Combined result after 1 completed round.",
totalSummaryTwo: "Combined result after 2 completed rounds.",
totalScores: "Total scores",
roundColumn: "Round",
roundShort: "Round {round}",
total: "Total",
playAgain: "Play Again",
backToTitle: "Back to Title",
closeDialog: "Close dialog",
rulesHtml: `
<div class="rules-grid">
<article class="rule-block">
<span class="rule-index">01</span>
<h3>Objective</h3>
<p>
Place your pieces on the 10 × 10 board and complete as many
filled squares as possible. The higher combined score after
two rounds wins the match.
</p>
</article>
<article class="rule-block">
<span class="rule-index">02</span>
<h3>Pieces</h3>
<ul>
<li><strong>Fino × 5:</strong> five cells forming a V: one corner cell with two cells along each arm.</li>
<li><strong>Vino × 7:</strong> three cells forming a small V: one corner cell and one cell along each arm.</li>
<li><strong>Mino × 3:</strong> one single cell.</li>
</ul>
</article>
<article class="rule-block">
<span class="rule-index">03</span>
<h3>Turn order and placement</h3>
<p>
Players alternate placing one piece. All Fino pieces must be
used before Vino, and all Vino pieces before Mino. Pieces may
not overlap or extend beyond the board. A player with no legal
placement for the required piece loses that round immediately.
</p>
</article>
<article class="rule-block">
<span class="rule-index">04</span>
<h3>Scoring</h3>
<p>
Every occupied cell scores 1 point. Every fully occupied
2 × 2 square scores 4, every 3 × 3 square scores 9, and so on.
Every valid position counts, including overlapping and repeated
square patterns. A 2 × 3 area contains two 2 × 2 squares; a
3 × 3 area contains four 2 × 2 squares and one 3 × 3 square.
Placing all of your pieces therefore guarantees 49 base points.
</p>
</article>
<article class="rule-block">
<span class="rule-index">05</span>
<h3>Two-round match</h3>
<p>
Player 1 starts Round 1. Player 2 starts Round 2. The board and
inventories reset between rounds. Add both round scores; the
player with the higher total wins.
</p>
</article>
<article class="rule-block">
<span class="rule-index">06</span>
<h3>Controls</h3>
<p>
A Fino or Vino frame is divided into four clickable quadrants.
Click a quadrant to aim the corner of the V toward it. Clicking
the same selected quadrant again cancels the selection. Click
Mino once to select it and again to cancel. Then click a board
cell; for Fino and Vino, that cell becomes the V's corner.
</p>
</article>
</div>
`
},
zh: {
documentTitle: "維諾廣場",
languageLabel: "語言",
startEyebrow: "抽象策略遊戲",
title: "維諾廣場",
subtitle: "法諾、維諾、米諾在廣場上玩",
rulesTitle: "遊戲規則",
startGame: "開始遊戲",
player1: "玩家一",
player2: "玩家二",
score: "分數",
pieceFino: "法諾",
pieceVino: "維諾",
pieceMino: "米諾",
roundOf: "第 {round} 局/共 2 局",
turn: "輪到{player}",
roundCompleteStatus: "第 {round} 局結束",
matchCompleteStatus: "比賽結束",
viewRoundResult: "查看本局結果",
viewFinalResult: "查看最終結果",
boardReady: "準備開始。",
boardAria: "10 乘 10 的維諾廣場棋盤。{status}",
pieceCountAria: "{player}的{piece},剩餘 {count} 個。",
toolUndo: "回退",
toolRedo: "前進",
toolAI: "AI 設置",
toolExport: "導出文本棋譜",
toolDownloadSVG: "下載 SVG",
toolRestore: "恢復窗口",
toolAutoRotate: "自動旋轉",
notTurn: "現在輪到{player}。",
mustFinish: "必須先放完所有{piece}。",
noPieces: "已沒有剩餘的{piece}。",
outOfBounds: "這個棋子會超出棋盤邊界。",
overlap: "這個棋子會與已佔用的格子重疊。",
selectPiece: "請先選擇棋子,再點擊棋盤格子。",
comingSoon: "此工具將在之後的版本中實現。",
roundCompleteEyebrow: "第 {round} 局結束",
winsRound: "{player}贏得本局",
tiedRound: "本局平手",
noLegalSummary:
"{loser}無法合規放置{piece},因此{winner}立即贏得本局。",
allPlacedWinSummary:
"所有棋子均已放置完成,{winner}的分數較高。",
allPlacedTieSummary:
"所有棋子均已放置完成,雙方分數相同。",
roundScores: "本局分數",
nextRound: "進行第二局・由{player}先手",
endGame: "結束遊戲",
finishMatch: "統計最終結果",
reviewBoard: "查看棋盤",
finalEyebrow: "比賽結束",
winsMatch: "{player}贏得維諾廣場",
tiedMatch: "本場比賽平手",
totalSummaryOne: "以下為完成 1 局後的合計結果。",
totalSummaryTwo: "以下為完成 2 局後的合計結果。",
totalScores: "總分",
roundColumn: "局數",
roundShort: "第 {round} 局",
total: "合計",
playAgain: "再玩一次",
backToTitle: "返回開始頁面",
closeDialog: "關閉窗口",
rulesHtml: `
<div class="rules-grid">
<article class="rule-block">
<span class="rule-index">01</span>
<h3>遊戲目標</h3>
<p>
在 10 × 10 棋盤上放置棋子,圍成盡可能多的完整正方形。
兩局結束後合計分數,總分較高者獲勝。
</p>
</article>
<article class="rule-block">
<span class="rule-index">02</span>
<h3>棋子</h3>
<ul>
<li><strong>法諾 × 5:</strong>五個正方格組成 V 形;一個角格沿兩條邊各連接兩格。</li>
<li><strong>維諾 × 7:</strong>三個正方格組成小 V 形;一個角格沿兩條邊各連接一格。</li>
<li><strong>米諾 × 3:</strong>單獨一個正方格。</li>
</ul>
</article>
<article class="rule-block">
<span class="rule-index">03</span>
<h3>回合與落子順序</h3>
<p>
雙方輪流放置一個棋子。必須先放完法諾,才能放維諾;
放完維諾後才能放米諾。棋子不能重疊,也不能超出棋盤。
如果一方無法合規放置目前必須使用的棋子,該方立即輸掉本局。
</p>
</article>
<article class="rule-block">
<span class="rule-index">04</span>
<h3>計分</h3>
<p>
每個已佔用的正方格計 1 分。每個完整 2 × 2 正方形計 4 分,
3 × 3 計 9 分,依此類推。所有不同位置均可計分,重疊或重複
的正方形也會分別計算。例如 2 × 3 區域包含兩個 2 × 2;
3 × 3 區域包含四個 2 × 2 和一個 3 × 3。每方若放完所有
棋子,至少會有 49 分基礎分。
</p>
</article>
<article class="rule-block">
<span class="rule-index">05</span>
<h3>兩局賽制</h3>
<p>
第一局由玩家一先手,第二局改由玩家二先手。第二局會重置
棋盤和棋子數量。完成後合計兩局分數,總分較高者獲勝。
</p>
</article>
<article class="rule-block">
<span class="rule-index">06</span>
<h3>操作方法</h3>
<p>
法諾和維諾的棋子框分為四個象限。點擊某個象限,V 形角格
就會朝向該象限;再次點擊相同象限會取消選取。米諾點擊
一次選取,再次點擊取消。選取後點擊棋盤格落子;法諾與
維諾會以所點擊的棋盤格作為 V 形角格。
</p>
</article>
</div>
`
}
};
function createEmptyBoard() {
return Array.from(
{ length: BOARD_SIZE },
() => Array(BOARD_SIZE).fill(0)
);
}
function createInventory() {
return {
1: { ...INITIAL_COUNTS },
2: { ...INITIAL_COUNTS }
};
}
function createOrientationMemory() {
return {
1: { fino: "tl", vino: "tl", mino: "single" },
2: { fino: "tl", vino: "tl", mino: "single" }
};
}
const state = {
lang: "en",
gameStarted: false,
gameOver: false,
roundNumber: 1,
currentPlayer: 1,
board: createEmptyBoard(),
inventory: createInventory(),
scores: { 1: 0, 2: 0 },
selected: null,
hoverCell: null,
orientationMemory: createOrientationMemory(),
roundEnded: false,
endInfo: null,
roundResults: [],
history: [],
historyIndex: 0,
modalMode: null,
toastData: null,
toastTimer: null
};
const els = {
startScreen: document.getElementById("startScreen"),
gameScreen: document.getElementById("gameScreen"),
startEyebrow: document.getElementById("startEyebrow"),
mainTitle: document.getElementById("mainTitle"),
subtitle: document.getElementById("subtitle"),
rulesTitle: document.getElementById("rulesTitle"),
rulesContent: document.getElementById("rulesContent"),
startButton: document.getElementById("startButton"),
player1Panel: document.getElementById("player1Panel"),
player2Panel: document.getElementById("player2Panel"),
playerName1: document.getElementById("playerName1"),
playerName2: document.getElementById("playerName2"),
score1: document.getElementById("score1"),
score2: document.getElementById("score2"),
scoreLabel1: document.getElementById("scoreLabel1"),
scoreLabel2: document.getElementById("scoreLabel2"),
scoreCard1: document.getElementById("scoreCard1"),
scoreCard2: document.getElementById("scoreCard2"),
roundText: document.getElementById("roundText"),
turnText: document.getElementById("turnText"),
viewResultButton: document.getElementById("viewResultButton"),
boardSvg: document.getElementById("boardSvg"),
boardSvgTitle: document.getElementById("boardSvgTitle"),
placedLayer: document.getElementById("placedLayer"),
previewLayer: document.getElementById("previewLayer"),
gridLayer: document.getElementById("gridLayer"),
undoButton: document.getElementById("undoButton"),
redoButton: document.getElementById("redoButton"),
toast: document.getElementById("toast"),
modalBackdrop: document.getElementById("modalBackdrop"),
modalClose: document.getElementById("modalClose"),
modalEyebrow: document.getElementById("modalEyebrow"),
modalTitle: document.getElementById("modalTitle"),
modalSummary: document.getElementById("modalSummary"),
modalSectionLabel: document.getElementById("modalSectionLabel"),
modalScores: document.getElementById("modalScores"),
modalBreakdown: document.getElementById("modalBreakdown"),
modalPrimary: document.getElementById("modalPrimary"),
modalSecondary: document.getElementById("modalSecondary")
};
function playerToken(player) {
return { kind: "player", value: player };
}
function pieceToken(piece) {
return { kind: "piece", value: piece };
}
function playerName(player) {
return translate(player === 1 ? "player1" : "player2");
}
function pieceName(piece) {
const key = {
fino: "pieceFino",
vino: "pieceVino",
mino: "pieceMino"
}[piece];
return translate(key);
}
function resolveTranslationValue(value) {
if (value && typeof value === "object") {
if (value.kind === "player") {
return playerName(value.value);
}
if (value.kind === "piece") {
return pieceName(value.value);
}
}
return String(value ?? "");
}
function translate(key, variables = {}) {
const language = I18N[state.lang] || I18N.en;
let text = language[key] ?? I18N.en[key] ?? key;
if (typeof text !== "string") {
return text;
}
return text.replace(/\{(\w+)\}/g, (match, variableName) => {
if (!(variableName in variables)) {
return match;
}
return resolveTranslationValue(variables[variableName]);
});
}
function applyLanguage(language) {
state.lang = language === "zh" ? "zh" : "en";
document.documentElement.lang =
state.lang === "zh" ? "zh-Hant" : "en";
document.title = translate("documentTitle");
els.startEyebrow.textContent = translate("startEyebrow");
els.mainTitle.textContent = translate("title");
els.subtitle.textContent = translate("subtitle");
els.rulesTitle.textContent = translate("rulesTitle");
els.rulesContent.innerHTML = I18N[state.lang].rulesHtml;
els.startButton.textContent = translate("startGame");
document.querySelectorAll(".lang-switch").forEach((switchElement) => {
switchElement.setAttribute("aria-label", translate("languageLabel"));
});
document.querySelectorAll("[data-set-lang]").forEach((button) => {
const active = button.dataset.setLang === state.lang;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
document.querySelectorAll("[data-label-key]").forEach((button) => {
const label = translate(button.dataset.labelKey);
const hiddenLabel = button.querySelector(".sr-only");
button.dataset.tooltip = label;
button.setAttribute("aria-label", label);
if (hiddenLabel) {
hiddenLabel.textContent = label;
}
});
els.modalClose.setAttribute("aria-label", translate("closeDialog"));
els.modalClose.querySelector(".sr-only").textContent =
translate("closeDialog");
renderAll();
if (state.toastData) {
updateToastText();
}
if (!els.modalBackdrop.hidden) {
if (state.modalMode === "round") {
showRoundModal(false);
} else if (state.modalMode === "final") {
showFinalModal(false);
}
}
}
function buildBoardGrid() {
let pathData = "";
for (let index = 1; index < BOARD_SIZE; index += 1) {
const position = index * CELL_SIZE;
pathData += `M ${position} 0 V 480 `;
pathData += `M 0 ${position} H 480 `;
}
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", pathData.trim());
path.setAttribute("class", "grid-lines");
els.gridLayer.appendChild(path);
}
/**
* Offsets are expressed relative to the clicked corner cell.
* tl = corner at top-left, arms extend right and down.
*/
function getPieceOffsets(type, orientation) {
if (type === "mino") {
return [[0, 0]];
}
const armLength = type === "fino" ? 2 : 1;
const directions = {
tl: [[1, 0], [0, 1]],
tr: [[-1, 0], [0, 1]],
br: [[-1, 0], [0, -1]],
bl: [[1, 0], [0, -1]]
};
const [horizontal, vertical] =
directions[orientation] || directions.tl;
const offsets = [[0, 0]];
for (let step = 1; step <= armLength; step += 1) {
offsets.push([
horizontal[0] * step,
horizontal[1] * step
]);
offsets.push([
vertical[0] * step,
vertical[1] * step
]);
}
return offsets;
}
function renderMiniPiece(button, type, orientation, player) {
button.replaceChildren();
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 100 100");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
const offsets = getPieceOffsets(type, orientation);
const xs = offsets.map(([x]) => x);
const ys = offsets.map(([, y]) => y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const cellSize =
type === "fino" ? 22 :
type === "vino" ? 29 :
38;
const gap = type === "mino" ? 0 : 3;
const step = cellSize + gap;
const totalWidth = (maxX - minX) * step + cellSize;
const totalHeight = (maxY - minY) * step + cellSize;
const originX = (100 - totalWidth) / 2 - minX * step;
const originY = (100 - totalHeight) / 2 - minY * step;
offsets.forEach(([x, y], index) => {
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", originX + x * step);
rect.setAttribute("y", originY + y * step);
rect.setAttribute("width", cellSize);
rect.setAttribute("height", cellSize);
rect.setAttribute("rx", type === "mino" ? 7 : 4);
rect.setAttribute(
"class",
`mini-cell owner-${player}${index === 0 ? " anchor" : ""}`
);
svg.appendChild(rect);
if (index === 0 && type !== "mino") {
const dot = document.createElementNS(SVG_NS, "circle");
dot.setAttribute("cx", originX + x * step + cellSize / 2);
dot.setAttribute("cy", originY + y * step + cellSize / 2);
dot.setAttribute("r", 2.6);
dot.setAttribute("class", "anchor-dot");
svg.appendChild(dot);
}
});
button.appendChild(svg);
}
function getRequiredPiece(player) {
if (!player || !state.inventory[player]) {
return null;
}
for (const type of PIECE_ORDER) {
if (state.inventory[player][type] > 0) {
return type;
}
}
return null;
}
function totalRemaining(player) {
return PIECE_ORDER.reduce(
(total, type) => total + state.inventory[player][type],
0
);
}
function allPiecesPlaced() {
return totalRemaining(1) === 0 && totalRemaining(2) === 0;
}
function otherPlayer(player) {
return player === 1 ? 2 : 1;
}
function validatePlacement(player, type, orientation, anchorRow, anchorCol) {
const offsets = getPieceOffsets(type, orientation);
const cells = offsets.map(([dx, dy]) => ({
row: anchorRow + dy,
col: anchorCol + dx
}));
const outOfBounds = cells.some(({ row, col }) => (
row < 0 ||
row >= BOARD_SIZE ||
col < 0 ||
col >= BOARD_SIZE
));
if (outOfBounds) {
return {
ok: false,
reason: "bounds",
cells
};
}
const overlaps = cells.some(({ row, col }) => (
state.board[row][col] !== 0
));
if (overlaps) {
return {
ok: false,
reason: "overlap",
cells
};
}
return {
ok: true,
reason: null,
cells
};
}
function hasAnyLegalMove(player) {
const type = getRequiredPiece(player);
if (!type) {
return false;
}
const orientations =
type === "mino"
? ["single"]
: ["tl", "tr", "br", "bl"];
for (const orientation of orientations) {
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
if (
validatePlacement(
player,
type,
orientation,
row,
col
).ok
) {
return true;
}
}
}
}
return false;
}
/**
* Score every fully occupied k × k square owned by the player.
* Each valid position adds k², including k = 1.
*/
function calculateScore(player) {
let score = 0;
for (let size = 1; size <= BOARD_SIZE; size += 1) {
const squareScore = size * size;
for (
let startRow = 0;
startRow <= BOARD_SIZE - size;
startRow += 1
) {
for (
let startCol = 0;
startCol <= BOARD_SIZE - size;
startCol += 1
) {
let complete = true;
for (
let row = startRow;
row < startRow + size && complete;
row += 1
) {
for (
let col = startCol;
col < startCol + size;
col += 1
) {
if (state.board[row][col] !== player) {
complete = false;
break;
}
}
}
if (complete) {
score += squareScore;
}
}
}
}
return score;
}
function renderScores() {
els.playerName1.textContent = playerName(1);
els.playerName2.textContent = playerName(2);
els.scoreLabel1.textContent = translate("score");
els.scoreLabel2.textContent = translate("score");
els.score1.textContent = String(state.scores[1]);
els.score2.textContent = String(state.scores[2]);
const player1Active =
state.gameStarted &&
!state.roundEnded &&
!state.gameOver &&
state.currentPlayer === 1;
const player2Active =
state.gameStarted &&
!state.roundEnded &&
!state.gameOver &&
state.currentPlayer === 2;
els.scoreCard1.classList.toggle("active", player1Active);
els.scoreCard2.classList.toggle("active", player2Active);
els.player1Panel.setAttribute("aria-label", playerName(1));
els.player2Panel.setAttribute("aria-label", playerName(2));
}
function renderPieces() {
document.querySelectorAll(".piece-item").forEach((item) => {
const player = Number(item.dataset.player);
const type = item.dataset.type;
const count = state.inventory[player][type];
const requiredPiece = getRequiredPiece(player);
const frame = item.querySelector(".piece-frame");
const nameElement = item.querySelector(".piece-name");
const countElement = item.querySelector(".piece-count");
const orientation =
state.orientationMemory[player][type] ||
(type === "mino" ? "single" : "tl");
const selected = Boolean(
state.selected &&
state.selected.player === player &&
state.selected.type === type
);
const activePlayer =
state.gameStarted &&
!state.roundEnded &&
!state.gameOver &&
state.currentPlayer === player;
const locked =
count > 0 &&
requiredPiece !== null &&
requiredPiece !== type;
nameElement.textContent = pieceName(type);
countElement.textContent = `×${count}`;
item.classList.toggle("selected", selected);
item.classList.toggle("available", activePlayer && !locked && count > 0);
item.classList.toggle("locked", locked);
item.classList.toggle("not-current", !activePlayer);
item.classList.toggle("depleted", count === 0);
frame.setAttribute("aria-pressed", String(selected));
frame.setAttribute(
"aria-label",
translate("pieceCountAria", {
player: playerToken(player),
piece: pieceToken(type),
count
})
);
frame.title = frame.getAttribute("aria-label");
renderMiniPiece(frame, type, orientation, player);
});
}
function renderBoard() {
const fragment = document.createDocumentFragment();
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
const owner = state.board[row][col];
if (!owner) {
continue;
}
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", col * CELL_SIZE + 2.5);
rect.setAttribute("y", row * CELL_SIZE + 2.5);
rect.setAttribute("width", CELL_SIZE - 5);
rect.setAttribute("height", CELL_SIZE - 5);
rect.setAttribute("rx", 7);
rect.setAttribute(
"class",
`placed-cell owner-${owner}`
);
fragment.appendChild(rect);
}
}
els.placedLayer.replaceChildren(fragment);
renderPreview();
let status = translate("boardReady");
if (state.gameStarted) {
if (state.gameOver) {
status = translate("matchCompleteStatus");
} else if (state.roundEnded) {
status = translate("roundCompleteStatus", {
round: state.roundNumber
});
} else {
status = translate("turn", {
player: playerToken(state.currentPlayer)
});
}
}
const boardDescription = translate("boardAria", { status });
els.boardSvgTitle.textContent = boardDescription;
els.boardSvg.setAttribute("aria-label", boardDescription);
}
function renderPreview() {
els.previewLayer.replaceChildren();
if (
!state.selected ||
!state.hoverCell ||
state.roundEnded ||
state.gameOver
) {
return;
}
const validation = validatePlacement(
state.selected.player,
state.selected.type,
state.selected.orientation,
state.hoverCell.row,
state.hoverCell.col
);
const fragment = document.createDocumentFragment();
validation.cells.forEach(({ row, col }, index) => {
if (
row < 0 ||
row >= BOARD_SIZE ||
col < 0 ||
col >= BOARD_SIZE
) {
return;
}
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", col * CELL_SIZE + 4);
rect.setAttribute("y", row * CELL_SIZE + 4);
rect.setAttribute("width", CELL_SIZE - 8);
rect.setAttribute("height", CELL_SIZE - 8);
rect.setAttribute("rx", 7);
rect.setAttribute(
"class",
`preview-cell ${validation.ok ? "valid" : "invalid"}${
index === 0 ? " anchor-preview" : ""
}`
);
fragment.appendChild(rect);
});
els.previewLayer.appendChild(fragment);
}
function renderStatus() {
if (!state.gameStarted) {
return;
}
if (state.gameOver) {
els.roundText.textContent = translate("matchCompleteStatus");
} else if (state.roundEnded) {
els.roundText.textContent = translate("roundCompleteStatus", {
round: state.roundNumber
});
} else {
els.roundText.textContent = translate("roundOf", {
round: state.roundNumber
});
}
if (state.roundEnded || state.gameOver) {
els.turnText.hidden = true;
els.viewResultButton.hidden = false;
els.viewResultButton.textContent = state.gameOver
? translate("viewFinalResult")
: translate("viewRoundResult");
} else {
els.viewResultButton.hidden = true;
els.turnText.hidden = false;
els.turnText.textContent = translate("turn", {
player: playerToken(state.currentPlayer)
});
els.turnText.classList.remove("owner-1", "owner-2");
els.turnText.classList.add(`owner-${state.currentPlayer}`);
}
}
function updateToolbar() {
els.undoButton.disabled =
!state.gameStarted ||
state.gameOver ||
state.historyIndex <= 0;
els.redoButton.disabled =
!state.gameStarted ||
state.gameOver ||
state.historyIndex >= state.history.length - 1;
}
function renderAll() {
renderScores();
renderPieces();
renderBoard();
renderStatus();
updateToolbar();
}
function getBoardCellFromPointer(event) {
const screenMatrix = els.boardSvg.getScreenCTM();
if (!screenMatrix) {
return null;
}
const point = els.boardSvg.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
const transformed = point.matrixTransform(screenMatrix.inverse());
const col = Math.floor(transformed.x / CELL_SIZE);
const row = Math.floor(transformed.y / CELL_SIZE);
if (
row < 0 ||
row >= BOARD_SIZE ||
col < 0 ||
col >= BOARD_SIZE
) {
return null;
}
return { row, col };
}
function getOrientationFromFrameClick(event, frame) {
if (event.detail === 0) {
const player = Number(frame.dataset.player);
const type = frame.dataset.type;
return state.orientationMemory[player][type] || "tl";
}
const rect = frame.getBoundingClientRect();
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
const left = localX < rect.width / 2;
const top = localY < rect.height / 2;
if (top && left) return "tl";
if (top && !left) return "tr";
if (!top && !left) return "br";
return "bl";
}
function handlePieceFrameClick(event, frame) {
if (!state.gameStarted) {
return;
}
if (state.roundEnded || state.gameOver) {
openCurrentResult();
return;
}
const player = Number(frame.dataset.player);
const type = frame.dataset.type;
if (player !== state.currentPlayer) {
showToast("notTurn", {
player: playerToken(state.currentPlayer)
});
return;
}
const count = state.inventory[player][type];
if (count <= 0) {
showToast("noPieces", {
piece: pieceToken(type)
});
return;
}
const requiredPiece = getRequiredPiece(player);
if (type !== requiredPiece) {
showToast("mustFinish", {
piece: pieceToken(requiredPiece)
});
return;
}
const orientation =
type === "mino"
? "single"
: getOrientationFromFrameClick(event, frame);
state.orientationMemory[player][type] = orientation;
const sameSelection = Boolean(
state.selected &&
state.selected.player === player &&
state.selected.type === type &&
state.selected.orientation === orientation
);
if (sameSelection) {
state.selected = null;
state.hoverCell = null;
} else {
state.selected = {
player,
type,
orientation
};
}
renderPieces();
renderPreview();
}
function completeRoundByScore() {
let winner = 0;
if (state.scores[1] > state.scores[2]) {
winner = 1;
} else if (state.scores[2] > state.scores[1]) {
winner = 2;
}
state.roundEnded = true;
state.currentPlayer = 0;
state.endInfo = {
reason: "allPlaced",
winner,
loser: winner ? otherPlayer(winner) : 0,
requiredType: null
};
}
function completeRoundByNoLegalMove(loser, requiredType) {
state.roundEnded = true;
state.endInfo = {
reason: "noLegal",
winner: otherPlayer(loser),
loser,
requiredType
};
}
function placeSelectedPiece(validation) {
const move = { ...state.selected };
const player = move.player;
validation.cells.forEach(({ row, col }) => {
state.board[row][col] = player;
});
state.inventory[player][move.type] -= 1;
state.scores[player] = calculateScore(player);
state.selected = null;
state.hoverCell = null;
if (allPiecesPlaced()) {
completeRoundByScore();
} else {
let nextPlayer = otherPlayer(player);
/*
* This fallback is normally unnecessary because both players have
* identical inventories and alternate turns. It keeps the state
* valid if a future rule variant changes the inventories.
*/
if (!getRequiredPiece(nextPlayer)) {
nextPlayer = player;
}
state.currentPlayer = nextPlayer;
const requiredPiece = getRequiredPiece(nextPlayer);
if (
requiredPiece &&
!hasAnyLegalMove(nextPlayer)
) {
completeRoundByNoLegalMove(nextPlayer, requiredPiece);
}
}
pushHistorySnapshot();
renderAll();
if (state.roundEnded) {
window.setTimeout(() => {
if (state.roundEnded && !state.gameOver) {
showRoundModal();
}
}, 120);
}
}
function handleBoardClick(event) {
if (!state.gameStarted) {
return;
}
if (state.roundEnded || state.gameOver) {
openCurrentResult();
return;
}
if (!state.selected) {
showToast("selectPiece");
return;
}
const cell = getBoardCellFromPointer(event);
if (!cell) {
return;
}
const validation = validatePlacement(
state.selected.player,
state.selected.type,
state.selected.orientation,
cell.row,
cell.col
);
if (!validation.ok) {
showToast(
validation.reason === "bounds"
? "outOfBounds"
: "overlap"
);
return;
}
placeSelectedPiece(validation);
}
function handleBoardPointerMove(event) {
if (
!state.selected ||
state.roundEnded ||
state.gameOver
) {
return;
}
const cell = getBoardCellFromPointer(event);
if (!cell) {
if (state.hoverCell) {
state.hoverCell = null;
renderPreview();
}
return;
}
if (
!state.hoverCell ||
state.hoverCell.row !== cell.row ||
state.hoverCell.col !== cell.col
) {
state.hoverCell = cell;
renderPreview();
}
}
function captureSnapshot() {
return {
board: state.board.map((row) => [...row]),
inventory: {
1: { ...state.inventory[1] },
2: { ...state.inventory[2] }
},
scores: {
1: state.scores[1],
2: state.scores[2]
},
currentPlayer: state.currentPlayer,
roundEnded: state.roundEnded,
endInfo: state.endInfo ? { ...state.endInfo } : null
};
}
function restoreSnapshot(snapshot) {
state.board = snapshot.board.map((row) => [...row]);
state.inventory = {
1: { ...snapshot.inventory[1] },
2: { ...snapshot.inventory[2] }
};
state.scores = {
1: snapshot.scores[1],
2: snapshot.scores[2]
};
state.currentPlayer = snapshot.currentPlayer;
state.roundEnded = snapshot.roundEnded;
state.endInfo = snapshot.endInfo
? { ...snapshot.endInfo }
: null;
state.selected = null;
state.hoverCell = null;
}
function initializeHistory() {
state.history = [captureSnapshot()];
state.historyIndex = 0;
}
function pushHistorySnapshot() {
state.history = state.history.slice(0, state.historyIndex + 1);
state.history.push(captureSnapshot());
state.historyIndex = state.history.length - 1;
}
function undoMove() {
if (
state.gameOver ||
state.historyIndex <= 0
) {
return;
}
hideModal();
state.historyIndex -= 1;
restoreSnapshot(state.history[state.historyIndex]);
renderAll();
}
function redoMove() {
if (
state.gameOver ||
state.historyIndex >= state.history.length - 1
) {
return;
}
hideModal();
state.historyIndex += 1;
restoreSnapshot(state.history[state.historyIndex]);
renderAll();
if (state.roundEnded) {
window.setTimeout(() => {
if (state.roundEnded && !state.gameOver) {
showRoundModal();
}
}, 100);
}
}
function startRound(roundNumber) {
hideModal();
state.roundNumber = roundNumber;
state.gameOver = false;
state.roundEnded = false;
state.endInfo = null;
state.board = createEmptyBoard();
state.inventory = createInventory();
state.scores = { 1: 0, 2: 0 };
state.selected = null;
state.hoverCell = null;
state.orientationMemory = createOrientationMemory();
state.currentPlayer = roundNumber === 1 ? 1 : 2;
initializeHistory();
renderAll();
window.scrollTo({ top: 0, behavior: "smooth" });
}
function startGame() {
state.gameStarted = true;
state.gameOver = false;
state.roundResults = [];
els.startScreen.hidden = true;
els.gameScreen.hidden = false;
startRound(1);
}
function getPendingRoundResult() {
if (!state.roundEnded || !state.endInfo) {
return null;
}
return {
round: state.roundNumber,
starter: state.roundNumber === 1 ? 1 : 2,
scores: {
1: state.scores[1],
2: state.scores[2]
},
endInfo: { ...state.endInfo }
};
}
function commitPendingRoundResult() {
const existing = state.roundResults.some(
(result) => result.round === state.roundNumber
);
if (existing) {
return;
}
const result = getPendingRoundResult();
if (result) {
state.roundResults.push(result);
state.roundResults.sort((a, b) => a.round - b.round);
}
}
function scoreCardsHTML(score1, score2) {
return `
<div class="modal-score player-one">
<span>${playerName(1)}</span>
<strong>${score1}</strong>
</div>
<div class="modal-score player-two">
<span>${playerName(2)}</span>
<strong>${score2}</strong>
</div>
`;
}
function showRoundModal(focusPrimary = true) {
const result = getPendingRoundResult();
if (!result) {
return;
}
state.modalMode = "round";
els.modalEyebrow.textContent = translate(
"roundCompleteEyebrow",
{ round: state.roundNumber }
);
if (result.endInfo.winner === 0) {
els.modalTitle.textContent = translate("tiedRound");
} else {
els.modalTitle.textContent = translate("winsRound", {
player: playerToken(result.endInfo.winner)
});
}
if (result.endInfo.reason === "noLegal") {
els.modalSummary.textContent = translate("noLegalSummary", {
loser: playerToken(result.endInfo.loser),
winner: playerToken(result.endInfo.winner),
piece: pieceToken(result.endInfo.requiredType)
});
} else if (result.endInfo.winner === 0) {
els.modalSummary.textContent =
translate("allPlacedTieSummary");
} else {
els.modalSummary.textContent =
translate("allPlacedWinSummary", {
winner: playerToken(result.endInfo.winner)
});
}
els.modalSectionLabel.textContent = translate("roundScores");
els.modalScores.innerHTML = scoreCardsHTML(
result.scores[1],
result.scores[2]
);
els.modalBreakdown.hidden = true;
els.modalBreakdown.replaceChildren();
els.modalSecondary.hidden = false;
if (state.roundNumber === 1) {
els.modalPrimary.textContent = translate("nextRound", {
player: playerToken(2)
});
els.modalSecondary.textContent = translate("endGame");
els.modalPrimary.onclick = () => {
commitPendingRoundResult();
startRound(2);
};
els.modalSecondary.onclick = () => {
finishMatch();
};
} else {
els.modalPrimary.textContent = translate("finishMatch");
els.modalSecondary.textContent = translate("reviewBoard");
els.modalPrimary.onclick = () => {
finishMatch();
};
els.modalSecondary.onclick = () => {
hideModal();
};
}
els.modalBackdrop.hidden = false;
if (focusPrimary) {
requestAnimationFrame(() => {
els.modalPrimary.focus();
});
}
}
function calculateTotals(results) {
return results.reduce(
(totals, result) => {
totals[1] += result.scores[1];
totals[2] += result.scores[2];
return totals;
},
{ 1: 0, 2: 0 }
);
}
function finishMatch() {
commitPendingRoundResult();
state.gameOver = true;
state.roundEnded = true;
renderAll();
showFinalModal();
}
function showFinalModal(focusPrimary = true) {
const results = [...state.roundResults].sort(
(a, b) => a.round - b.round
);
if (results.length === 0) {
return;
}
state.modalMode = "final";
const totals = calculateTotals(results);
let winner = 0;
if (totals[1] > totals[2]) {
winner = 1;
} else if (totals[2] > totals[1]) {
winner = 2;
}
els.modalEyebrow.textContent = translate("finalEyebrow");
els.modalTitle.textContent = winner
? translate("winsMatch", {
player: playerToken(winner)
})
: translate("tiedMatch");
els.modalSummary.textContent =
results.length === 1
? translate("totalSummaryOne")
: translate("totalSummaryTwo");
els.modalSectionLabel.textContent = translate("totalScores");
els.modalScores.innerHTML = scoreCardsHTML(
totals[1],
totals[2]
);
const rows = results.map((result) => `
<tr>
<td>${translate("roundShort", { round: result.round })}</td>
<td>${result.scores[1]}</td>
<td>${result.scores[2]}</td>
</tr>
`).join("");
els.modalBreakdown.innerHTML = `
<table class="result-table">
<thead>
<tr>
<th>${translate("roundColumn")}</th>
<th>${playerName(1)}</th>
<th>${playerName(2)}</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td>${translate("total")}</td>
<td>${totals[1]}</td>
<td>${totals[2]}</td>
</tr>
</tfoot>
</table>
`;
els.modalBreakdown.hidden = false;
els.modalPrimary.textContent = translate("playAgain");
els.modalSecondary.textContent = translate("backToTitle");
els.modalSecondary.hidden = false;
els.modalPrimary.onclick = () => {
state.roundResults = [];
state.gameOver = false;
state.gameStarted = true;
startRound(1);
};
els.modalSecondary.onclick = () => {
returnToTitle();
};
els.modalBackdrop.hidden = false;
if (focusPrimary) {
requestAnimationFrame(() => {
els.modalPrimary.focus();
});
}
}
function openCurrentResult() {
if (state.gameOver) {
showFinalModal();
} else if (state.roundEnded) {
showRoundModal();
}
}
function hideModal() {
els.modalBackdrop.hidden = true;
state.modalMode = null;
}
function returnToTitle() {
hideModal();
clearToast();
state.gameStarted = false;
state.gameOver = false;
els.gameScreen.hidden = true;
els.startScreen.hidden = false;
window.scrollTo({ top: 0, behavior: "smooth" });
requestAnimationFrame(() => {
els.startButton.focus();
});
}
function updateToastText() {
if (!state.toastData) {
return;
}
els.toast.textContent = translate(
state.toastData.key,
state.toastData.variables
);
}
/**
* All message boxes remain visible for exactly 2400 ms.
*/
function showToast(key, variables = {}) {
window.clearTimeout(state.toastTimer);
state.toastData = { key, variables };
updateToastText();
els.toast.classList.remove("show");
void els.toast.offsetWidth;
els.toast.classList.add("show");
state.toastTimer = window.setTimeout(() => {
els.toast.classList.remove("show");
state.toastData = null;
}, 2400);
}
function clearToast() {
window.clearTimeout(state.toastTimer);
state.toastData = null;
els.toast.classList.remove("show");
}
/* ---------- Events ---------- */
document.querySelectorAll("[data-set-lang]").forEach((button) => {
button.addEventListener("click", () => {
applyLanguage(button.dataset.setLang);
});
});
els.startButton.addEventListener("click", startGame);
els.gameScreen.addEventListener("click", (event) => {
const frame = event.target.closest(".piece-frame");
if (frame) {
handlePieceFrameClick(event, frame);
}
});
els.boardSvg.addEventListener("click", handleBoardClick);
els.boardSvg.addEventListener("pointermove", handleBoardPointerMove);
els.boardSvg.addEventListener("pointerleave", () => {
if (state.hoverCell) {
state.hoverCell = null;
renderPreview();
}
});
els.undoButton.addEventListener("click", undoMove);
els.redoButton.addEventListener("click", redoMove);
document.querySelectorAll("[data-placeholder-tool]").forEach((button) => {
button.addEventListener("click", () => {
showToast("comingSoon");
});
});
els.viewResultButton.addEventListener("click", openCurrentResult);
els.modalClose.addEventListener("click", hideModal);
els.modalBackdrop.addEventListener("click", (event) => {
if (event.target === els.modalBackdrop) {
hideModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !els.modalBackdrop.hidden) {
hideModal();
return;
}
if (
!state.gameStarted ||
state.gameOver ||
!(event.ctrlKey || event.metaKey)
) {
return;
}
if (event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) {
redoMove();
} else {
undoMove();
}
} else if (event.key.toLowerCase() === "y") {
event.preventDefault();
redoMove();
}
});
/* ---------- Initialization ---------- */
buildBoardGrid();
applyLanguage("en");
</script>
</body>
</html>更改與修復 一,頁面調整
在開始頁面中,規則介紹應為一個按鈕,當點擊時再彈出規則介紹的頁面。
當進入遊戲時,所有的元素都應顯示在頁面中,不能有滾動條。
把分數下面的「SCORE」去掉。
二,棋子繪製更改
把法諾和維諾棋子改為連成一片,再用線分割。角上仍保留圓角。
三,遊戲規則更改
當在放法諾時,本方的棋子之間不能相互接觸,包括角對角接觸。如果一方不能合規放完五個法諾,則直接判負。
當放完法諾後,要先放米諾,最後才放維諾。在放米諾時,也不能跟本方的棋子相互接觸,包括角對角接觸。
當進入維諾階段,在每次落子前,可以選擇本方的一個米諾移動一格。移動時可推動這個方向的所有剛性連結,但是不能推動另一個米諾或推出邊界。
也可以不移動米諾,只下維諾。
當一方認為他已經不能再落下一個維諾,則這一方的遊戲結束(可以最後移動一次米諾),但另一方仍然可以自行繼續,直至他也無法放置一個維諾。
四,控制流程更改
在雙方的控制區增加一個「結束本方遊戲」按鈕,當在輪到這一方時顯示。當按下這個按鈕,本方將不能再行動,同時切換到對方回合。
當輪到一方時,他將不用選擇所要放置的棋子,棋子將自動選取。
當一方在放置維諾階段,他如果點擊一個他的米諾,則在這個米諾能移動的方向將出現箭頭,點擊這個箭頭則會移動。
五,更改計分方式
計分除了計總分,還要標明22的數量,33的數量。
QuanAI以下為完整替換版單檔程式。已加入規則彈窗、無捲動遊戲版面、連體棋子、法諾/米諾接觸限制、自動選取、米諾移動與連鎖推動、玩家獨立結束,以及 2×2、3×3 數量統計。
其中「剛性連結」實作為:法諾與維諾各自是完整剛體;推動時若碰到其他法諾/維諾,會遞迴連鎖推動整個棋子;遇到另一個米諾或棋盤邊界則不能移動。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
>
<meta name="theme-color" content="#0b111b">
<title>Vino Square</title>
<style>
:root {
color-scheme: dark;
--app-height: 100dvh;
--bg: #0b111b;
--panel: #142033;
--panel-deep: #0f1826;
--panel-border: rgba(255, 255, 255, 0.11);
--text: #f8f3e9;
--muted: #aab6c6;
--faint: #758296;
--board: #f1e9d7;
--board-grid: rgba(45, 50, 59, 0.34);
--board-border: #877e6d;
--p1: #ff806f;
--p1-dark: #a9433c;
--p1-soft: rgba(255, 128, 111, 0.17);
--p2: #62adf7;
--p2-dark: #326da9;
--p2-soft: rgba(98, 173, 247, 0.17);
--wine: #bb5b7b;
--wine-light: #f0a7bd;
--valid: #48d59a;
--valid-dark: #16885b;
--invalid: #ff5872;
--invalid-dark: #b92743;
--radius: 20px;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.38);
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body {
min-width: 320px;
margin: 0;
background: var(--bg);
}
body {
min-height: 100dvh;
color: var(--text);
background:
radial-gradient(circle at 14% 10%, rgba(187, 91, 123, 0.17), transparent 34rem),
radial-gradient(circle at 88% 84%, rgba(70, 137, 211, 0.16), transparent 36rem),
linear-gradient(145deg, #090e16, #101927 55%, #0b111b);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", "Noto Sans TC", "PingFang TC", "Microsoft JhengHei",
Arial, sans-serif;
}
body.game-active,
body.dialog-open {
overflow: hidden;
}
button {
color: inherit;
font: inherit;
}
button:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(240, 167, 189, 0.85);
outline-offset: 3px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.app-shell {
position: relative;
min-height: 100dvh;
isolation: isolate;
}
.ambient {
position: fixed;
z-index: -1;
width: 24rem;
height: 24rem;
border: 1px solid rgba(255, 255, 255, 0.035);
pointer-events: none;
transform: rotate(28deg);
}
.ambient.one {
top: -12rem;
left: -9rem;
border-radius: 37%;
background: rgba(187, 91, 123, 0.035);
}
.ambient.two {
right: -11rem;
bottom: -13rem;
border-radius: 43%;
background: rgba(98, 173, 247, 0.035);
}
/* ---------- Common controls ---------- */
.lang-switch {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 999px;
background: rgba(3, 8, 14, 0.42);
}
.lang-option {
min-width: 42px;
padding: 6px 10px;
border: 0;
border-radius: 999px;
color: var(--muted);
background: transparent;
cursor: pointer;
}
.lang-option:hover {
color: var(--text);
}
.lang-option.active {
color: #21171c;
background: linear-gradient(135deg, #f4dfc7, #f0aac0);
box-shadow: 0 4px 15px rgba(187, 91, 123, 0.24);
}
.lang-switch.compact .lang-option {
min-width: 33px;
padding: 4px 7px;
font-size: 0.72rem;
}
.primary-button,
.secondary-button {
min-height: 48px;
padding: 12px 22px;
border-radius: 14px;
cursor: pointer;
transition:
transform 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
}
.primary-button {
border: 0;
color: #20151a;
font-weight: 820;
background: linear-gradient(135deg, #f4dfc7, #efa3bb);
box-shadow: 0 14px 30px rgba(187, 91, 123, 0.25);
}
.primary-button:hover {
transform: translateY(-2px);
box-shadow: 0 18px 38px rgba(187, 91, 123, 0.34);
}
.secondary-button {
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--text);
background: rgba(255, 255, 255, 0.05);
}
.secondary-button:hover {
border-color: rgba(255, 255, 255, 0.28);
transform: translateY(-1px);
}
/* ---------- Start page ---------- */
.start-screen {
display: grid;
min-height: 100dvh;
padding: clamp(18px, 4vw, 56px);
place-items: center;
}
.start-card {
width: min(100%, 880px);
padding: clamp(24px, 5vw, 54px);
border: 1px solid var(--panel-border);
border-radius: clamp(25px, 4vw, 40px);
background:
linear-gradient(145deg, rgba(28, 40, 57, 0.95), rgba(13, 21, 33, 0.95));
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.start-topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: clamp(34px, 7vh, 70px);
}
.brand-signature {
display: flex;
align-items: center;
gap: 12px;
color: var(--muted);
font-size: 0.75rem;
font-weight: 760;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.brand-mark {
position: relative;
width: 35px;
height: 35px;
transform: rotate(-5deg);
}
.brand-mark span {
position: absolute;
width: 13px;
height: 13px;
border-radius: 4px;
background: var(--wine-light);
box-shadow: 0 4px 12px rgba(187, 91, 123, 0.3);
}
.brand-mark span:nth-child(1) {
top: 0;
left: 0;
}
.brand-mark span:nth-child(2) {
top: 0;
left: 17px;
}
.brand-mark span:nth-child(3) {
top: 17px;
left: 0;
}
.hero {
max-width: 760px;
margin: auto;
text-align: center;
}
.hero h1 {
margin: 0;
font-size: clamp(3.2rem, 9vw, 7rem);
font-weight: 870;
line-height: 0.94;
letter-spacing: -0.06em;
text-wrap: balance;
}
.hero h1::after {
display: block;
width: min(180px, 36vw);
height: 5px;
margin: 24px auto 0;
border-radius: 999px;
background: linear-gradient(90deg, var(--p2), var(--wine-light), var(--p1));
content: "";
}
.subtitle {
max-width: 650px;
margin: 24px auto 0;
color: var(--muted);
font-size: clamp(1rem, 2.4vw, 1.35rem);
line-height: 1.7;
text-wrap: balance;
}
.start-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
margin-top: clamp(36px, 7vh, 70px);
}
.start-actions button {
min-width: min(100%, 220px);
min-height: 56px;
}
/* ---------- Rules page ---------- */
.rules-overlay,
.modal-backdrop {
position: fixed;
z-index: 200;
inset: 0;
display: grid;
padding: clamp(10px, 3vw, 28px);
place-items: center;
background: rgba(3, 7, 12, 0.82);
backdrop-filter: blur(10px);
}
.rules-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(100%, 1000px);
height: min(92dvh, 800px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 26px;
background:
linear-gradient(150deg, rgba(28, 41, 58, 0.99), rgba(13, 21, 33, 0.99));
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.55);
}
.rules-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 18px clamp(16px, 3vw, 28px);
border-bottom: 1px solid rgba(255, 255, 255, 0.09);
}
.rules-header h2 {
margin: 0;
font-size: clamp(1.35rem, 4vw, 2rem);
letter-spacing: -0.03em;
}
.rules-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.icon-close {
display: grid;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 11px;
color: var(--muted);
background: rgba(255, 255, 255, 0.045);
cursor: pointer;
place-items: center;
}
.icon-close:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.09);
}
.icon-close svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.rules-body {
min-height: 0;
padding: clamp(16px, 3vw, 28px);
overflow-y: auto;
overscroll-behavior: contain;
}
.rules-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.rule-block {
position: relative;
min-height: 148px;
padding: 20px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
}
.rule-block.wide {
grid-column: 1 / -1;
}
.rule-index {
position: absolute;
top: 12px;
right: 16px;
color: rgba(255, 255, 255, 0.06);
font-size: 2.5rem;
font-weight: 900;
line-height: 1;
}
.rule-block h3 {
position: relative;
z-index: 1;
margin: 0 0 10px;
font-size: 1rem;
}
.rule-block p,
.rule-block li {
position: relative;
z-index: 1;
color: var(--muted);
font-size: 0.91rem;
line-height: 1.65;
}
.rule-block p {
margin: 0;
}
.rule-block ul {
margin: 0;
padding-left: 1.2rem;
}
.rule-block li + li {
margin-top: 6px;
}
.rule-block strong {
color: var(--text);
}
.rules-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px clamp(16px, 3vw, 28px);
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
/* ---------- Game layout ---------- */
.game-screen {
position: fixed;
inset: 0;
height: var(--app-height);
padding: 6px;
overflow: hidden;
}
.game-layout {
--board-size: 300px;
--panel-size: 160px;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows:
var(--panel-size)
minmax(0, 1fr)
var(--panel-size);
gap: 6px;
width: 100%;
height: 100%;
overflow: hidden;
}
/* ---------- Player panels: mobile ---------- */
.player-panel {
--owner-color: var(--p1);
--owner-soft: var(--p1-soft);
position: relative;
display: grid;
grid-template-rows:
minmax(40px, 0.72fr)
minmax(40px, 1.28fr)
28px;
gap: 3px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
padding: 5px 8px;
overflow: hidden;
border: 1px solid var(--panel-border);
border-radius: var(--radius);
background:
linear-gradient(145deg, rgba(25, 37, 53, 0.97), rgba(15, 24, 36, 0.97));
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.21);
}
.player-panel::before {
position: absolute;
top: 0;
right: 18%;
left: 18%;
height: 2px;
border-radius: 999px;
background: var(--owner-color);
opacity: 0.68;
content: "";
}
.player-panel.player-one {
--owner-color: var(--p1);
--owner-soft: var(--p1-soft);
}
.player-panel.player-two {
--owner-color: var(--p2);
--owner-soft: var(--p2-soft);
}
.player-panel.player-finished {
opacity: 0.67;
}
.score-card {
position: relative;
display: grid;
grid-template-columns: minmax(55px, 1fr) auto minmax(92px, 1.2fr);
align-items: center;
gap: 6px;
min-width: 0;
min-height: 0;
padding: 4px 9px;
border: 1px solid transparent;
border-radius: 14px;
background: rgba(255, 255, 255, 0.035);
transition:
background 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
}
.player-one .score-card.active {
border-color: rgba(255, 128, 111, 0.56);
background:
linear-gradient(135deg, rgba(255, 128, 111, 0.3), rgba(255, 128, 111, 0.09));
box-shadow: inset 0 0 24px rgba(255, 128, 111, 0.08);
}
.player-two .score-card.active {
border-color: rgba(98, 173, 247, 0.56);
background:
linear-gradient(135deg, rgba(98, 173, 247, 0.3), rgba(98, 173, 247, 0.09));
box-shadow: inset 0 0 24px rgba(98, 173, 247, 0.08);
}
.player-name {
min-width: 0;
overflow: hidden;
font-size: 0.82rem;
font-weight: 780;
text-overflow: ellipsis;
white-space: nowrap;
}
.score-value {
display: block;
min-width: 3ch;
text-align: center;
font-size: clamp(1.8rem, 8vw, 2.45rem);
font-weight: 870;
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.05em;
}
.square-metrics {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
min-width: 0;
}
.square-metric {
display: flex;
flex-direction: column;
align-items: center;
min-width: 40px;
padding: 3px 5px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 8px;
background: rgba(0, 0, 0, 0.12);
font-size: 0.6rem;
line-height: 1.1;
}
.square-metric span {
color: var(--muted);
white-space: nowrap;
}
.square-metric strong {
margin-top: 2px;
color: var(--text);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.turn-dot {
position: absolute;
top: 7px;
right: 7px;
width: 8px;
height: 8px;
border: 2px solid var(--owner-color);
border-radius: 50%;
opacity: 0.32;
}
.score-card.active .turn-dot {
background: var(--owner-color);
opacity: 1;
box-shadow: 0 0 11px var(--owner-color);
}
.pieces-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
gap: 5px;
min-width: 0;
min-height: 0;
}
.piece-item {
display: grid;
grid-template-rows: 12px minmax(0, 1fr) 12px;
align-items: center;
justify-items: center;
min-width: 0;
min-height: 0;
color: var(--muted);
}
.piece-name {
max-width: 100%;
overflow: hidden;
font-size: 0.65rem;
font-weight: 730;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.piece-frame {
position: relative;
display: grid;
width: auto;
height: min(100%, 58px);
aspect-ratio: 1;
padding: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
background:
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.06), transparent 72%),
rgba(3, 8, 14, 0.34);
cursor: pointer;
place-items: center;
touch-action: manipulation;
transition:
border-color 150ms ease,
opacity 150ms ease,
transform 150ms ease,
box-shadow 150ms ease;
}
.piece-item.directional .piece-frame::before {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background:
linear-gradient(
to right,
transparent calc(50% - 0.5px),
rgba(255, 255, 255, 0.07) calc(50% - 0.5px),
rgba(255, 255, 255, 0.07) calc(50% + 0.5px),
transparent calc(50% + 0.5px)
),
linear-gradient(
to bottom,
transparent calc(50% - 0.5px),
rgba(255, 255, 255, 0.07) calc(50% - 0.5px),
rgba(255, 255, 255, 0.07) calc(50% + 0.5px),
transparent calc(50% + 0.5px)
);
content: "";
}
.piece-frame svg {
position: relative;
z-index: 1;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.piece-frame:hover {
border-color: rgba(255, 255, 255, 0.29);
transform: translateY(-1px);
}
.piece-item.auto-selected .piece-frame {
border-color: var(--owner-color);
transform: translateY(-1px);
box-shadow:
0 0 0 3px var(--owner-soft),
0 9px 20px rgba(0, 0, 0, 0.22);
}
.piece-item.locked:not(.auto-selected) .piece-frame {
opacity: 0.38;
filter: saturate(0.58);
}
.piece-item.not-current:not(.auto-selected) .piece-frame {
opacity: 0.54;
}
.piece-item.depleted:not(.move-source) .piece-frame {
opacity: 0.2;
filter: grayscale(0.75);
}
.piece-item.move-source .piece-frame {
opacity: 0.75;
border-color: rgba(72, 213, 154, 0.5);
}
.piece-count {
color: var(--text);
font-size: 0.75rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.panel-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
min-width: 0;
min-height: 0;
}
.phase-label {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 0.65rem;
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.phase-label.current {
color: var(--owner-color);
}
.end-player-button {
max-width: 66%;
padding: 4px 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
color: var(--text);
background: rgba(187, 91, 123, 0.14);
cursor: pointer;
font-size: 0.65rem;
font-weight: 760;
opacity: 0;
pointer-events: none;
text-overflow: ellipsis;
visibility: hidden;
white-space: nowrap;
}
.end-player-button.is-visible {
opacity: 1;
pointer-events: auto;
visibility: visible;
}
.end-player-button:disabled {
color: var(--faint);
background: rgba(255, 255, 255, 0.025);
cursor: not-allowed;
opacity: 0.52;
}
/* ---------- Connected piece graphics ---------- */
.mini-piece,
.board-piece,
.preview-piece {
stroke-linecap: round;
stroke-linejoin: round;
}
.mini-piece.owner-1,
.board-piece.owner-1 {
fill: var(--p1);
stroke: var(--p1-dark);
}
.mini-piece.owner-2,
.board-piece.owner-2 {
fill: var(--p2);
stroke: var(--p2-dark);
}
.mini-piece {
stroke-width: 2.6;
}
.board-piece {
stroke-width: 3;
vector-effect: non-scaling-stroke;
}
.mini-divider.owner-1,
.board-divider.owner-1 {
stroke: var(--p1-dark);
}
.mini-divider.owner-2,
.board-divider.owner-2 {
stroke: var(--p2-dark);
}
.mini-divider {
fill: none;
stroke-width: 2;
stroke-linecap: round;
}
.board-divider {
fill: none;
stroke-width: 2;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.anchor-dot {
fill: #fff9ee;
stroke: rgba(0, 0, 0, 0.22);
stroke-width: 1;
}
/* ---------- Board ---------- */
.board-zone {
display: grid;
grid-template-rows: 34px var(--board-size) 38px;
align-self: center;
justify-self: center;
gap: 4px;
width: min(480px, 100%);
max-width: calc(100vw - 12px);
min-width: 0;
min-height: 0;
}
.round-strip {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
min-width: 0;
padding: 4px 6px 4px 10px;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: rgba(17, 26, 40, 0.91);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
}
.round-label {
color: var(--muted);
font-size: 0.68rem;
font-weight: 760;
white-space: nowrap;
}
.turn-pill,
.result-reopen {
justify-self: center;
min-width: 0;
max-width: 100%;
padding: 5px 9px;
overflow: hidden;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 780;
text-overflow: ellipsis;
white-space: nowrap;
}
.turn-pill {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.045);
}
.turn-pill.owner-1 {
color: #ffd6cf;
border-color: rgba(255, 128, 111, 0.31);
background: rgba(255, 128, 111, 0.12);
}
.turn-pill.owner-2 {
color: #d1e9ff;
border-color: rgba(98, 173, 247, 0.31);
background: rgba(98, 173, 247, 0.12);
}
.result-reopen {
border: 1px solid rgba(240, 167, 189, 0.35);
color: #ffe0e9;
background: rgba(187, 91, 123, 0.16);
cursor: pointer;
}
.board-shell {
position: relative;
justify-self: center;
width: var(--board-size);
height: var(--board-size);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 17px;
background: var(--board);
box-shadow:
0 20px 45px rgba(0, 0, 0, 0.31),
0 0 0 4px rgba(255, 255, 255, 0.025);
}
#boardSvg {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: manipulation;
user-select: none;
}
.board-base {
fill: var(--board);
}
.grid-lines {
fill: none;
stroke: var(--board-grid);
stroke-width: 1.15;
vector-effect: non-scaling-stroke;
}
.board-border {
fill: none;
stroke: var(--board-border);
stroke-width: 3;
vector-effect: non-scaling-stroke;
}
.preview-piece.valid {
fill: rgba(72, 213, 154, 0.53);
stroke: var(--valid-dark);
}
.preview-piece.invalid {
fill: rgba(255, 88, 114, 0.52);
stroke: var(--invalid-dark);
}
.preview-piece {
stroke-width: 3;
vector-effect: non-scaling-stroke;
}
.preview-divider {
fill: none;
stroke: rgba(20, 44, 36, 0.7);
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
.preview-anchor {
fill: #fff;
stroke: rgba(0, 0, 0, 0.28);
stroke-width: 2;
}
.selected-mino-halo {
fill: none;
stroke: var(--valid);
stroke-width: 8;
opacity: 0.68;
vector-effect: non-scaling-stroke;
}
.move-arrow {
cursor: pointer;
filter: drop-shadow(0 4px 5px rgba(0, 0, 0, 0.35));
}
.move-arrow circle {
fill: #102b24;
stroke: var(--valid);
stroke-width: 2.5;
vector-effect: non-scaling-stroke;
}
.move-arrow path {
fill: none;
stroke: #d9fff0;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 0;
padding: 3px 5px;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: rgba(17, 26, 40, 0.91);
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
}
.tool-button {
position: relative;
display: grid;
width: clamp(25px, 8vw, 38px);
height: 30px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--muted);
background: rgba(255, 255, 255, 0.035);
cursor: pointer;
place-items: center;
}
.tool-button svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
.tool-button:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.08);
}
.tool-button:disabled {
cursor: not-allowed;
opacity: 0.27;
}
.tool-button::after {
position: absolute;
z-index: 30;
bottom: calc(100% + 7px);
left: 50%;
padding: 5px 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
color: var(--text);
background: #111a27;
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.3);
content: attr(data-tooltip);
font-size: 0.66rem;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 3px);
transition: opacity 120ms ease, transform 120ms ease;
white-space: nowrap;
}
.tool-button:hover::after,
.tool-button:focus-visible::after {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
z-index: 300;
right: 14px;
bottom: 16px;
left: 14px;
width: fit-content;
max-width: min(92vw, 540px);
margin: auto;
padding: 11px 17px;
border: 1px solid rgba(255, 128, 149, 0.4);
border-radius: 12px;
color: #fff1f4;
background: rgba(89, 25, 40, 0.96);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.4);
font-size: 0.87rem;
line-height: 1.45;
opacity: 0;
pointer-events: none;
transform: translateY(12px);
transition: opacity 170ms ease, transform 170ms ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
/* ---------- Result modal ---------- */
.modal-backdrop {
z-index: 210;
}
.result-modal {
position: relative;
width: min(100%, 600px);
max-height: calc(var(--app-height) - 30px);
padding: 62px 22px 22px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 26px;
background:
linear-gradient(150deg, rgba(29, 42, 59, 0.99), rgba(14, 22, 34, 0.99));
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.55);
}
.modal-lang {
position: absolute;
top: 15px;
left: 17px;
}
.modal-close {
position: absolute;
top: 15px;
right: 17px;
}
.modal-eyebrow {
margin: 0 0 8px;
color: var(--wine-light);
font-size: 0.72rem;
font-weight: 820;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.result-modal h2 {
margin: 0;
font-size: clamp(1.65rem, 5vw, 2.45rem);
line-height: 1.12;
letter-spacing: -0.035em;
text-wrap: balance;
}
.modal-summary {
margin: 12px 0 0;
color: var(--muted);
line-height: 1.65;
}
.modal-section-label {
margin: 23px 0 10px;
color: var(--muted);
font-size: 0.7rem;
font-weight: 820;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.modal-score-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.modal-score {
padding: 15px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 16px;
background: rgba(255, 255, 255, 0.035);
text-align: center;
}
.modal-score.player-one {
border-color: rgba(255, 128, 111, 0.25);
background: rgba(255, 128, 111, 0.08);
}
.modal-score.player-two {
border-color: rgba(98, 173, 247, 0.25);
background: rgba(98, 173, 247, 0.08);
}
.modal-score-name {
display: block;
color: var(--muted);
font-size: 0.78rem;
}
.modal-score-total {
display: block;
margin-top: 4px;
font-size: clamp(2rem, 8vw, 3rem);
font-variant-numeric: tabular-nums;
line-height: 1;
letter-spacing: -0.04em;
}
.modal-score-metrics {
display: flex;
justify-content: center;
gap: 7px;
margin-top: 10px;
}
.modal-score-metrics span {
padding: 4px 7px;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 8px;
color: var(--muted);
background: rgba(0, 0, 0, 0.12);
font-size: 0.7rem;
white-space: nowrap;
}
.modal-breakdown {
margin-top: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 15px;
}
.result-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
.result-table th,
.result-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
text-align: right;
}
.result-table th:first-child,
.result-table td:first-child {
text-align: left;
}
.result-table th {
color: var(--muted);
font-size: 0.7rem;
}
.result-table td small {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 0.65rem;
}
.result-table tfoot td {
font-weight: 850;
background: rgba(255, 255, 255, 0.04);
}
.result-table tr:last-child td {
border-bottom: 0;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 23px;
}
/* ---------- Desktop ---------- */
@media (min-width: 960px) {
.game-layout {
grid-template-columns:
minmax(240px, 1fr)
var(--board-size)
minmax(240px, 1fr);
grid-template-rows: minmax(0, 1fr);
gap: 8px;
}
.player-two {
grid-column: 1;
grid-row: 1;
}
.board-zone {
grid-column: 2;
grid-row: 1;
width: var(--board-size);
}
.player-one {
grid-column: 3;
grid-row: 1;
}
.player-panel {
grid-template-rows:
minmax(112px, 0.28fr)
minmax(250px, 1fr)
38px;
min-width: 240px;
padding: 13px;
}
.score-card {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
justify-items: center;
gap: 4px;
padding: 12px;
text-align: center;
}
.player-name {
font-size: 0.92rem;
}
.score-value {
font-size: clamp(3rem, 5vw, 4.2rem);
}
.square-metrics {
justify-content: center;
}
.square-metric {
min-width: 53px;
padding: 4px 7px;
}
.square-metric strong {
font-size: 0.9rem;
}
.turn-dot {
top: 12px;
right: 12px;
width: 10px;
height: 10px;
}
.pieces-list {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: repeat(3, minmax(76px, 1fr));
gap: 4px;
}
.piece-item {
display: grid;
grid-template-columns: minmax(38px, 1fr) 78px minmax(38px, 1fr);
grid-template-rows: 15px minmax(0, 78px);
column-gap: 9px;
}
.piece-name {
grid-column: 2;
grid-row: 1;
font-size: 0.75rem;
}
.piece-frame {
grid-column: 2;
grid-row: 2;
width: 78px;
height: 78px;
border-radius: 14px;
}
.piece-count {
grid-row: 2;
margin: 0;
font-size: 1.12rem;
}
.player-one .piece-count {
grid-column: 3;
justify-self: start;
}
.player-two .piece-count {
grid-column: 1;
justify-self: end;
}
.panel-footer {
justify-content: space-between;
}
.phase-label {
font-size: 0.72rem;
}
.end-player-button {
max-width: 72%;
padding: 6px 10px;
font-size: 0.7rem;
}
.tool-button {
width: 38px;
}
}
@media (min-width: 960px) and (max-height: 590px) {
.player-panel {
grid-template-rows: 100px minmax(210px, 1fr) 34px;
padding: 8px 11px;
}
.piece-item {
grid-template-columns: minmax(34px, 1fr) 63px minmax(34px, 1fr);
grid-template-rows: 13px minmax(0, 63px);
}
.piece-frame {
width: 63px;
height: 63px;
}
.score-value {
font-size: 2.65rem;
}
}
/* Very short mobile viewports */
@media (max-width: 959px) and (max-height: 430px) {
.player-panel {
grid-template-columns:
minmax(72px, 0.8fr)
minmax(128px, 1.55fr)
minmax(82px, 0.9fr);
grid-template-rows: minmax(0, 1fr);
gap: 4px;
padding: 3px 5px;
}
.score-card {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
justify-items: center;
gap: 1px;
padding: 2px 4px;
text-align: center;
}
.player-name {
font-size: 0.56rem;
}
.score-value {
font-size: 1.25rem;
}
.square-metrics {
gap: 2px;
}
.square-metric {
min-width: 27px;
padding: 1px 2px;
font-size: 0.45rem;
}
.square-metric strong {
margin-top: 0;
font-size: 0.55rem;
}
.turn-dot {
top: 3px;
right: 3px;
width: 6px;
height: 6px;
}
.pieces-list {
grid-column: 2;
grid-row: 1;
}
.piece-item {
grid-template-rows: 9px minmax(0, 1fr) 9px;
}
.piece-name,
.piece-count {
font-size: 0.5rem;
}
.piece-frame {
height: min(100%, 31px);
min-height: 22px;
border-radius: 7px;
}
.panel-footer {
grid-column: 3;
grid-row: 1;
flex-direction: column;
justify-content: center;
gap: 2px;
}
.phase-label {
max-width: 100%;
font-size: 0.5rem;
text-align: center;
}
.end-player-button {
max-width: 100%;
padding: 3px 4px;
font-size: 0.48rem;
}
}
@media (max-width: 620px) {
.rules-grid {
grid-template-columns: minmax(0, 1fr);
}
.rule-block.wide {
grid-column: auto;
}
.rules-footer,
.modal-actions {
flex-direction: column-reverse;
}
.rules-footer button,
.modal-actions button {
width: 100%;
}
.modal-score-grid {
gap: 7px;
}
.result-modal {
padding-right: 15px;
padding-left: 15px;
}
}
@media (max-width: 365px) {
.start-card {
padding: 20px 15px 24px;
}
.brand-signature {
max-width: 155px;
font-size: 0.62rem;
}
.round-label,
.turn-pill,
.result-reopen {
font-size: 0.61rem;
}
.lang-switch.compact .lang-option {
min-width: 29px;
padding-right: 5px;
padding-left: 5px;
font-size: 0.64rem;
}
.score-card {
grid-template-columns: minmax(48px, 1fr) auto minmax(82px, 1fr);
}
.square-metric {
min-width: 36px;
padding-right: 3px;
padding-left: 3px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<div class="app-shell">
<div class="ambient one" aria-hidden="true"></div>
<div class="ambient two" aria-hidden="true"></div>
<!-- Start page -->
<section id="startScreen" class="start-screen">
<div class="start-card">
<div class="start-topline">
<div class="brand-signature">
<span class="brand-mark" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</span>
<span id="startEyebrow"></span>
</div>
<div class="lang-switch" role="group">
<button type="button" class="lang-option" data-set-lang="en">EN</button>
<button type="button" class="lang-option" data-set-lang="zh">中文</button>
</div>
</div>
<header class="hero">
<h1 id="mainTitle"></h1>
<p id="subtitle" class="subtitle"></p>
</header>
<div class="start-actions">
<button id="rulesButton" type="button" class="secondary-button"></button>
<button id="startButton" type="button" class="primary-button"></button>
</div>
</div>
</section>
<!-- Rules page -->
<div id="rulesOverlay" class="rules-overlay" hidden>
<section
class="rules-page"
role="dialog"
aria-modal="true"
aria-labelledby="rulesDialogTitle"
>
<header class="rules-header">
<h2 id="rulesDialogTitle"></h2>
<div class="rules-header-actions">
<div class="lang-switch compact" role="group">
<button type="button" class="lang-option" data-set-lang="en">EN</button>
<button type="button" class="lang-option" data-set-lang="zh">中文</button>
</div>
<button id="rulesCloseButton" type="button" class="icon-close">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6 18 18M18 6 6 18"></path>
</svg>
<span class="sr-only"></span>
</button>
</div>
</header>
<div id="rulesDialogContent" class="rules-body"></div>
<footer class="rules-footer">
<button id="rulesBackButton" type="button" class="secondary-button"></button>
<button id="rulesStartButton" type="button" class="primary-button"></button>
</footer>
</section>
</div>
<!-- Game -->
<section id="gameScreen" class="game-screen" hidden>
<main id="gameLayout" class="game-layout">
<!-- Player 2: left on desktop, top on mobile -->
<aside id="player2Panel" class="player-panel player-two">
<div id="scoreCard2" class="score-card">
<span id="playerName2" class="player-name"></span>
<strong id="score2" class="score-value">0</strong>
<div class="square-metrics">
<span class="square-metric">
<span>2×2</span>
<strong id="square2Player2">×0</strong>
</span>
<span class="square-metric">
<span>3×3</span>
<strong id="square3Player2">×0</strong>
</span>
</div>
<span class="turn-dot" aria-hidden="true"></span>
</div>
<div class="pieces-list">
<div class="piece-item" data-player="2" data-type="fino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="2" data-type="fino"></button>
<span class="piece-count"></span>
</div>
<div class="piece-item" data-player="2" data-type="mino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="2" data-type="mino"></button>
<span class="piece-count"></span>
</div>
<div class="piece-item" data-player="2" data-type="vino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="2" data-type="vino"></button>
<span class="piece-count"></span>
</div>
</div>
<div class="panel-footer">
<span id="phaseText2" class="phase-label"></span>
<button id="endPlayer2" type="button" class="end-player-button"></button>
</div>
</aside>
<!-- Board -->
<section class="board-zone">
<div class="round-strip">
<span id="roundText" class="round-label"></span>
<span id="turnText" class="turn-pill"></span>
<button
id="viewResultButton"
type="button"
class="result-reopen"
hidden
></button>
<div class="lang-switch compact" role="group">
<button type="button" class="lang-option" data-set-lang="en">EN</button>
<button type="button" class="lang-option" data-set-lang="zh">中文</button>
</div>
</div>
<div class="board-shell">
<svg
id="boardSvg"
width="480"
height="480"
viewBox="0 0 480 480"
preserveAspectRatio="xMidYMid meet"
role="img"
>
<title id="boardSvgTitle"></title>
<defs>
<clipPath id="boardClip">
<rect x="0" y="0" width="480" height="480" rx="16"></rect>
</clipPath>
</defs>
<rect class="board-base" x="0" y="0" width="480" height="480"></rect>
<g id="gridLayer" pointer-events="none"></g>
<g id="placedLayer" clip-path="url(#boardClip)"></g>
<g id="previewLayer" clip-path="url(#boardClip)" pointer-events="none"></g>
<g id="moveLayer" clip-path="url(#boardClip)"></g>
<rect
class="board-border"
x="1.5"
y="1.5"
width="477"
height="477"
rx="15"
pointer-events="none"
></rect>
</svg>
</div>
<nav class="toolbar">
<button
id="undoButton"
type="button"
class="tool-button"
data-label-key="toolUndo"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 7H4V2"></path>
<path d="M4.4 6.7A9 9 0 1 1 4 16"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
id="redoButton"
type="button"
class="tool-button"
data-label-key="toolRedo"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M15 7h5V2"></path>
<path d="M19.6 6.7A9 9 0 1 0 20 16"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolAI"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3v3"></path>
<circle cx="12" cy="2.5" r="1"></circle>
<rect x="5" y="6" width="14" height="13" rx="3"></rect>
<circle cx="9.5" cy="11.5" r="1"></circle>
<circle cx="14.5" cy="11.5" r="1"></circle>
<path d="M9 16h6"></path>
<path d="M3 10v5M21 10v5"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolExport"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 3h7l4 4v14H7z"></path>
<path d="M14 3v5h5"></path>
<path d="M10 12h5M10 16h5"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolDownloadSVG"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3v12"></path>
<path d="m8 11 4 4 4-4"></path>
<path d="M5 19h14"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolRestore"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="6" width="14" height="13" rx="2"></rect>
<path d="M8 6V4h12v11h-2"></path>
<path d="M8 10h6v5H8z"></path>
</svg>
<span class="sr-only"></span>
</button>
<button
type="button"
class="tool-button"
data-label-key="toolAutoRotate"
data-placeholder-tool
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 8a8 8 0 0 1 13-2"></path>
<path d="M18 2v5h-5"></path>
<path d="M19 16a8 8 0 0 1-13 2"></path>
<path d="M6 22v-5h5"></path>
</svg>
<span class="sr-only"></span>
</button>
</nav>
</section>
<!-- Player 1: right on desktop, bottom on mobile -->
<aside id="player1Panel" class="player-panel player-one">
<div id="scoreCard1" class="score-card">
<span id="playerName1" class="player-name"></span>
<strong id="score1" class="score-value">0</strong>
<div class="square-metrics">
<span class="square-metric">
<span>2×2</span>
<strong id="square2Player1">×0</strong>
</span>
<span class="square-metric">
<span>3×3</span>
<strong id="square3Player1">×0</strong>
</span>
</div>
<span class="turn-dot" aria-hidden="true"></span>
</div>
<div class="pieces-list">
<div class="piece-item" data-player="1" data-type="fino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="1" data-type="fino"></button>
<span class="piece-count"></span>
</div>
<div class="piece-item" data-player="1" data-type="mino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="1" data-type="mino"></button>
<span class="piece-count"></span>
</div>
<div class="piece-item" data-player="1" data-type="vino">
<span class="piece-name"></span>
<button type="button" class="piece-frame" data-player="1" data-type="vino"></button>
<span class="piece-count"></span>
</div>
</div>
<div class="panel-footer">
<span id="phaseText1" class="phase-label"></span>
<button id="endPlayer1" type="button" class="end-player-button"></button>
</div>
</aside>
</main>
</section>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<!-- Result dialog -->
<div id="modalBackdrop" class="modal-backdrop" hidden>
<section
class="result-modal"
role="dialog"
aria-modal="true"
aria-labelledby="modalTitle"
>
<div class="lang-switch compact modal-lang" role="group">
<button type="button" class="lang-option" data-set-lang="en">EN</button>
<button type="button" class="lang-option" data-set-lang="zh">中文</button>
</div>
<button id="modalClose" type="button" class="icon-close modal-close">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6 18 18M18 6 6 18"></path>
</svg>
<span class="sr-only"></span>
</button>
<p id="modalEyebrow" class="modal-eyebrow"></p>
<h2 id="modalTitle"></h2>
<p id="modalSummary" class="modal-summary"></p>
<p id="modalSectionLabel" class="modal-section-label"></p>
<div id="modalScores" class="modal-score-grid"></div>
<div id="modalBreakdown" class="modal-breakdown" hidden></div>
<div class="modal-actions">
<button id="modalSecondary" type="button" class="secondary-button"></button>
<button id="modalPrimary" type="button" class="primary-button"></button>
</div>
</section>
</div>
<script>
"use strict";
const SVG_NS = "http://www.w3.org/2000/svg";
const BOARD_SIZE = 10;
const CELL_SIZE = 48;
/*
* New compulsory order:
* Fino -> Mino -> Vino
*/
const PIECE_ORDER = ["fino", "mino", "vino"];
const ORIENTATIONS = ["tl", "tr", "br", "bl"];
const INITIAL_COUNTS = Object.freeze({
fino: 5,
mino: 3,
vino: 7
});
const DIRECTIONS = Object.freeze({
up: { dr: -1, dc: 0, angle: -90 },
right: { dr: 0, dc: 1, angle: 0 },
down: { dr: 1, dc: 0, angle: 90 },
left: { dr: 0, dc: -1, angle: 180 }
});
const I18N = {
en: {
documentTitle: "Vino Square",
languageLabel: "Language",
startEyebrow: "Abstract strategy game",
title: "Vino Square",
subtitle: "Fino, Vino, and Mino Play on the Square.",
rulesButton: "Game Rules",
rulesTitle: "How to Play",
rulesBack: "Close Rules",
startGame: "Start Game",
closeDialog: "Close dialog",
player1: "Player 1",
player2: "Player 2",
pieceFino: "Fino",
pieceMino: "Mino",
pieceVino: "Vino",
phaseFino: "Fino phase",
phaseMino: "Mino phase",
phaseVino: "Vino phase",
phaseFinished: "Finished",
phaseRoundOver: "Round over",
phaseWaiting: "Waiting",
roundOf: "Round {round} of 2",
turnStage: "{player} · {phase}",
moveMinoMode: "{player} · Move Mino",
minoMovedShort: "Mino moved",
roundCompleteStatus: "Round {round} complete",
matchCompleteStatus: "Match complete",
viewRoundResult: "View result",
viewFinalResult: "Final result",
boardReady: "Ready to start.",
boardAria: "10 by 10 Vino Square board. {status}",
pieceCountAria: "{player}, {piece}, {count} remaining.",
endOwnGame: "End My Game",
endOnlyVino: "Ending is available only during the Vino phase.",
toolUndo: "Undo",
toolRedo: "Redo",
toolAI: "AI settings",
toolExport: "Export text game record",
toolDownloadSVG: "Download SVG",
toolRestore: "Restore window",
toolAutoRotate: "Auto rotate",
notTurn: "It is {player}'s turn.",
autoSelected: "{piece} is selected automatically.",
noPieces: "No {piece} pieces remain.",
outOfBounds: "That piece would extend beyond the board.",
overlap: "That piece would overlap an occupied cell.",
touchForbidden:
"During this phase, the new {piece} may not touch your existing pieces, even diagonally.",
selectBoardMino:
"Select one of your placed Mino pieces on the board.",
noMinoDirections:
"This Mino has no legal movement direction.",
minoAlreadyMoved:
"You have already moved a Mino during this turn.",
moveBlocked:
"The Mino cannot move in that direction.",
minoMoved:
"The Mino and its rigid push chain moved one cell.",
playerEndedContinues:
"{player} has ended. {next} continues playing.",
comingSoon:
"This tool is reserved for a future version.",
roundCompleteEyebrow: "Round {round} complete",
winsRound: "{player} wins the round",
tiedRound: "The round is tied",
mandatoryBlockedSummary:
"{loser} has no legal placement for the required {piece} and loses the round immediately.",
vinoFinishedWinSummary:
"Both players have ended their Vino play. {winner} has the higher score.",
vinoFinishedTieSummary:
"Both players have ended their Vino play with equal scores.",
roundScores: "Round scores",
nextRound: "Play Round 2 · {player} starts",
endGame: "End Match",
finishMatch: "Finish Match",
reviewBoard: "Review Board",
finalEyebrow: "Match complete",
winsMatch: "{player} wins Vino Square",
tiedMatch: "The match is tied",
totalSummaryOne: "Combined result after 1 completed round.",
totalSummaryTwo: "Combined result after 2 completed rounds.",
totalScores: "Total scores",
roundColumn: "Round",
roundShort: "Round {round}",
total: "Total",
playAgain: "Play Again",
backToTitle: "Back to Title",
rulesHtml: `
<div class="rules-grid">
<article class="rule-block">
<span class="rule-index">01</span>
<h3>Objective and pieces</h3>
<p>
Players place pieces on a 10 × 10 board and try to form as many
completely filled squares as possible. Each player has five
Fino pieces, three Mino pieces, and seven Vino pieces.
</p>
</article>
<article class="rule-block">
<span class="rule-index">02</span>
<h3>Piece shapes</h3>
<ul>
<li><strong>Fino:</strong> five cells forming a V, with two cells along each arm.</li>
<li><strong>Mino:</strong> one single square cell.</li>
<li><strong>Vino:</strong> three cells forming a small V, with one cell along each arm.</li>
</ul>
</article>
<article class="rule-block">
<span class="rule-index">03</span>
<h3>Compulsory order</h3>
<p>
Players alternate one placement at a time. All Fino pieces must
be placed first, followed by all Mino pieces. Vino pieces are
placed last. The current required piece is selected automatically.
</p>
</article>
<article class="rule-block">
<span class="rule-index">04</span>
<h3>Fino and Mino spacing</h3>
<p>
When placing Fino or Mino, the new piece may not touch any of
that player's existing pieces, including diagonal corner
contact. Contact with the opponent's pieces is allowed.
If a compulsory Fino or Mino placement is no longer possible,
that player loses the round immediately.
</p>
</article>
<article class="rule-block wide">
<span class="rule-index">05</span>
<h3>Moving a Mino during the Vino phase</h3>
<p>
Before placing each Vino, a player may move one of their placed
Mino pieces by one orthogonal cell. Click the Mino on the board
to display every legal direction, then click an arrow.
A Mino can push an encountered Fino or Vino as a complete rigid
piece. If that piece meets another rigid piece, the push
continues recursively. A move is illegal if another Mino would
have to be pushed or if any moved cell would leave the board.
The Mino move is optional, and only one Mino move is permitted
before each Vino placement.
</p>
</article>
<article class="rule-block">
<span class="rule-index">06</span>
<h3>Ending Vino play</h3>
<p>
A player who believes they cannot place another Vino may move
one Mino for the final time and then press “End My Game”.
That player takes no more turns, while the other player
continues independently until also ending or using every Vino.
</p>
</article>
<article class="rule-block">
<span class="rule-index">07</span>
<h3>Scoring</h3>
<p>
Every occupied cell scores 1 point. Every fully occupied
2 × 2 square scores 4, every 3 × 3 scores 9, and larger squares
score their area. All positions count, including overlapping
square patterns. The interface also displays the numbers of
completed 2 × 2 and 3 × 3 squares.
</p>
</article>
<article class="rule-block">
<span class="rule-index">08</span>
<h3>Two-round match</h3>
<p>
Player 1 starts Round 1 and Player 2 starts Round 2. The board
and inventories reset between rounds. Both round scores are
added together, and the higher total wins the match.
</p>
</article>
<article class="rule-block">
<span class="rule-index">09</span>
<h3>Orientation controls</h3>
<p>
Fino and Vino frames contain four quadrants. Click a quadrant
to point the corner cell of the V toward it. Then click a board
cell; that cell becomes the corner of the selected V shape.
</p>
</article>
</div>
`
},
zh: {
documentTitle: "維諾廣場",
languageLabel: "語言",
startEyebrow: "抽象策略遊戲",
title: "維諾廣場",
subtitle: "法諾、維諾、米諾在廣場上玩",
rulesButton: "遊戲規則",
rulesTitle: "遊戲規則",
rulesBack: "關閉規則",
startGame: "開始遊戲",
closeDialog: "關閉窗口",
player1: "玩家一",
player2: "玩家二",
pieceFino: "法諾",
pieceMino: "米諾",
pieceVino: "維諾",
phaseFino: "法諾階段",
phaseMino: "米諾階段",
phaseVino: "維諾階段",
phaseFinished: "本方已結束",
phaseRoundOver: "本局已結束",
phaseWaiting: "等待中",
roundOf: "第 {round} 局/共 2 局",
turnStage: "{player}・{phase}",
moveMinoMode: "{player}・移動米諾",
minoMovedShort: "已移動米諾",
roundCompleteStatus: "第 {round} 局結束",
matchCompleteStatus: "比賽結束",
viewRoundResult: "查看結果",
viewFinalResult: "最終結果",
boardReady: "準備開始。",
boardAria: "10 乘 10 的維諾廣場棋盤。{status}",
pieceCountAria: "{player}的{piece},剩餘 {count} 個。",
endOwnGame: "結束本方遊戲",
endOnlyVino: "只有進入維諾階段後才能結束本方遊戲。",
toolUndo: "回退",
toolRedo: "前進",
toolAI: "AI 設置",
toolExport: "導出文本棋譜",
toolDownloadSVG: "下載 SVG",
toolRestore: "恢復窗口",
toolAutoRotate: "自動旋轉",
notTurn: "現在輪到{player}。",
autoSelected: "目前已自動選取{piece}。",
noPieces: "已沒有剩餘的{piece}。",
outOfBounds: "這個棋子會超出棋盤邊界。",
overlap: "這個棋子會與已佔用的格子重疊。",
touchForbidden:
"在此階段,新放置的{piece}不能接觸本方已有棋子,包括角對角接觸。",
selectBoardMino:
"請點擊棋盤上本方已放置的其中一個米諾。",
noMinoDirections:
"這個米諾目前沒有可以移動的方向。",
minoAlreadyMoved:
"本回合已經移動過一次米諾。",
moveBlocked:
"米諾不能向這個方向移動。",
minoMoved:
"米諾及其連鎖推動的剛性棋子已移動一格。",
playerEndedContinues:
"{player}已結束遊戲,由{next}繼續行動。",
comingSoon:
"此工具將在之後的版本中實現。",
roundCompleteEyebrow: "第 {round} 局結束",
winsRound: "{player}贏得本局",
tiedRound: "本局平手",
mandatoryBlockedSummary:
"{loser}已無法合規放置必須使用的{piece},因此立即輸掉本局。",
vinoFinishedWinSummary:
"雙方均已結束維諾階段,{winner}的分數較高。",
vinoFinishedTieSummary:
"雙方均已結束維諾階段,而且分數相同。",
roundScores: "本局分數",
nextRound: "進行第二局・由{player}先手",
endGame: "結束比賽",
finishMatch: "統計最終結果",
reviewBoard: "查看棋盤",
finalEyebrow: "比賽結束",
winsMatch: "{player}贏得維諾廣場",
tiedMatch: "本場比賽平手",
totalSummaryOne: "以下為完成 1 局後的合計結果。",
totalSummaryTwo: "以下為完成 2 局後的合計結果。",
totalScores: "總分",
roundColumn: "局數",
roundShort: "第 {round} 局",
total: "合計",
playAgain: "再玩一次",
backToTitle: "返回開始頁面",
rulesHtml: `
<div class="rules-grid">
<article class="rule-block">
<span class="rule-index">01</span>
<h3>目標與棋子數量</h3>
<p>
雙方在 10 × 10 棋盤上放置棋子,圍成盡可能多的完整正方形。
每方有五個法諾、三個米諾和七個維諾。
</p>
</article>
<article class="rule-block">
<span class="rule-index">02</span>
<h3>棋子形狀</h3>
<ul>
<li><strong>法諾:</strong>五格組成 V 形,角格沿兩條邊各連接兩格。</li>
<li><strong>米諾:</strong>單獨一個正方格。</li>
<li><strong>維諾:</strong>三格組成小 V 形,角格沿兩條邊各連接一格。</li>
</ul>
</article>
<article class="rule-block">
<span class="rule-index">03</span>
<h3>必須遵守的順序</h3>
<p>
雙方輪流放置一個棋子。必須先放完所有法諾,再放完所有米諾,
最後才進入維諾階段。輪到玩家時,目前必須使用的棋子會自動選取。
</p>
</article>
<article class="rule-block">
<span class="rule-index">04</span>
<h3>法諾與米諾的間距</h3>
<p>
放置法諾或米諾時,新棋子不能接觸本方已有棋子,包括角對角接觸;
但可以接觸對方棋子。法諾與米諾都是必須完成的階段,若目前必放
的棋子已沒有合法位置,該玩家立即輸掉本局。
</p>
</article>
<article class="rule-block wide">
<span class="rule-index">05</span>
<h3>維諾階段移動米諾</h3>
<p>
每次放置維諾前,可以把本方一個已放置的米諾向上下左右移動一格。
點擊棋盤上的米諾後,畫面只會顯示可以移動的方向箭頭。
米諾碰到法諾或維諾時,會推動整個剛性棋子;如果該棋子再碰到
其他剛性棋子,推動會遞迴傳遞。若需要推動另一個米諾,或任何
被移動的格子會超出棋盤,則不能移動。每次放置維諾前最多只能
移動一次米諾,也可以不移動米諾而直接放置維諾。
</p>
</article>
<article class="rule-block">
<span class="rule-index">06</span>
<h3>結束本方遊戲</h3>
<p>
如果一方認為已不能再放置維諾,可以先進行最後一次米諾移動,
再按下「結束本方遊戲」。之後該方不再行動,另一方仍可自行
繼續,直到也按下結束按鈕或放完所有維諾。
</p>
</article>
<article class="rule-block">
<span class="rule-index">07</span>
<h3>計分</h3>
<p>
每個本方佔用格計 1 分。每個完整 2 × 2 正方形計 4 分,
3 × 3 計 9 分,更大的正方形按面積計分。不同位置與相互重疊
的正方形均可重複計算。控制區亦會顯示 2 × 2 和 3 × 3
正方形的完成數量。
</p>
</article>
<article class="rule-block">
<span class="rule-index">08</span>
<h3>兩局賽制</h3>
<p>
第一局由玩家一先手,第二局改由玩家二先手。第二局會重置棋盤
與棋子數量。完成後合計兩局總分,分數較高者獲勝。
</p>
</article>
<article class="rule-block">
<span class="rule-index">09</span>
<h3>旋轉與落子</h3>
<p>
法諾和維諾的控制框分為四個象限。點擊一個象限,V 形角格會
朝向該象限。之後點擊棋盤格,該格會作為 V 形的角格。
</p>
</article>
</div>
`
}
};
function createEmptyBoard() {
return Array.from(
{ length: BOARD_SIZE },
() => Array(BOARD_SIZE).fill(0)
);
}
function createInventory() {
return {
1: { ...INITIAL_COUNTS },
2: { ...INITIAL_COUNTS }
};
}
function createOrientationMemory() {
return {
1: { fino: "tl", mino: "single", vino: "tl" },
2: { fino: "tl", mino: "single", vino: "tl" }
};
}
function createEmptyScoreStats() {
const counts = {};
for (let size = 1; size <= BOARD_SIZE; size += 1) {
counts[size] = 0;
}
return {
total: 0,
counts
};
}
const state = {
lang: "en",
gameStarted: false,
gameOver: false,
roundNumber: 1,
currentPlayer: 1,
board: createEmptyBoard(),
pieces: [],
pieceIndex: new Map(),
nextPieceId: 1,
inventory: createInventory(),
orientationMemory: createOrientationMemory(),
scoreStats: {
1: createEmptyScoreStats(),
2: createEmptyScoreStats()
},
endedPlayers: {
1: false,
2: false
},
minoMovedThisTurn: false,
moveSelection: null,
hoverCell: null,
roundEnded: false,
endInfo: null,
roundResults: [],
history: [],
historyIndex: 0,
modalMode: null,
toastData: null,
toastTimer: null
};
const els = {
startScreen: document.getElementById("startScreen"),
gameScreen: document.getElementById("gameScreen"),
gameLayout: document.getElementById("gameLayout"),
startEyebrow: document.getElementById("startEyebrow"),
mainTitle: document.getElementById("mainTitle"),
subtitle: document.getElementById("subtitle"),
rulesButton: document.getElementById("rulesButton"),
startButton: document.getElementById("startButton"),
rulesOverlay: document.getElementById("rulesOverlay"),
rulesDialogTitle: document.getElementById("rulesDialogTitle"),
rulesDialogContent: document.getElementById("rulesDialogContent"),
rulesCloseButton: document.getElementById("rulesCloseButton"),
rulesBackButton: document.getElementById("rulesBackButton"),
rulesStartButton: document.getElementById("rulesStartButton"),
player1Panel: document.getElementById("player1Panel"),
player2Panel: document.getElementById("player2Panel"),
playerName1: document.getElementById("playerName1"),
playerName2: document.getElementById("playerName2"),
score1: document.getElementById("score1"),
score2: document.getElementById("score2"),
square2Player1: document.getElementById("square2Player1"),
square3Player1: document.getElementById("square3Player1"),
square2Player2: document.getElementById("square2Player2"),
square3Player2: document.getElementById("square3Player2"),
scoreCard1: document.getElementById("scoreCard1"),
scoreCard2: document.getElementById("scoreCard2"),
phaseText1: document.getElementById("phaseText1"),
phaseText2: document.getElementById("phaseText2"),
endPlayer1: document.getElementById("endPlayer1"),
endPlayer2: document.getElementById("endPlayer2"),
roundText: document.getElementById("roundText"),
turnText: document.getElementById("turnText"),
viewResultButton: document.getElementById("viewResultButton"),
boardSvg: document.getElementById("boardSvg"),
boardSvgTitle: document.getElementById("boardSvgTitle"),
gridLayer: document.getElementById("gridLayer"),
placedLayer: document.getElementById("placedLayer"),
previewLayer: document.getElementById("previewLayer"),
moveLayer: document.getElementById("moveLayer"),
undoButton: document.getElementById("undoButton"),
redoButton: document.getElementById("redoButton"),
toast: document.getElementById("toast"),
modalBackdrop: document.getElementById("modalBackdrop"),
modalClose: document.getElementById("modalClose"),
modalEyebrow: document.getElementById("modalEyebrow"),
modalTitle: document.getElementById("modalTitle"),
modalSummary: document.getElementById("modalSummary"),
modalSectionLabel: document.getElementById("modalSectionLabel"),
modalScores: document.getElementById("modalScores"),
modalBreakdown: document.getElementById("modalBreakdown"),
modalPrimary: document.getElementById("modalPrimary"),
modalSecondary: document.getElementById("modalSecondary")
};
/* ---------- Translation ---------- */
function playerToken(player) {
return { kind: "player", value: player };
}
function pieceToken(piece) {
return { kind: "piece", value: piece };
}
function playerName(player) {
return translate(player === 1 ? "player1" : "player2");
}
function pieceName(piece) {
return translate({
fino: "pieceFino",
mino: "pieceMino",
vino: "pieceVino"
}[piece]);
}
function phaseName(piece) {
if (!piece) {
return translate("phaseWaiting");
}
return translate({
fino: "phaseFino",
mino: "phaseMino",
vino: "phaseVino"
}[piece]);
}
function resolveTranslationValue(value) {
if (value && typeof value === "object") {
if (value.kind === "player") {
return playerName(value.value);
}
if (value.kind === "piece") {
return pieceName(value.value);
}
}
return String(value ?? "");
}
function translate(key, variables = {}) {
const language = I18N[state.lang] || I18N.en;
let text = language[key] ?? I18N.en[key] ?? key;
if (typeof text !== "string") {
return text;
}
return text.replace(/\{(\w+)\}/g, (match, variableName) => {
if (!(variableName in variables)) {
return match;
}
return resolveTranslationValue(variables[variableName]);
});
}
function applyLanguage(language) {
state.lang = language === "zh" ? "zh" : "en";
document.documentElement.lang =
state.lang === "zh" ? "zh-Hant" : "en";
document.title = translate("documentTitle");
els.startEyebrow.textContent = translate("startEyebrow");
els.mainTitle.textContent = translate("title");
els.subtitle.textContent = translate("subtitle");
els.rulesButton.textContent = translate("rulesButton");
els.startButton.textContent = translate("startGame");
els.rulesDialogTitle.textContent = translate("rulesTitle");
els.rulesDialogContent.innerHTML = I18N[state.lang].rulesHtml;
els.rulesBackButton.textContent = translate("rulesBack");
els.rulesStartButton.textContent = translate("startGame");
els.endPlayer1.textContent = translate("endOwnGame");
els.endPlayer2.textContent = translate("endOwnGame");
document.querySelectorAll(".lang-switch").forEach((switchElement) => {
switchElement.setAttribute("aria-label", translate("languageLabel"));
});
document.querySelectorAll("[data-set-lang]").forEach((button) => {
const active = button.dataset.setLang === state.lang;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
document.querySelectorAll("[data-label-key]").forEach((button) => {
const label = translate(button.dataset.labelKey);
const hiddenLabel = button.querySelector(".sr-only");
button.dataset.tooltip = label;
button.setAttribute("aria-label", label);
if (hiddenLabel) {
hiddenLabel.textContent = label;
}
});
[
els.rulesCloseButton,
els.modalClose
].forEach((button) => {
button.setAttribute("aria-label", translate("closeDialog"));
const hiddenLabel = button.querySelector(".sr-only");
if (hiddenLabel) {
hiddenLabel.textContent = translate("closeDialog");
}
});
renderAll();
if (state.toastData) {
updateToastText();
}
if (!els.modalBackdrop.hidden) {
if (state.modalMode === "round") {
showRoundModal(false);
} else if (state.modalMode === "final") {
showFinalModal(false);
}
}
}
/* ---------- Responsive viewport ---------- */
function updateResponsiveLayout() {
const viewportHeight = Math.round(
window.visualViewport?.height || window.innerHeight
);
const viewportWidth = Math.round(
window.visualViewport?.width || window.innerWidth
);
document.documentElement.style.setProperty(
"--app-height",
`${viewportHeight}px`
);
if (viewportWidth >= 960) {
const fixedHorizontal = 12 + 16 + 480;
const fixedVertical = 12 + 80;
const boardByWidth = viewportWidth - fixedHorizontal;
const boardByHeight = viewportHeight - fixedVertical;
const boardSize = Math.max(
140,
Math.min(480, boardByWidth, boardByHeight)
);
els.gameLayout.style.setProperty(
"--board-size",
`${Math.floor(boardSize)}px`
);
els.gameLayout.style.setProperty("--panel-size", "160px");
} else {
const fixedVertical = 12 + 12 + 80;
const minimumBoard = 72;
let panelSize = 160;
let boardByHeight =
viewportHeight - fixedVertical - panelSize * 2;
if (boardByHeight < minimumBoard) {
panelSize = Math.max(
70,
Math.floor(
(viewportHeight - fixedVertical - minimumBoard) / 2
)
);
boardByHeight =
viewportHeight - fixedVertical - panelSize * 2;
}
const boardByWidth = viewportWidth - 12;
const boardSize = Math.max(
48,
Math.min(480, boardByWidth, boardByHeight)
);
els.gameLayout.style.setProperty(
"--panel-size",
`${panelSize}px`
);
els.gameLayout.style.setProperty(
"--board-size",
`${Math.floor(boardSize)}px`
);
}
}
/* ---------- Board and piece model ---------- */
function otherPlayer(player) {
return player === 1 ? 2 : 1;
}
function getRequiredPiece(player) {
if (
!player ||
!state.inventory[player] ||
state.endedPlayers[player]
) {
return null;
}
for (const type of PIECE_ORDER) {
if (state.inventory[player][type] > 0) {
return type;
}
}
return null;
}
function getPieceById(pieceId) {
return state.pieceIndex.get(pieceId) || null;
}
function getPieceAt(row, col) {
if (
row < 0 ||
row >= BOARD_SIZE ||
col < 0 ||
col >= BOARD_SIZE
) {
return null;
}
const pieceId = state.board[row][col];
return pieceId ? getPieceById(pieceId) : null;
}
function rebuildBoard() {
state.board = createEmptyBoard();
state.pieceIndex = new Map();
for (const piece of state.pieces) {
state.pieceIndex.set(piece.id, piece);
}
for (const piece of state.pieces) {
for (const cell of piece.cells) {
if (
cell.row >= 0 &&
cell.row < BOARD_SIZE &&
cell.col >= 0 &&
cell.col < BOARD_SIZE
) {
state.board[cell.row][cell.col] = piece.id;
}
}
}
}
function getPieceOffsets(type, orientation) {
if (type === "mino") {
return [[0, 0]];
}
const armLength = type === "fino" ? 2 : 1;
const directions = {
tl: [[1, 0], [0, 1]],
tr: [[-1, 0], [0, 1]],
br: [[-1, 0], [0, -1]],
bl: [[1, 0], [0, -1]]
};
const [horizontal, vertical] =
directions[orientation] || directions.tl;
const offsets = [[0, 0]];
for (let step = 1; step <= armLength; step += 1) {
offsets.push([
horizontal[0] * step,
horizontal[1] * step
]);
offsets.push([
vertical[0] * step,
vertical[1] * step
]);
}
return offsets;
}
function validatePlacement(
player,
type,
orientation,
anchorRow,
anchorCol
) {
const cells = getPieceOffsets(type, orientation).map(([dx, dy]) => ({
row: anchorRow + dy,
col: anchorCol + dx
}));
const outOfBounds = cells.some(({ row, col }) => (
row < 0 ||
row >= BOARD_SIZE ||
col < 0 ||
col >= BOARD_SIZE
));
if (outOfBounds) {
return {
ok: false,
reason: "bounds",
cells
};
}
const overlap = cells.some(({ row, col }) => (
state.board[row][col] !== 0
));
if (overlap) {
return {
ok: false,
reason: "overlap",
cells
};
}
/*
* During Fino and Mino placement, the candidate may not
* touch any existing piece owned by the same player.
* All eight surrounding directions are checked.
*/
if (type === "fino" || type === "mino") {
for (const cell of cells) {
for (let dr = -1; dr <= 1; dr += 1) {
for (let dc = -1; dc <= 1; dc += 1) {
if (dr === 0 && dc === 0) {
continue;
}
const row = cell.row + dr;
const col = cell.col + dc;
if (
row < 0 ||
row >= BOARD_SIZE ||
col < 0 ||
col >= BOARD_SIZE
) {
continue;
}
const existingPiece = getPieceAt(row, col);
if (
existingPiece &&
existingPiece.player === player
) {
return {
ok: false,
reason: "touch",
cells
};
}
}
}
}
}
return {
ok: true,
reason: null,
cells
};
}
function hasAnyLegalPlacement(player, type = getRequiredPiece(player)) {
if (!type) {
return false;
}
const orientations =
type === "mino"
? ["single"]
: ORIENTATIONS;
for (const orientation of orientations) {
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
if (
validatePlacement(
player,
type,
orientation,
row,
col
).ok
) {
return true;
}
}
}
}
return false;
}
/* ---------- Scoring ---------- */
function calculateScoreStats(player) {
const prefix = Array.from(
{ length: BOARD_SIZE + 1 },
() => Array(BOARD_SIZE + 1).fill(0)
);
for (let row = 0; row < BOARD_SIZE; row += 1) {
for (let col = 0; col < BOARD_SIZE; col += 1) {
const piece = getPieceAt(row, col);
const occupied =
piece && piece.player === player ? 1 : 0;
prefix[row + 1][col + 1] =
occupied +
prefix[row][col + 1] +
prefix[row + 1][col] -
prefix[row][col];
}
}
const counts = {};
let total = 0;
for (let size = 1; size <= BOARD_SIZE; size += 1) {
let squareCount = 0;
const requiredArea = size * size;
for (
let startRow = 0;
startRow <= BOARD_SIZE - size;
startRow += 1
) {
for (
let startCol = 0;
startCol <= BOARD_SIZE - size;
startCol += 1
) {
const endRow = startRow + size;
const endCol = startCol + size;
const area =
prefix[endRow][endCol] -
prefix[startRow][endCol] -
prefix[endRow][startCol] +
prefix[startRow][startCol];
if (area === requiredArea) {
squareCount += 1;
}
}
}
counts[size] = squareCount;
total += squareCount * requiredArea;
}
return {
total,
counts
};
}
function recalculateScores() {
state.scoreStats[1] = calculateScoreStats(1);
state.scoreStats[2] = calculateScoreStats(2);
}
function cloneScoreStats(stats) {
return {
total: stats.total,
counts: { ...stats.counts }
};
}
/* ---------- Connected SVG geometry ---------- */
function svgElement(name) {
return document.createElementNS(SVG_NS, name);
}
function pointKey(x, y) {
return `${x},${y}`;
}
function cellKey(col, row) {
return `${col},${row}`;
}
function edgeDirection(edge) {
const dx = edge.ex - edge.sx;
const dy = edge.ey - edge.sy;
if (dx > 0) return 0;
if (dy > 0) return 1;
if (dx < 0) return 2;
return 3;
}
function getBoundaryLoops(cells) {
const occupied = new Set(
cells.map((cell) => cellKey(cell.col, cell.row))
);
const edges = [];
function addEdge(sx, sy, ex, ey) {
const edge = { sx, sy, ex, ey };
edge.dir = edgeDirection(edge);
edges.push(edge);
}
for (const cell of cells) {
const x = cell.col;
const y = cell.row;
if (!occupied.has(cellKey(x, y - 1))) {
addEdge(x, y, x + 1, y);
}
if (!occupied.has(cellKey(x + 1, y))) {
addEdge(x + 1, y, x + 1, y + 1);
}
if (!occupied.has(cellKey(x, y + 1))) {
addEdge(x + 1, y + 1, x, y + 1);
}
if (!occupied.has(cellKey(x - 1, y))) {
addEdge(x, y + 1, x, y);
}
}
const startMap = new Map();
edges.forEach((edge, index) => {
const key = pointKey(edge.sx, edge.sy);
if (!startMap.has(key)) {
startMap.set(key, []);
}
startMap.get(key).push(index);
});
const used = new Set();
const loops = [];
for (let index = 0; index < edges.length; index += 1) {
if (used.has(index)) {
continue;
}
const firstEdge = edges[index];
const startingKey = pointKey(firstEdge.sx, firstEdge.sy);
const points = [
{ x: firstEdge.sx, y: firstEdge.sy }
];
let currentIndex = index;
let safety = 0;
while (safety < edges.length + 5) {
safety += 1;
const currentEdge = edges[currentIndex];
used.add(currentIndex);
const endingKey = pointKey(
currentEdge.ex,
currentEdge.ey
);
if (endingKey === startingKey) {
break;
}
points.push({
x: currentEdge.ex,
y: currentEdge.ey
});
const candidates = (
startMap.get(endingKey) || []
).filter((candidateIndex) => !used.has(candidateIndex));
if (!candidates.length) {
break;
}
/*
* Prefer a right turn, then straight, then left.
* This keeps the occupied area on the right side.
*/
const turnRank = {
1: 0,
0: 1,
3: 2,
2: 3
};
candidates.sort((a, b) => {
const turnA =
(edges[a].dir - currentEdge.dir + 4) % 4;
const turnB =
(edges[b].dir - currentEdge.dir + 4) % 4;
return turnRank[turnA] - turnRank[turnB];
});
currentIndex = candidates[0];
}
if (points.length >= 4) {
loops.push(points);
}
}
return loops;
}
function numberText(value) {
return Number(value.toFixed(3)).toString();
}
function roundedLoopPath(points, radius) {
const count = points.length;
const before = [];
const after = [];
for (let index = 0; index < count; index += 1) {
const previous = points[(index - 1 + count) % count];
const current = points[index];
const next = points[(index + 1) % count];
const previousDistance = Math.hypot(
previous.x - current.x,
previous.y - current.y
);
const nextDistance = Math.hypot(
next.x - current.x,
next.y - current.y
);
const beforeRadius = Math.min(
radius,
previousDistance / 2
);
const afterRadius = Math.min(
radius,
nextDistance / 2
);
before.push({
x:
current.x +
((previous.x - current.x) / previousDistance) *
beforeRadius,
y:
current.y +
((previous.y - current.y) / previousDistance) *
beforeRadius
});
after.push({
x:
current.x +
((next.x - current.x) / nextDistance) *
afterRadius,
y:
current.y +
((next.y - current.y) / nextDistance) *
afterRadius
});
}
let path = `M ${numberText(after[0].x)} ${numberText(after[0].y)}`;
for (let index = 1; index < count; index += 1) {
path +=
` L ${numberText(before[index].x)} ${numberText(before[index].y)}` +
` Q ${numberText(points[index].x)} ${numberText(points[index].y)}` +
` ${numberText(after[index].x)} ${numberText(after[index].y)}`;
}
path +=
` L ${numberText(before[0].x)} ${numberText(before[0].y)}` +
` Q ${numberText(points[0].x)} ${numberText(points[0].y)}` +
` ${numberText(after[0].x)} ${numberText(after[0].y)} Z`;
return path;
}
function buildRoundedPiecePath(
cells,
unit,
originX,
originY,
radius
) {
const loops = getBoundaryLoops(cells);
return loops.map((loop) => {
const transformed = loop.map((point) => ({
x: originX + point.x * unit,
y: originY + point.y * unit
}));
return roundedLoopPath(transformed, radius);
}).join(" ");
}
function buildDividerPath(cells, unit, originX, originY) {
const occupied = new Set(
cells.map((cell) => cellKey(cell.col, cell.row))
);
let path = "";
for (const cell of cells) {
const x = cell.col;
const y = cell.row;
if (occupied.has(cellKey(x + 1, y))) {
const lineX = originX + (x + 1) * unit;
const top = originY + y * unit;
const bottom = originY + (y + 1) * unit;
path +=
`M ${numberText(lineX)} ${numberText(top)}` +
` L ${numberText(lineX)} ${numberText(bottom)} `;
}
if (occupied.has(cellKey(x, y + 1))) {
const lineY = originY + (y + 1) * unit;
const left = originX + x * unit;
const right = originX + (x + 1) * unit;
path +=
`M ${numberText(left)} ${numberText(lineY)}` +
` L ${numberText(right)} ${numberText(lineY)} `;
}
}
return path.trim();
}
function renderMiniPiece(button, type, orientation, player) {
button.replaceChildren();
const svg = svgElement("svg");
svg.setAttribute("viewBox", "0 0 100 100");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
const offsets = getPieceOffsets(type, orientation);
const cells = offsets.map(([dx, dy]) => ({
col: dx,
row: dy
}));
const columns = cells.map((cell) => cell.col);
const rows = cells.map((cell) => cell.row);
const minCol = Math.min(...columns);
const maxCol = Math.max(...columns);
const minRow = Math.min(...rows);
const maxRow = Math.max(...rows);
const unit =
type === "fino" ? 21 :
type === "vino" ? 28 :
38;
const width = (maxCol - minCol + 1) * unit;
const height = (maxRow - minRow + 1) * unit;
const originX = (100 - width) / 2 - minCol * unit;
const originY = (100 - height) / 2 - minRow * unit;
const body = svgElement("path");
body.setAttribute(
"d",
buildRoundedPiecePath(
cells,
unit,
originX,
originY,
unit * 0.18
)
);
body.setAttribute(
"class",
`mini-piece owner-${player}`
);
svg.appendChild(body);
const dividerPath = buildDividerPath(
cells,
unit,
originX,
originY
);
if (dividerPath) {
const divider = svgElement("path");
divider.setAttribute("d", dividerPath);
divider.setAttribute(
"class",
`mini-divider owner-${player}`
);
svg.appendChild(divider);
}
if (type !== "mino") {
const anchor = svgElement("circle");
anchor.setAttribute(
"cx",
originX + unit / 2
);
anchor.setAttribute(
"cy",
originY + unit / 2
);
anchor.setAttribute("r", 2.5);
anchor.setAttribute("class", "anchor-dot");
svg.appendChild(anchor);
}
button.appendChild(svg);
}
function buildBoardGrid() {
let pathData = "";
for (let index = 1; index < BOARD_SIZE; index += 1) {
const position = index * CELL_SIZE;
pathData += `M ${position} 0 V 480 `;
pathData += `M 0 ${position} H 480 `;
}
const path = svgElement("path");
path.setAttribute("d", pathData.trim());
path.setAttribute("class", "grid-lines");
els.gridLayer.appendChild(path);
}
/* ---------- Rigid Mino pushing ---------- */
function analyzeMinoMove(minoId, directionName) {
const direction = DIRECTIONS[directionName];
const mino = getPieceById(minoId);
if (
!direction ||
!mino ||
mino.type !== "mino" ||
mino.player !== state.currentPlayer
) {
return {
ok: false,
reason: "invalid",
movingIds: []
};
}
const movingIds = new Set([minoId]);
const queue = [minoId];
while (queue.length) {
const pieceId = queue.shift();
const piece = getPieceById(pieceId);
for (const cell of piece.cells) {
const targetRow = cell.row + direction.dr;
const targetCol = cell.col + direction.dc;
if (
targetRow < 0 ||
targetRow >= BOARD_SIZE ||
targetCol < 0 || targetCol >= BOARD_SIZE ) { return { ok: false, reason: "bounds", movingIds: [] }; }
const targetId = state.board[targetRow][targetCol];
if (
!targetId ||
movingIds.has(targetId)
) {
continue;
}
const targetPiece = getPieceById(targetId);
if (!targetPiece) {
continue;
}
/*
* A Mino can never push another Mino, regardless of owner.
* Fino and Vino are rigid pieces and join the push chain.
*/
if (targetPiece.type === "mino") {
return {
ok: false,
reason: "mino",
movingIds: []
};
}
movingIds.add(targetId);
queue.push(targetId);
}
}
/*
* Final simultaneous-movement validation. Cells occupied by another
* member of the moving set are valid because that member also moves.
*/
for (const pieceId of movingIds) {
const piece = getPieceById(pieceId);
for (const cell of piece.cells) {
const targetRow = cell.row + direction.dr;
const targetCol = cell.col + direction.dc;
if (
targetRow < 0 ||
targetRow >= BOARD_SIZE ||
targetCol < 0 ||
targetCol >= BOARD_SIZE
) {
return {
ok: false,
reason: "bounds",
movingIds: []
};
}
const targetId = state.board[targetRow][targetCol];
if (
targetId &&
!movingIds.has(targetId)
) {
return {
ok: false,
reason: "blocked",
movingIds: []
};
}
}
}
return {
ok: true,
reason: null,
movingIds: [...movingIds]
};
}
function executeMinoMove(minoId, directionName) {
if (
!state.gameStarted ||
state.gameOver ||
state.roundEnded
) {
return;
}
const requiredPiece = getRequiredPiece(state.currentPlayer);
if (requiredPiece !== "vino") {
showToast("endOnlyVino");
return;
}
if (state.minoMovedThisTurn) {
showToast("minoAlreadyMoved");
return;
}
const analysis = analyzeMinoMove(minoId, directionName);
if (!analysis.ok) {
showToast("moveBlocked");
return;
}
const direction = DIRECTIONS[directionName];
for (const pieceId of analysis.movingIds) {
const piece = getPieceById(pieceId);
piece.cells = piece.cells.map((cell) => ({
row: cell.row + direction.dr,
col: cell.col + direction.dc
}));
}
rebuildBoard();
recalculateScores();
state.minoMovedThisTurn = true;
state.moveSelection = null;
state.hoverCell = null;
pushHistorySnapshot();
renderAll();
showToast("minoMoved");
}
function selectMinoForMovement(piece) {
if (
!piece ||
piece.type !== "mino" ||
piece.player !== state.currentPlayer
) {
return;
}
if (state.minoMovedThisTurn) {
showToast("minoAlreadyMoved");
return;
}
if (
state.moveSelection &&
state.moveSelection.pieceId === piece.id
) {
state.moveSelection = null;
renderBoard();
renderStatus();
return;
}
const legalDirections = Object.keys(DIRECTIONS).filter(
(directionName) => (
analyzeMinoMove(piece.id, directionName).ok
)
);
if (!legalDirections.length) {
state.moveSelection = null;
renderBoard();
renderStatus();
showToast("noMinoDirections");
return;
}
state.moveSelection = {
pieceId: piece.id
};
state.hoverCell = null;
renderBoard();
renderStatus();
}
/* ---------- Rendering ---------- */
function renderScores() {
const score1 = state.scoreStats[1];
const score2 = state.scoreStats[2];
els.playerName1.textContent = playerName(1);
els.playerName2.textContent = playerName(2);
els.score1.textContent = String(score1.total);
els.score2.textContent = String(score2.total);
els.square2Player1.textContent = `×${score1.counts[2] || 0}`;
els.square3Player1.textContent = `×${score1.counts[3] || 0}`;
els.square2Player2.textContent = `×${score2.counts[2] || 0}`;
els.square3Player2.textContent = `×${score2.counts[3] || 0}`;
const player1Active =
state.gameStarted &&
!state.gameOver &&
!state.roundEnded &&
state.currentPlayer === 1;
const player2Active =
state.gameStarted &&
!state.gameOver &&
!state.roundEnded &&
state.currentPlayer === 2;
els.scoreCard1.classList.toggle("active", player1Active);
els.scoreCard2.classList.toggle("active", player2Active);
els.player1Panel.classList.toggle(
"player-finished",
state.endedPlayers[1]
);
els.player2Panel.classList.toggle(
"player-finished",
state.endedPlayers[2]
);
els.player1Panel.setAttribute("aria-label", playerName(1));
els.player2Panel.setAttribute("aria-label", playerName(2));
}
function renderPieces() {
document.querySelectorAll(".piece-item").forEach((item) => {
const player = Number(item.dataset.player);
const type = item.dataset.type;
const frame = item.querySelector(".piece-frame");
const nameElement = item.querySelector(".piece-name");
const countElement = item.querySelector(".piece-count");
const count = state.inventory[player][type];
const requiredPiece = getRequiredPiece(player);
const isCurrent =
state.gameStarted &&
!state.gameOver &&
!state.roundEnded &&
!state.endedPlayers[player] &&
state.currentPlayer === player;
const autoSelected =
isCurrent &&
requiredPiece === type;
const moveSource =
isCurrent &&
requiredPiece === "vino" &&
type === "mino" &&
!state.minoMovedThisTurn &&
state.pieces.some((piece) => (
piece.player === player &&
piece.type === "mino"
));
const locked =
!autoSelected &&
!moveSource;
const orientation =
state.orientationMemory[player][type] ||
(type === "mino" ? "single" : "tl");
item.classList.toggle(
"directional",
type === "fino" || type === "vino"
);
item.classList.toggle("auto-selected", autoSelected);
item.classList.toggle("move-source", moveSource);
item.classList.toggle("locked", locked);
item.classList.toggle("not-current", !isCurrent);
item.classList.toggle("depleted", count === 0);
nameElement.textContent = pieceName(type);
countElement.textContent = `×${count}`;
const label = translate("pieceCountAria", {
player: playerToken(player),
piece: pieceToken(type),
count
});
frame.setAttribute("aria-label", label);
frame.setAttribute(
"aria-pressed",
String(autoSelected || moveSource)
);
frame.setAttribute(
"aria-disabled",
String(!isCurrent)
);
frame.title = label;
renderMiniPiece(
frame,
type,
orientation,
player
);
});
}
function playerPhaseText(player) {
if (state.roundEnded || state.gameOver) {
return translate("phaseRoundOver");
}
if (state.endedPlayers[player]) {
return translate("phaseFinished");
}
return phaseName(getRequiredPiece(player));
}
function renderPlayerFooters() {
els.phaseText1.textContent = playerPhaseText(1);
els.phaseText2.textContent = playerPhaseText(2);
els.phaseText1.classList.toggle(
"current",
state.currentPlayer === 1 &&
!state.roundEnded &&
!state.gameOver
);
els.phaseText2.classList.toggle(
"current",
state.currentPlayer === 2 &&
!state.roundEnded &&
!state.gameOver
);
[
[1, els.endPlayer1],
[2, els.endPlayer2]
].forEach(([player, button]) => {
const isCurrent =
state.gameStarted &&
!state.gameOver &&
!state.roundEnded &&
!state.endedPlayers[player] &&
state.currentPlayer === player;
const requiredPiece = getRequiredPiece(player);
const canEnd = isCurrent && requiredPiece === "vino";
button.classList.toggle("is-visible", isCurrent);
button.disabled = !canEnd;
button.title = canEnd
? translate("endOwnGame")
: translate("endOnlyVino");
});
}
function renderPlacedPieces() {
const fragment = document.createDocumentFragment();
for (const piece of state.pieces) {
const group = svgElement("g");
group.dataset.pieceId = String(piece.id);
const body = svgElement("path");
body.setAttribute(
"d",
buildRoundedPiecePath(
piece.cells,
CELL_SIZE,
0,
0,
7
)
);
body.setAttribute(
"class",
`board-piece owner-${piece.player}`
);
body.setAttribute("fill-rule", "evenodd");
body.dataset.pieceId = String(piece.id);
group.appendChild(body);
const dividerPath = buildDividerPath(
piece.cells,
CELL_SIZE,
0,
0
);
if (dividerPath) {
const divider = svgElement("path");
divider.setAttribute("d", dividerPath);
divider.setAttribute(
"class",
`board-divider owner-${piece.player}`
);
divider.setAttribute("pointer-events", "none");
group.appendChild(divider);
}
fragment.appendChild(group);
}
els.placedLayer.replaceChildren(fragment);
}
function renderPreview() {
els.previewLayer.replaceChildren();
if (
!state.gameStarted ||
state.gameOver ||
state.roundEnded ||
!state.currentPlayer ||
!state.hoverCell ||
state.moveSelection
) {
return;
}
const type = getRequiredPiece(state.currentPlayer);
if (!type) {
return;
}
const orientation =
type === "mino"
? "single"
: state.orientationMemory[state.currentPlayer][type];
const validation = validatePlacement(
state.currentPlayer,
type,
orientation,
state.hoverCell.row,
state.hoverCell.col
);
const body = svgElement("path");
body.setAttribute(
"d",
buildRoundedPiecePath(
validation.cells,
CELL_SIZE,
0,
0,
7
)
);
body.setAttribute(
"class",
`preview-piece ${validation.ok ? "valid" : "invalid"}`
);
body.setAttribute("fill-rule", "evenodd");
els.previewLayer.appendChild(body);
const dividerPath = buildDividerPath(
validation.cells,
CELL_SIZE,
0,
0
);
if (dividerPath) {
const divider = svgElement("path");
divider.setAttribute("d", dividerPath);
divider.setAttribute("class", "preview-divider");
els.previewLayer.appendChild(divider);
}
if (type !== "mino") {
const anchor = svgElement("circle");
anchor.setAttribute(
"cx",
state.hoverCell.col * CELL_SIZE + CELL_SIZE / 2
);
anchor.setAttribute(
"cy",
state.hoverCell.row * CELL_SIZE + CELL_SIZE / 2
);
anchor.setAttribute("r", 4);
anchor.setAttribute("class", "preview-anchor");
els.previewLayer.appendChild(anchor);
}
}
function directionAriaLabel(directionName) {
const labels = state.lang === "zh"
? {
up: "向上移動米諾",
right: "向右移動米諾",
down: "向下移動米諾",
left: "向左移動米諾"
}
: {
up: "Move Mino up",
right: "Move Mino right",
down: "Move Mino down",
left: "Move Mino left"
};
return labels[directionName];
}
function renderMoveLayer() {
els.moveLayer.replaceChildren();
if (
!state.moveSelection ||
state.gameOver ||
state.roundEnded ||
state.minoMovedThisTurn ||
getRequiredPiece(state.currentPlayer) !== "vino"
) {
return;
}
const mino = getPieceById(
state.moveSelection.pieceId
);
if (
!mino ||
mino.type !== "mino" ||
mino.player !== state.currentPlayer
) {
state.moveSelection = null;
return;
}
const cell = mino.cells[0];
const halo = svgElement("rect");
halo.setAttribute(
"x",
cell.col * CELL_SIZE + 6
);
halo.setAttribute(
"y",
cell.row * CELL_SIZE + 6
);
halo.setAttribute("width", CELL_SIZE - 12);
halo.setAttribute("height", CELL_SIZE - 12);
halo.setAttribute("rx", 10);
halo.setAttribute("class", "selected-mino-halo");
halo.setAttribute("pointer-events", "none");
els.moveLayer.appendChild(halo);
for (const [directionName, direction] of Object.entries(DIRECTIONS)) {
const analysis = analyzeMinoMove(
mino.id,
directionName
);
if (!analysis.ok) {
continue;
}
const centerX =
(cell.col + 0.5 + direction.dc) * CELL_SIZE;
const centerY =
(cell.row + 0.5 + direction.dr) * CELL_SIZE;
const arrow = svgElement("g");
arrow.setAttribute("class", "move-arrow");
arrow.setAttribute(
"transform",
`translate(${centerX} ${centerY}) rotate(${direction.angle})`
);
arrow.setAttribute("role", "button");
arrow.setAttribute("tabindex", "0");
arrow.setAttribute(
"aria-label",
directionAriaLabel(directionName)
);
arrow.dataset.direction = directionName;
const circle = svgElement("circle");
circle.setAttribute("cx", 0);
circle.setAttribute("cy", 0);
circle.setAttribute("r", 15);
arrow.appendChild(circle);
const arrowPath = svgElement("path");
arrowPath.setAttribute(
"d",
"M -7 0 H 7 M 2 -5 L 7 0 L 2 5"
);
arrow.appendChild(arrowPath);
arrow.addEventListener("click", (event) => {
event.stopPropagation();
executeMinoMove(
mino.id,
directionName
);
});
arrow.addEventListener("keydown", (event) => {
if (
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
event.stopPropagation();
executeMinoMove(
mino.id,
directionName
);
}
});
els.moveLayer.appendChild(arrow);
}
}
function currentStatusText() {
if (!state.gameStarted) {
return translate("boardReady");
}
if (state.gameOver) {
return translate("matchCompleteStatus");
}
if (state.roundEnded) {
return translate("roundCompleteStatus", {
round: state.roundNumber
});
}
const requiredPiece = getRequiredPiece(
state.currentPlayer
);
if (state.moveSelection) {
return translate("moveMinoMode", {
player: playerToken(state.currentPlayer)
});
}
let status = translate("turnStage", {
player: playerToken(state.currentPlayer),
phase: phaseName(requiredPiece)
});
if (
requiredPiece === "vino" &&
state.minoMovedThisTurn
) {
status += ` · ${translate("minoMovedShort")}`;
}
return status;
}
function renderBoard() {
renderPlacedPieces();
renderPreview();
renderMoveLayer();
const status = currentStatusText();
const boardDescription = translate("boardAria", {
status
});
els.boardSvgTitle.textContent = boardDescription;
els.boardSvg.setAttribute(
"aria-label",
boardDescription
);
}
function renderStatus() {
if (!state.gameStarted) {
return;
}
if (state.gameOver) {
els.roundText.textContent =
translate("matchCompleteStatus");
} else if (state.roundEnded) {
els.roundText.textContent =
translate("roundCompleteStatus", {
round: state.roundNumber
});
} else {
els.roundText.textContent =
translate("roundOf", {
round: state.roundNumber
});
}
if (state.roundEnded || state.gameOver) {
els.turnText.hidden = true;
els.viewResultButton.hidden = false;
els.viewResultButton.textContent = state.gameOver
? translate("viewFinalResult")
: translate("viewRoundResult");
return;
}
els.viewResultButton.hidden = true;
els.turnText.hidden = false;
els.turnText.textContent = currentStatusText();
els.turnText.classList.remove(
"owner-1",
"owner-2"
);
els.turnText.classList.add(
`owner-${state.currentPlayer}`
);
}
function updateToolbar() {
els.undoButton.disabled =
!state.gameStarted ||
state.gameOver ||
state.historyIndex <= 0;
els.redoButton.disabled =
!state.gameStarted ||
state.gameOver ||
state.historyIndex >= state.history.length - 1;
}
function renderAll() {
renderScores();
renderPieces();
renderPlayerFooters();
renderBoard();
renderStatus();
updateToolbar();
}
/* ---------- Turn and round flow ---------- */
function completeRoundByMandatoryBlock(
loser,
requiredType
) {
recalculateScores();
state.roundEnded = true;
state.currentPlayer = 0;
state.moveSelection = null;
state.hoverCell = null;
state.endInfo = {
reason: "mandatoryBlocked",
winner: otherPlayer(loser),
loser,
requiredType
};
}
function completeRoundByVinoScores() {
recalculateScores();
let winner = 0;
if (
state.scoreStats[1].total >
state.scoreStats[2].total
) {
winner = 1;
} else if (
state.scoreStats[2].total >
state.scoreStats[1].total
) {
winner = 2;
}
state.roundEnded = true;
state.currentPlayer = 0;
state.moveSelection = null;
state.hoverCell = null;
state.endInfo = {
reason: "vinoFinished",
winner,
loser: winner ? otherPlayer(winner) : 0,
requiredType: null
};
}
function ensureCurrentTurnIsValid() {
let guard = 0;
while (
!state.roundEnded &&
guard < 5
) {
guard += 1;
if (
state.endedPlayers[1] &&
state.endedPlayers[2]
) {
completeRoundByVinoScores();
return;
}
if (
!state.currentPlayer ||
state.endedPlayers[state.currentPlayer]
) {
const candidate =
!state.endedPlayers[1]
? 1
: !state.endedPlayers[2]
? 2
: 0;
if (!candidate) {
completeRoundByVinoScores();
return;
}
state.currentPlayer = candidate;
}
const requiredPiece = getRequiredPiece(
state.currentPlayer
);
/*
* No remaining piece means all seven Vino pieces were used,
* so this player's play has ended automatically.
*/
if (!requiredPiece) {
state.endedPlayers[state.currentPlayer] = true;
state.currentPlayer = otherPlayer(
state.currentPlayer
);
continue;
}
if (
(requiredPiece === "fino" ||
requiredPiece === "mino") &&
!hasAnyLegalPlacement(
state.currentPlayer,
requiredPiece
)
) {
completeRoundByMandatoryBlock(
state.currentPlayer,
requiredPiece
);
}
return;
}
}
function advanceAfterAction(actor) {
state.moveSelection = null;
state.hoverCell = null;
state.minoMovedThisTurn = false;
if (
state.endedPlayers[1] &&
state.endedPlayers[2]
) {
completeRoundByVinoScores();
return;
}
const opponent = otherPlayer(actor);
if (!state.endedPlayers[opponent]) {
state.currentPlayer = opponent;
} else if (!state.endedPlayers[actor]) {
/*
* Once the opponent has ended, the remaining player takes
* consecutive turns until also ending.
*/
state.currentPlayer = actor;
} else {
state.currentPlayer = 0;
}
ensureCurrentTurnIsValid();
}
function scheduleRoundModal() {
const roundAtScheduling = state.roundNumber;
window.setTimeout(() => {
if (
state.roundEnded &&
!state.gameOver &&
state.roundNumber === roundAtScheduling
) {
showRoundModal();
}
}, 120);
}
function placeCurrentPiece(validation) {
const player = state.currentPlayer;
const type = getRequiredPiece(player);
if (!type) {
return;
}
const orientation =
type === "mino"
? "single"
: state.orientationMemory[player][type];
const piece = {
id: state.nextPieceId,
player,
type,
orientation,
cells: validation.cells.map((cell) => ({
row: cell.row,
col: cell.col
}))
};
state.nextPieceId += 1;
state.pieces.push(piece);
state.inventory[player][type] -= 1;
rebuildBoard();
recalculateScores();
const automaticallyFinished =
type === "vino" &&
state.inventory[player].vino === 0;
if (automaticallyFinished) {
state.endedPlayers[player] = true;
}
advanceAfterAction(player);
pushHistorySnapshot();
renderAll();
if (state.roundEnded) {
scheduleRoundModal();
} else if (automaticallyFinished) {
showToast("playerEndedContinues", {
player: playerToken(player),
next: playerToken(state.currentPlayer)
});
}
}
function endPlayerGame(player) {
if (
!state.gameStarted ||
state.gameOver ||
state.roundEnded
) {
return;
}
if (player !== state.currentPlayer) {
showToast("notTurn", {
player: playerToken(state.currentPlayer)
});
return;
}
if (getRequiredPiece(player) !== "vino") {
showToast("endOnlyVino");
return;
}
state.endedPlayers[player] = true;
advanceAfterAction(player);
pushHistorySnapshot();
renderAll();
if (state.roundEnded) {
scheduleRoundModal();
} else {
showToast("playerEndedContinues", {
player: playerToken(player),
next: playerToken(state.currentPlayer)
});
}
}
/* ---------- Pointer and piece controls ---------- */
function getBoardCellFromPointer(event) {
const screenMatrix = els.boardSvg.getScreenCTM();
if (!screenMatrix) {
return null;
}
const point = els.boardSvg.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
const transformed = point.matrixTransform(
screenMatrix.inverse()
);
const col = Math.floor(
transformed.x / CELL_SIZE
);
const row = Math.floor(
transformed.y / CELL_SIZE
);
if (
row < 0 ||
row >= BOARD_SIZE ||
col < 0 ||
col >= BOARD_SIZE
) {
return null;
}
return { row, col };
}
function getOrientationFromFrameClick(
event,
frame
) {
const player = Number(frame.dataset.player);
const type = frame.dataset.type;
if (event.detail === 0) {
return (
state.orientationMemory[player][type] ||
"tl"
);
}
const rect = frame.getBoundingClientRect();
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
const left = localX < rect.width / 2;
const top = localY < rect.height / 2;
if (top && left) return "tl";
if (top && !left) return "tr";
if (!top && !left) return "br";
return "bl";
}
function handlePieceFrameClick(event, frame) {
if (
!state.gameStarted ||
state.gameOver ||
state.roundEnded
) {
return;
}
const player = Number(frame.dataset.player);
const type = frame.dataset.type;
if (player !== state.currentPlayer) {
showToast("notTurn", {
player: playerToken(state.currentPlayer)
});
return;
}
const requiredPiece = getRequiredPiece(player);
/*
* During the Vino phase, the Mino frame is a reminder that
* movement starts by selecting a placed Mino on the board.
*/
if (
requiredPiece === "vino" &&
type === "mino"
) {
if (state.minoMovedThisTurn) {
showToast("minoAlreadyMoved");
} else {
showToast("selectBoardMino");
}
return;
}
if (type !== requiredPiece) {
showToast("autoSelected", {
piece: pieceToken(requiredPiece)
});
return;
}
if (type === "mino") {
showToast("autoSelected", {
piece: pieceToken(type)
});
return;
}
const orientation = getOrientationFromFrameClick(
event,
frame
);
state.orientationMemory[player][type] =
orientation;
state.moveSelection = null;
renderPieces();
renderBoard();
renderStatus();
}
function handleBoardClick(event) {
if (!state.gameStarted) {
return;
}
if (state.roundEnded || state.gameOver) {
openCurrentResult();
return;
}
if (event.target.closest(".move-arrow")) {
return;
}
const cell = getBoardCellFromPointer(event);
if (!cell) {
return;
}
const player = state.currentPlayer;
const type = getRequiredPiece(player);
if (!type) {
return;
}
const clickedPiece = getPieceAt(
cell.row,
cell.col
);
/*
* Clicking one's own board Mino during the Vino phase enters
* movement mode instead of attempting a Vino placement there.
*/
if (
type === "vino" &&
clickedPiece &&
clickedPiece.player === player &&
clickedPiece.type === "mino"
) {
if (state.minoMovedThisTurn) {
showToast("minoAlreadyMoved");
} else {
selectMinoForMovement(clickedPiece);
}
return;
}
state.moveSelection = null;
const orientation =
type === "mino"
? "single"
: state.orientationMemory[player][type];
const validation = validatePlacement(
player,
type,
orientation,
cell.row,
cell.col
);
if (!validation.ok) {
const messageKey = {
bounds: "outOfBounds",
overlap: "overlap",
touch: "touchForbidden"
}[validation.reason] || "overlap";
showToast(messageKey, {
piece: pieceToken(type)
});
renderBoard();
renderStatus();
return;
}
placeCurrentPiece(validation);
}
function handleBoardPointerMove(event) {
if (
!state.gameStarted ||
state.gameOver ||
state.roundEnded ||
!getRequiredPiece(state.currentPlayer)
) {
return;
}
const cell = getBoardCellFromPointer(event);
if (!cell) {
if (state.hoverCell) {
state.hoverCell = null;
renderPreview();
}
return;
}
if (
!state.hoverCell ||
state.hoverCell.row !== cell.row ||
state.hoverCell.col !== cell.col
) {
state.hoverCell = cell;
renderPreview();
}
}
/* ---------- Undo and redo ---------- */
function clonePieces(pieces) {
return pieces.map((piece) => ({
id: piece.id,
player: piece.player,
type: piece.type,
orientation: piece.orientation,
cells: piece.cells.map((cell) => ({
row: cell.row,
col: cell.col
}))
}));
}
function captureSnapshot() {
return {
pieces: clonePieces(state.pieces),
nextPieceId: state.nextPieceId,
inventory: {
1: { ...state.inventory[1] },
2: { ...state.inventory[2] }
},
orientationMemory: {
1: { ...state.orientationMemory[1] },
2: { ...state.orientationMemory[2] }
},
endedPlayers: {
1: state.endedPlayers[1],
2: state.endedPlayers[2]
},
currentPlayer: state.currentPlayer,
minoMovedThisTurn: state.minoMovedThisTurn,
roundEnded: state.roundEnded,
endInfo: state.endInfo
? { ...state.endInfo }
: null
};
}
function restoreSnapshot(snapshot) {
state.pieces = clonePieces(snapshot.pieces);
state.nextPieceId = snapshot.nextPieceId;
state.inventory = {
1: { ...snapshot.inventory[1] },
2: { ...snapshot.inventory[2] }
};
state.orientationMemory = {
1: { ...snapshot.orientationMemory[1] },
2: { ...snapshot.orientationMemory[2] }
};
state.endedPlayers = {
1: snapshot.endedPlayers[1],
2: snapshot.endedPlayers[2]
};
state.currentPlayer = snapshot.currentPlayer;
state.minoMovedThisTurn =
snapshot.minoMovedThisTurn;
state.roundEnded = snapshot.roundEnded;
state.endInfo = snapshot.endInfo
? { ...snapshot.endInfo }
: null;
state.moveSelection = null;
state.hoverCell = null;
rebuildBoard();
recalculateScores();
}
function initializeHistory() {
state.history = [captureSnapshot()];
state.historyIndex = 0;
}
function pushHistorySnapshot() {
state.history = state.history.slice(
0,
state.historyIndex + 1
);
state.history.push(captureSnapshot());
state.historyIndex =
state.history.length - 1;
}
function undoMove() {
if (
state.gameOver ||
state.historyIndex <= 0
) {
return;
}
hideModal();
clearToast();
state.historyIndex -= 1;
restoreSnapshot(
state.history[state.historyIndex]
);
renderAll();
}
function redoMove() {
if (
state.gameOver ||
state.historyIndex >=
state.history.length - 1
) {
return;
}
hideModal();
clearToast();
state.historyIndex += 1;
restoreSnapshot(
state.history[state.historyIndex]
);
renderAll();
if (state.roundEnded) {
scheduleRoundModal();
}
}
/* ---------- Starting and resetting ---------- */
function startRound(roundNumber) {
hideModal();
state.roundNumber = roundNumber;
state.gameOver = false;
state.roundEnded = false;
state.endInfo = null;
state.board = createEmptyBoard();
state.pieces = [];
state.pieceIndex = new Map();
state.nextPieceId = 1;
state.inventory = createInventory();
state.orientationMemory =
createOrientationMemory();
state.scoreStats = {
1: createEmptyScoreStats(),
2: createEmptyScoreStats()
};
state.endedPlayers = {
1: false,
2: false
};
state.minoMovedThisTurn = false;
state.moveSelection = null;
state.hoverCell = null;
state.currentPlayer =
roundNumber === 1 ? 1 : 2;
ensureCurrentTurnIsValid();
initializeHistory();
updateResponsiveLayout();
renderAll();
}
function startGame() {
closeRules();
state.gameStarted = true;
state.gameOver = false;
state.roundResults = [];
els.startScreen.hidden = true;
els.gameScreen.hidden = false;
syncBodyLock();
updateResponsiveLayout();
startRound(1);
}
function returnToTitle() {
hideModal();
closeRules();
clearToast();
state.gameStarted = false;
state.gameOver = false;
state.roundEnded = false;
state.roundResults = [];
els.gameScreen.hidden = true;
els.startScreen.hidden = false;
syncBodyLock();
window.scrollTo({
top: 0,
behavior: "smooth"
});
requestAnimationFrame(() => {
els.startButton.focus();
});
}
/* ---------- Rules dialog ---------- */
function syncBodyLock() {
const gameActive =
state.gameStarted &&
!els.gameScreen.hidden;
const dialogOpen =
!els.rulesOverlay.hidden ||
!els.modalBackdrop.hidden;
document.body.classList.toggle(
"game-active",
gameActive
);
document.body.classList.toggle(
"dialog-open",
dialogOpen
);
}
function openRules() {
els.rulesOverlay.hidden = false;
syncBodyLock();
requestAnimationFrame(() => {
els.rulesCloseButton.focus();
});
}
function closeRules() {
els.rulesOverlay.hidden = true;
syncBodyLock();
}
/* ---------- Round and match results ---------- */
function getPendingRoundResult() {
if (
!state.roundEnded ||
!state.endInfo
) {
return null;
}
return {
round: state.roundNumber,
starter: state.roundNumber === 1 ? 1 : 2,
stats: {
1: cloneScoreStats(state.scoreStats[1]),
2: cloneScoreStats(state.scoreStats[2])
},
endInfo: {
...state.endInfo
}
};
}
function commitPendingRoundResult() {
const alreadyCommitted =
state.roundResults.some((result) => (
result.round === state.roundNumber
));
if (alreadyCommitted) {
return;
}
const result = getPendingRoundResult();
if (result) {
state.roundResults.push(result);
state.roundResults.sort(
(a, b) => a.round - b.round
);
}
}
function scoreCardsHTML(stats1, stats2) {
return `
<div class="modal-score player-one">
<span class="modal-score-name">${playerName(1)}</span>
<strong class="modal-score-total">${stats1.total}</strong>
<div class="modal-score-metrics">
<span>2×2 ×${stats1.counts[2] || 0}</span>
<span>3×3 ×${stats1.counts[3] || 0}</span>
</div>
</div>
<div class="modal-score player-two">
<span class="modal-score-name">${playerName(2)}</span>
<strong class="modal-score-total">${stats2.total}</strong>
<div class="modal-score-metrics">
<span>2×2 ×${stats2.counts[2] || 0}</span>
<span>3×3 ×${stats2.counts[3] || 0}</span>
</div>
</div>
`;
}
function showRoundModal(focusPrimary = true) {
const result = getPendingRoundResult();
if (!result) {
return;
}
state.modalMode = "round";
els.modalEyebrow.textContent =
translate("roundCompleteEyebrow", {
round: state.roundNumber
});
if (result.endInfo.winner) {
els.modalTitle.textContent =
translate("winsRound", {
player: playerToken(
result.endInfo.winner
)
});
} else {
els.modalTitle.textContent =
translate("tiedRound");
}
if (
result.endInfo.reason ===
"mandatoryBlocked"
) {
els.modalSummary.textContent =
translate("mandatoryBlockedSummary", {
loser: playerToken(
result.endInfo.loser
),
piece: pieceToken(
result.endInfo.requiredType
)
});
} else if (result.endInfo.winner) {
els.modalSummary.textContent =
translate("vinoFinishedWinSummary", {
winner: playerToken(
result.endInfo.winner
)
});
} else {
els.modalSummary.textContent =
translate("vinoFinishedTieSummary");
}
els.modalSectionLabel.textContent =
translate("roundScores");
els.modalScores.innerHTML = scoreCardsHTML(
result.stats[1],
result.stats[2]
);
els.modalBreakdown.hidden = true;
els.modalBreakdown.replaceChildren();
els.modalSecondary.hidden = false;
if (state.roundNumber === 1) {
els.modalPrimary.textContent =
translate("nextRound", {
player: playerToken(2)
});
els.modalSecondary.textContent =
translate("endGame");
els.modalPrimary.onclick = () => {
commitPendingRoundResult();
startRound(2);
};
els.modalSecondary.onclick = () => {
finishMatch();
};
} else {
els.modalPrimary.textContent =
translate("finishMatch");
els.modalSecondary.textContent =
translate("reviewBoard");
els.modalPrimary.onclick = () => {
finishMatch();
};
els.modalSecondary.onclick = () => {
hideModal();
};
}
els.modalBackdrop.hidden = false;
syncBodyLock();
if (focusPrimary) {
requestAnimationFrame(() => {
els.modalPrimary.focus();
});
}
}
function calculateMatchTotals(results) {
const totals = {
1: createEmptyScoreStats(),
2: createEmptyScoreStats()
};
for (const result of results) {
for (const player of [1, 2]) {
totals[player].total +=
result.stats[player].total;
for (
let size = 1;
size <= BOARD_SIZE;
size += 1
) {
totals[player].counts[size] +=
result.stats[player].counts[size] || 0;
}
}
}
return totals;
}
function finishMatch() {
commitPendingRoundResult();
state.gameOver = true;
state.roundEnded = true;
state.currentPlayer = 0;
state.moveSelection = null;
state.hoverCell = null;
renderAll();
showFinalModal();
}
function showFinalModal(focusPrimary = true) {
const results = [...state.roundResults].sort(
(a, b) => a.round - b.round
);
if (!results.length) {
return;
}
state.modalMode = "final";
const totals = calculateMatchTotals(results);
let winner = 0;
if (totals[1].total > totals[2].total) {
winner = 1;
} else if (
totals[2].total > totals[1].total
) {
winner = 2;
}
els.modalEyebrow.textContent =
translate("finalEyebrow");
els.modalTitle.textContent = winner
? translate("winsMatch", {
player: playerToken(winner)
})
: translate("tiedMatch");
els.modalSummary.textContent =
results.length === 1
? translate("totalSummaryOne")
: translate("totalSummaryTwo");
els.modalSectionLabel.textContent =
translate("totalScores");
els.modalScores.innerHTML = scoreCardsHTML(
totals[1],
totals[2]
);
const rows = results.map((result) => {
const player1Stats = result.stats[1];
const player2Stats = result.stats[2];
return `
<tr>
<td>${translate("roundShort", {
round: result.round
})}</td>
<td>
${player1Stats.total}
<small>
2×2 ×${player1Stats.counts[2] || 0}
· 3×3 ×${player1Stats.counts[3] || 0}
</small>
</td>
<td>
${player2Stats.total}
<small>
2×2 ×${player2Stats.counts[2] || 0}
· 3×3 ×${player2Stats.counts[3] || 0}
</small>
</td>
</tr>
`;
}).join("");
els.modalBreakdown.innerHTML = `
<table class="result-table">
<thead>
<tr>
<th>${translate("roundColumn")}</th>
<th>${playerName(1)}</th>
<th>${playerName(2)}</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td>${translate("total")}</td>
<td>
${totals[1].total}
<small>
2×2 ×${totals[1].counts[2] || 0}
· 3×3 ×${totals[1].counts[3] || 0}
</small>
</td>
<td>
${totals[2].total}
<small>
2×2 ×${totals[2].counts[2] || 0}
· 3×3 ×${totals[2].counts[3] || 0}
</small>
</td>
</tr>
</tfoot>
</table>
`;
els.modalBreakdown.hidden = false;
els.modalPrimary.textContent =
translate("playAgain");
els.modalSecondary.textContent =
translate("backToTitle");
els.modalSecondary.hidden = false;
els.modalPrimary.onclick = () => {
state.roundResults = [];
state.gameOver = false;
state.gameStarted = true;
startRound(1);
};
els.modalSecondary.onclick = () => {
returnToTitle();
};
els.modalBackdrop.hidden = false;
syncBodyLock();
if (focusPrimary) {
requestAnimationFrame(() => {
els.modalPrimary.focus();
});
}
}
function openCurrentResult() {
if (state.gameOver) {
showFinalModal();
} else if (state.roundEnded) {
showRoundModal();
}
}
function hideModal() {
els.modalBackdrop.hidden = true;
state.modalMode = null;
syncBodyLock();
}
/* ---------- Toast ---------- */
function updateToastText() {
if (!state.toastData) {
return;
}
els.toast.textContent = translate(
state.toastData.key,
state.toastData.variables
);
}
function showToast(key, variables = {}) {
window.clearTimeout(state.toastTimer);
state.toastData = {
key,
variables
};
updateToastText();
els.toast.classList.remove("show");
void els.toast.offsetWidth;
els.toast.classList.add("show");
state.toastTimer = window.setTimeout(() => {
els.toast.classList.remove("show");
state.toastData = null;
}, 2400);
}
function clearToast() {
window.clearTimeout(state.toastTimer);
state.toastData = null;
els.toast.classList.remove("show");
}
/* ---------- Events ---------- */
document.querySelectorAll("[data-set-lang]").forEach((button) => {
button.addEventListener("click", () => {
applyLanguage(button.dataset.setLang);
});
});
els.rulesButton.addEventListener(
"click",
openRules
);
els.startButton.addEventListener(
"click",
startGame
);
els.rulesCloseButton.addEventListener(
"click",
closeRules
);
els.rulesBackButton.addEventListener(
"click",
closeRules
);
els.rulesStartButton.addEventListener(
"click",
startGame
);
els.rulesOverlay.addEventListener("click", (event) => {
if (event.target === els.rulesOverlay) {
closeRules();
}
});
els.gameScreen.addEventListener("click", (event) => {
const frame = event.target.closest(
".piece-frame"
);
if (frame) {
handlePieceFrameClick(event, frame);
}
});
els.boardSvg.addEventListener(
"click",
handleBoardClick
);
els.boardSvg.addEventListener(
"pointermove",
handleBoardPointerMove
);
els.boardSvg.addEventListener("pointerleave", () => {
if (state.hoverCell) {
state.hoverCell = null;
renderPreview();
}
});
els.endPlayer1.addEventListener("click", () => {
endPlayerGame(1);
});
els.endPlayer2.addEventListener("click", () => {
endPlayerGame(2);
});
els.undoButton.addEventListener(
"click",
undoMove
);
els.redoButton.addEventListener(
"click",
redoMove
);
document.querySelectorAll("[data-placeholder-tool]").forEach((button) => {
button.addEventListener("click", () => {
showToast("comingSoon");
});
});
els.viewResultButton.addEventListener(
"click",
openCurrentResult
);
els.modalClose.addEventListener(
"click",
hideModal
);
els.modalBackdrop.addEventListener("click", (event) => {
if (event.target === els.modalBackdrop) {
hideModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (!els.modalBackdrop.hidden) {
hideModal();
return;
}
if (!els.rulesOverlay.hidden) {
closeRules();
return;
}
}
if (
!state.gameStarted ||
state.gameOver ||
!(event.ctrlKey || event.metaKey)
) {
return;
}
if (event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) {
redoMove();
} else {
undoMove();
}
} else if (event.key.toLowerCase() === "y") {
event.preventDefault();
redoMove();
}
});
window.addEventListener(
"resize",
updateResponsiveLayout
);
window.addEventListener(
"orientationchange",
updateResponsiveLayout
);
if (window.visualViewport) {
window.visualViewport.addEventListener(
"resize",
updateResponsiveLayout
);
}
/* ---------- Initialization ---------- */
buildBoardGrid();
updateResponsiveLayout();
applyLanguage("en");
syncBodyLock();