horiyouta commited on
Commit
350facf
·
0 Parent(s):

2603271026

Browse files
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Quad
3
+ emoji: 🌖
4
+ colorFrom: indigo
5
+ colorTo: pink
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ import uvicorn
4
+ from fastapi import FastAPI
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi.responses import FileResponse
7
+ from pydantic import BaseModel
8
+ from typing import List, Optional
9
+
10
+ app = FastAPI()
11
+
12
+ class GameState(BaseModel):
13
+ board: List[List[int]]
14
+ hand: List[Optional[List[List[int]]]]
15
+
16
+ def rotate_cw(piece):
17
+ return [[piece[1][0], piece[0][0]],
18
+ [piece[1][1], piece[0][1]]]
19
+
20
+ def can_place(board, piece, r, c):
21
+ for ir in range(2):
22
+ for ic in range(2):
23
+ br, bc = r + ir, c + ic
24
+ if br < 0 or br >= 8 or bc < 0 or bc >= 8:
25
+ return False
26
+ if board[br][bc] >= 0:
27
+ return False
28
+ return True
29
+
30
+ def get_connected_empty_spaces(board):
31
+ visited = [[False]*8 for _ in range(8)]
32
+ small_holes = 0
33
+ for r in range(8):
34
+ for c in range(8):
35
+ if board[r][c] == -1 and not visited[r][c]:
36
+ q = [(r, c)]
37
+ visited[r][c] = True
38
+ size = 1
39
+ while q:
40
+ cr, cc = q.pop(0)
41
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
42
+ nr, nc = cr + dr, cc + dc
43
+ if 0 <= nr < 8 and 0 <= nc < 8 and board[nr][nc] == -1 and not visited[nr][nc]:
44
+ visited[nr][nc] = True
45
+ q.append((nr, nc))
46
+ size += 1
47
+ # 1〜2マスの孤立した空間は、ブロックが置けなくなる原因になるためカウント
48
+ if size <= 2:
49
+ small_holes += 1
50
+ return small_holes
51
+
52
+ def get_color_adjacency(board):
53
+ adj = 0
54
+ for r in range(8):
55
+ for c in range(8):
56
+ color = board[r][c]
57
+ if 0 <= color <= 3:
58
+ for dr, dc in [(1, 0), (0, 1)]:
59
+ nr, nc = r + dr, c + dc
60
+ if nr < 8 and nc < 8 and board[nr][nc] == color:
61
+ adj += 1
62
+ return adj
63
+
64
+ def simulate_move(board, piece, r, c):
65
+ temp_board = [row[:] for row in board]
66
+ for ir in range(2):
67
+ for ic in range(2):
68
+ temp_board[r + ir][c + ic] = piece[ir][ic]
69
+
70
+ score = 0
71
+ combo = 0
72
+
73
+ # 連鎖シミュレーション
74
+ while True:
75
+ visited = [[False]*8 for _ in range(8)]
76
+ to_remove = set()
77
+
78
+ for br in range(8):
79
+ for bc in range(8):
80
+ color = temp_board[br][bc]
81
+ if 0 <= color <= 3 and not visited[br][bc]:
82
+ q = [(br, bc)]
83
+ visited[br][bc] = True
84
+ group = [(br, bc)]
85
+ while q:
86
+ cr, cc = q.pop(0)
87
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
88
+ nr, nc = cr + dr, cc + dc
89
+ if 0 <= nr < 8 and 0 <= nc < 8 and not visited[nr][nc] and temp_board[nr][nc] == color:
90
+ visited[nr][nc] = True
91
+ q.append((nr, nc))
92
+ group.append((nr, nc))
93
+ if len(group) >= 3:
94
+ for gr, gc in group:
95
+ to_remove.add((gr, gc))
96
+
97
+ if not to_remove:
98
+ break
99
+
100
+ combo += 1
101
+ garbage = set()
102
+ for rr, cc in to_remove:
103
+ for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
104
+ nr, nc = rr + dr, cc + dc
105
+ if 0 <= nr < 8 and 0 <= nc < 8 and temp_board[nr][nc] == 4:
106
+ garbage.add((nr, nc))
107
+ to_remove.update(garbage)
108
+
109
+ score += len(to_remove) * 10 * combo
110
+
111
+ for rr, cc in to_remove:
112
+ temp_board[rr][cc] = -1
113
+
114
+ # 盤面評価(AIの賢さの要)
115
+ max_height = 0
116
+ for bc in range(8):
117
+ for br in range(8):
118
+ if temp_board[br][bc] != -1:
119
+ max_height = max(max_height, 8 - br)
120
+ break
121
+
122
+ small_holes = get_connected_empty_spaces(temp_board)
123
+ adjacency = get_color_adjacency(temp_board)
124
+
125
+ # 評価値の計算
126
+ eval_score = score * 100 # スコアは最優先
127
+ eval_score -= (max_height ** 2) * 5 # 高く積むことに対するペナルティ(指数関数的)
128
+ eval_score -= small_holes * 30 # 孤立マスに対するペナルティ
129
+ eval_score += adjacency * 3 # 同色ブロックが隣接していることへのボーナス
130
+
131
+ return eval_score + random.random() * 0.1 # 同点時の微小なランダム性
132
+
133
+ @app.post("/api/ai_move")
134
+ def ai_move(state: GameState):
135
+ board = state.board
136
+ hand = state.hand
137
+
138
+ best_score = -float('inf')
139
+ best_move = None
140
+
141
+ for p_idx, original_piece in enumerate(hand):
142
+ if original_piece is None:
143
+ continue
144
+ piece = original_piece
145
+ for rot in range(4):
146
+ for r in range(7):
147
+ for c in range(7):
148
+ if can_place(board, piece, r, c):
149
+ score = simulate_move(board, piece, r, c)
150
+ if score > best_score:
151
+ best_score = score
152
+ best_move = {"piece_idx": p_idx, "rotations": rot, "r": r, "c": c}
153
+ piece = rotate_cw(piece)
154
+
155
+ if best_move is None:
156
+ best_move = {"piece_idx": 0, "rotations": 0, "r": 0, "c": 0}
157
+
158
+ return best_move
159
+
160
+ app.mount("/static", StaticFiles(directory="static"), name="static")
161
+
162
+ @app.get("/")
163
+ def serve_index():
164
+ return FileResponse("static/index.html")
165
+
166
+ if __name__ == "__main__":
167
+ uvicorn.run(app, host="0.0.0.0", port=7860)
model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:25da11e1209b46a986242b31f8a88f2502ab2a872548b31ed834c47c5ee89482
3
+ size 936893
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ pydantic
4
+ torch
5
+ numpy
static/bgm.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f6f77ae3c5115367e2533d132bff4efa7cf8b7c0f872b6ca5dbb23d6605c55b9
3
+ size 3582220
static/damage.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:32bba94263a7686b213ab1338546fddd5ce176f099dee8998b9f7bfb304979a0
3
+ size 18447
static/gameover.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f1aabbc3d0088993b224d5682d56c14c711033135a79af0a86f33854ae3079a9
3
+ size 21582
static/index.html ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Quad - VS AI Edition</title>
7
+ <link rel="stylesheet" href="static/style.css">
8
+ </head>
9
+ <body>
10
+
11
+ <!-- タイトル画面 -->
12
+ <div id="title-screen">
13
+ <div id="demo-container">
14
+ <div id="demo-area" class="game-area">
15
+ <div class="board-wrapper"><div class="board"></div></div>
16
+ <div class="hand-area"></div>
17
+ </div>
18
+ </div>
19
+
20
+ <div id="title-overlay"></div>
21
+
22
+ <div id="title-content">
23
+ <div id="title-logo">QUAD</div>
24
+ <div style="color:#aaa; margin-bottom: 40px; letter-spacing: 4px;">COLOR PUZZLE - AI BATTLE</div>
25
+ <button class="btn" id="btn-1p">1P MODE</button>
26
+ <button class="btn vs" id="btn-vs-real">VS AI (REALTIME)</button>
27
+ <button class="btn vs-turn" id="btn-vs-turn">VS AI (TURN)</button>
28
+ </div>
29
+ </div>
30
+
31
+ <button id="sound-toggle">🔇</button>
32
+
33
+ <!-- ゲーム画面 -->
34
+ <div id="main-wrapper">
35
+ <div id="turn-indicator">PLAYER'S TURN</div>
36
+
37
+ <div id="game-boards-container">
38
+ <div id="p1-area" class="game-area">
39
+ <div class="player-label">PLAYER</div>
40
+ <div style="font-size: 20px;">Score: <span class="score-display">0</span></div>
41
+ <div class="board-wrapper">
42
+ <div class="garbage-ui"><div class="garbage-bar"></div><div class="garbage-text"></div></div>
43
+ <div class="board"></div>
44
+ <div class="message-pop"></div>
45
+ </div>
46
+ <div class="hand-area"></div>
47
+ </div>
48
+
49
+ <div id="p2-area" class="game-area" style="display: none;">
50
+ <div class="player-label" style="color: #ff6b6b;">AI CPU</div>
51
+ <div style="font-size: 20px;">Score: <span class="score-display">0</span></div>
52
+ <div class="board-wrapper">
53
+ <div class="garbage-ui"><div class="garbage-bar"></div><div class="garbage-text"></div></div>
54
+ <div class="board"></div>
55
+ <div class="message-pop"></div>
56
+ </div>
57
+ <div class="hand-area"></div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- リザルト画面 -->
63
+ <div id="game-over-overlay">
64
+ <h2 id="result-title" style="font-size: 50px; margin-bottom: 20px;">GAME OVER</h2>
65
+ <button class="btn" onclick="location.reload()">BACK TO TITLE</button>
66
+ </div>
67
+
68
+ <script src="static/script.js"></script>
69
+ </body>
70
+ </html>
static/match.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:abafcdeb8e83cfca385a150b99090fbe0eba298209d21c2d7988ca782bb3c64c
3
+ size 29732
static/put.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9fd6f39a8fd80c73bfb53de6a24b5985e123081d2fe79fe7daa0cfa146573831
3
+ size 15939
static/rotate.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9349ee0e4b8365d48f1002e9a07be3f38a801c3f4e7bac8a6cb0f063bc3b985c
3
+ size 15939
static/script.js ADDED
@@ -0,0 +1,614 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ==== 音声管理 ====
2
+ let soundEnabled = false;
3
+ let titleBgm, bgm, sfxPut, sfxMatch, sfxGameover, sfxRotate, sfxWin, sfxDamage;
4
+ let isPlayingGame = false;
5
+
6
+ function createAudio(src) { const a = new Audio(src); a.preload = 'auto'; return a; }
7
+
8
+ function ensureAudioCreated() {
9
+ if (!bgm) {
10
+ titleBgm = createAudio('static/title.mp3'); titleBgm.loop = true;
11
+ bgm = createAudio('static/bgm.mp3'); bgm.loop = true;
12
+ sfxPut = createAudio('static/put.mp3');
13
+ sfxMatch = createAudio('static/match.mp3');
14
+ sfxGameover = createAudio('static/gameover.mp3');
15
+ sfxRotate = createAudio('static/rotate.mp3');
16
+ sfxWin = createAudio('static/win.mp3');
17
+ sfxDamage = createAudio('static/damage.mp3');
18
+ }
19
+ }
20
+
21
+ function playSfx(audio, vol = 1.0) {
22
+ if (!soundEnabled || !audio) return;
23
+ const c = audio.cloneNode(); c.volume = vol; c.play().catch(() => { });
24
+ }
25
+
26
+ function bgmControl(state) {
27
+ if (!soundEnabled || !titleBgm || !bgm) return;
28
+ titleBgm.pause(); bgm.pause();
29
+ if (state === 'title') { titleBgm.currentTime = 0; titleBgm.volume = 0.4; titleBgm.play().catch(() => { }); }
30
+ else if (state === 'game') { bgm.currentTime = 0; bgm.volume = 0.4; bgm.play().catch(() => { }); }
31
+ }
32
+
33
+ document.getElementById('sound-toggle').addEventListener('click', () => {
34
+ ensureAudioCreated();
35
+ soundEnabled = !soundEnabled;
36
+ document.getElementById('sound-toggle').textContent = soundEnabled ? '🔊' : '🔇';
37
+ if (soundEnabled) {
38
+ if (isPlayingGame && (!p1 || !p1.isGameOver)) bgmControl('game');
39
+ else if (!isPlayingGame) bgmControl('title');
40
+ } else {
41
+ bgmControl('stop');
42
+ }
43
+ });
44
+
45
+ // ==== ゲーム設定 ====
46
+ const COLS = 8, ROWS = 8, COLORS = 4, MIN_MATCH = 3;
47
+ const tileImages = ['static/tile1.png', 'static/tile2.png', 'static/tile3.png', 'static/tile4.png', 'static/tile5.png'];
48
+ const tileImg = (i) => { const img = document.createElement('img'); img.src = tileImages[i]; img.draggable = false; return img; };
49
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
50
+
51
+ let p1, p2;
52
+ let gameMode = '1p'; // '1p', 'vs-real', 'vs-turn'
53
+ let currentTurn = 1; // 1: Player, 2: AI (ターン制用)
54
+ let isDemoActive = true;
55
+
56
+ class QuadGame {
57
+ constructor(rootEl, isAI, isDemo = false) {
58
+ this.root = rootEl; this.isAI = isAI; this.isDemo = isDemo;
59
+ this.boardEl = rootEl.querySelector('.board');
60
+ this.handEl = rootEl.querySelector('.hand-area');
61
+ this.scoreEl = rootEl.querySelector('.score-display');
62
+ this.msgEl = rootEl.querySelector('.message-pop');
63
+ this.garbageUi = rootEl.querySelector('.garbage-ui');
64
+ this.garbageBar = rootEl.querySelector('.garbage-bar');
65
+ this.garbageText = rootEl.querySelector('.garbage-text');
66
+
67
+ this.board = []; this.hand = [];
68
+ this.score = 0; this.combo = 0;
69
+ this.isProcessing = false; this.isGameOver = false; this.aiFetching = false;
70
+ this.opponent = null;
71
+
72
+ this.pendingGarbage = 0;
73
+ this.garbageTimerMax = 6000;
74
+ this.garbageTimer = 0;
75
+
76
+ this.dragging = null; this.dragEl = null; this.ghostCells = [];
77
+ }
78
+
79
+ init() {
80
+ this.board = Array.from({ length: ROWS }, () => Array(COLS).fill(-1));
81
+ this.hand = [this.genPiece(), this.genPiece(), this.genPiece()];
82
+ this.score = 0; this.combo = 0; this.pendingGarbage = 0;
83
+ this.isGameOver = false; this.isProcessing = false;
84
+
85
+ this.boardEl.innerHTML = '';
86
+ for (let i = 0; i < 64; i++) {
87
+ const c = document.createElement('div'); c.className = 'cell';
88
+ this.boardEl.appendChild(c);
89
+ }
90
+ this.updateHandUI();
91
+ if (this.scoreEl) this.scoreEl.textContent = '0';
92
+ if (this.garbageUi) this.garbageUi.style.display = 'none';
93
+ }
94
+
95
+ genPiece() {
96
+ let p;
97
+ while (true) {
98
+ p = [[Math.floor(Math.random() * COLORS), Math.floor(Math.random() * COLORS)],
99
+ [Math.floor(Math.random() * COLORS), Math.floor(Math.random() * COLORS)]];
100
+ if (!this.hasMatchSync(p)) return p;
101
+ }
102
+ }
103
+
104
+ hasMatchSync(p) {
105
+ for (let col = 0; col < COLORS; col++) {
106
+ let count = 0;
107
+ for (let r = 0; r < 2; r++) for (let c = 0; c < 2; c++) if (p[r][c] === col) count++;
108
+ if (count >= 3) return true;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ getCellEl(r, c) { return this.boardEl.children[r * COLS + c]; }
114
+
115
+ renderBoard() {
116
+ for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
117
+ const cell = this.getCellEl(r, c);
118
+ const ex = cell.querySelector('.block'); if (ex) ex.remove();
119
+ if (this.board[r][c] >= 0) {
120
+ const blk = document.createElement('div'); blk.className = 'block';
121
+ blk.appendChild(tileImg(this.board[r][c]));
122
+ cell.appendChild(blk);
123
+ }
124
+ }
125
+ }
126
+
127
+ updateHandUI() {
128
+ this.handEl.innerHTML = '';
129
+ this.hand.forEach((p, idx) => {
130
+ const el = document.createElement('div');
131
+ el.className = 'hand-piece' + (!this.isAI ? ' interactive' : '');
132
+ if (!p) { el.style.visibility = 'hidden'; this.handEl.appendChild(el); return; }
133
+
134
+ [[0, 0], [0, 1], [1, 0], [1, 1]].forEach(([r, c]) => {
135
+ const w = document.createElement('div'); w.className = 'hand-cell-wrapper';
136
+ const cl = document.createElement('div'); cl.className = 'hand-cell';
137
+ cl.style.left = '0'; cl.style.top = '0';
138
+ cl.appendChild(tileImg(p[r][c])); w.appendChild(cl); el.appendChild(w);
139
+ });
140
+
141
+ if (!this.isAI) {
142
+ el.addEventListener('mousedown', e => this.onHandDown(e, idx));
143
+ el.addEventListener('touchstart', e => this.onHandDown(e, idx), { passive: false });
144
+ }
145
+ this.handEl.appendChild(el);
146
+ });
147
+ this.checkValidMoves();
148
+ }
149
+
150
+ rotateCW(idx) {
151
+ const p = this.hand[idx];
152
+ this.hand[idx] = [[p[1][0], p[0][0]], [p[1][1], p[0][1]]];
153
+ }
154
+
155
+ async animateRotation(idx) {
156
+ if (!this.isDemo) playSfx(sfxRotate, 0.4);
157
+ this.rotateCW(idx);
158
+ this.updateHandUI();
159
+ }
160
+
161
+ canPlace(piece, sr, sc) {
162
+ if (!piece) return false;
163
+ for (let r = 0; r < 2; r++) for (let c = 0; c < 2; c++) {
164
+ const br = sr + r, bc = sc + c;
165
+ if (br < 0 || br >= ROWS || bc < 0 || bc >= COLS || this.board[br][bc] >= 0) return false;
166
+ }
167
+ return true;
168
+ }
169
+
170
+ checkValidMoves() {
171
+ if (this.isGameOver) return;
172
+ let any = false;
173
+ const els = this.handEl.querySelectorAll('.hand-piece');
174
+
175
+ // ターン制での自分以外のターンの場合はすべて無効化
176
+ const isMyTurn = (gameMode !== 'vs-turn') || (this.isAI ? currentTurn === 2 : currentTurn === 1);
177
+
178
+ this.hand.forEach((p, i) => {
179
+ if (!p) return;
180
+ let ok = false;
181
+ if (isMyTurn) {
182
+ for (let r = 0; r < 7; r++) for (let c = 0; c < 7; c++) if (this.canPlace(p, r, c)) ok = true;
183
+ }
184
+ if (ok) any = true;
185
+ if (els[i]) els[i].classList.toggle('disabled', !ok);
186
+ });
187
+
188
+ // 置ける手がない(本当に盤面が埋まっているか手札がない)場合のゲームオーバー判定
189
+ let trueAny = false;
190
+ this.hand.forEach(p => {
191
+ if (!p) return;
192
+ for (let r = 0; r < 7; r++) for (let c = 0; c < 7; c++) if (this.canPlace(p, r, c)) trueAny = true;
193
+ });
194
+
195
+ if (!trueAny && this.hand.some(p => p !== null) && !this.isProcessing) {
196
+ this.triggerGameOver();
197
+ }
198
+ }
199
+
200
+ onHandDown(e, idx) {
201
+ if (this.isProcessing || this.isGameOver || this.isDemo) return;
202
+ if (gameMode === 'vs-turn' && currentTurn !== 1) return; // 自分のターン以外操作不可
203
+
204
+ const t = e.touches ? e.touches[0] : e;
205
+ let startX = t.clientX, startY = t.clientY, moved = false;
206
+
207
+ const onMove = ev => {
208
+ const tt = ev.touches ? ev.touches[0] : ev;
209
+ if (!moved && Math.hypot(tt.clientX - startX, tt.clientY - startY) > 8) {
210
+ moved = true;
211
+ this.boardRect = this.boardEl.getBoundingClientRect();
212
+ this.cellSize = this.boardRect.width / COLS;
213
+ this.beginDrag(idx, tt.clientX, tt.clientY);
214
+ }
215
+ if (moved) this.moveDrag(tt.clientX, tt.clientY);
216
+ };
217
+
218
+ const onUp = ev => {
219
+ document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp);
220
+ document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp);
221
+ if (!moved) { this.animateRotation(idx); }
222
+ else { const tt = ev.changedTouches ? ev.changedTouches[0] : ev; this.finishDrag(tt.clientX, tt.clientY); }
223
+ };
224
+
225
+ document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
226
+ document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onUp);
227
+ }
228
+
229
+ beginDrag(idx, x, y) {
230
+ this.dragging = { idx, piece: this.hand[idx] };
231
+ this.dragEl = document.createElement('div'); this.dragEl.className = 'dragging-piece';
232
+ for (let r = 0; r < 2; r++) for (let c = 0; c < 2; c++) {
233
+ const cl = document.createElement('div'); cl.className = 'drag-cell'; cl.appendChild(tileImg(this.hand[idx][r][c])); this.dragEl.appendChild(cl);
234
+ }
235
+ document.body.appendChild(this.dragEl);
236
+ this.handEl.children[idx].style.opacity = '0.3';
237
+ this.moveDrag(x, y);
238
+ }
239
+
240
+ moveDrag(x, y) {
241
+ if (!this.dragEl) return;
242
+ this.dragEl.style.left = (x - 45) + 'px'; this.dragEl.style.top = (y - 45) + 'px';
243
+ const relX = x - this.boardRect.left, relY = y - this.boardRect.top;
244
+ const c = Math.round(relX / this.cellSize - 1), r = Math.round(relY / this.cellSize - 1);
245
+
246
+ this.ghostCells.forEach(el => { el.classList.remove('highlight'); const g = el.querySelector('.ghost'); if (g) g.remove(); });
247
+ this.ghostCells = [];
248
+ if (this.canPlace(this.dragging.piece, r, c)) {
249
+ for (let ir = 0; ir < 2; ir++) for (let ic = 0; ic < 2; ic++) {
250
+ const el = this.getCellEl(r + ir, c + ic); el.classList.add('highlight');
251
+ const g = document.createElement('div'); g.className = 'ghost'; g.appendChild(tileImg(this.dragging.piece[ir][ic]));
252
+ el.appendChild(g); this.ghostCells.push(el);
253
+ }
254
+ }
255
+ }
256
+
257
+ finishDrag(x, y) {
258
+ const relX = x - this.boardRect.left, relY = y - this.boardRect.top;
259
+ const c = Math.round(relX / this.cellSize - 1), r = Math.round(relY / this.cellSize - 1);
260
+ if (this.canPlace(this.dragging.piece, r, c)) {
261
+ this.placePiece(this.dragging.idx, r, c);
262
+ } else {
263
+ this.handEl.children[this.dragging.idx].style.opacity = '1';
264
+ }
265
+ this.ghostCells.forEach(el => { el.classList.remove('highlight'); const g = el.querySelector('.ghost'); if (g) g.remove(); });
266
+ this.ghostCells = [];
267
+ if (this.dragEl) { this.dragEl.remove(); this.dragEl = null; }
268
+ this.dragging = null;
269
+ }
270
+
271
+ placePiece(idx, r, c) {
272
+ if (!this.isDemo) playSfx(sfxPut, 0.6);
273
+ const p = this.hand[idx];
274
+ for (let ir = 0; ir < 2; ir++) for (let ic = 0; ic < 2; ic++) this.board[r + ir][c + ic] = p[ir][ic];
275
+ this.hand[idx] = this.genPiece();
276
+ this.combo = 0; this.isProcessing = true;
277
+ this.renderBoard(); this.updateHandUI();
278
+ setTimeout(() => this.processMatches(), 150);
279
+ }
280
+
281
+ processMatches() {
282
+ const vis = Array.from({ length: ROWS }, () => Array(COLS).fill(false));
283
+ const toRemove = new Set();
284
+
285
+ for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
286
+ if (this.board[r][c] >= 0 && this.board[r][c] <= 3 && !vis[r][c]) {
287
+ const color = this.board[r][c], grp = [], q = [[r, c]]; vis[r][c] = true;
288
+ while (q.length > 0) {
289
+ const [cr, cc] = q.shift(); grp.push([cr, cc]);
290
+ [[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
291
+ const nr = cr + dr, nc = cc + dc;
292
+ if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && !vis[nr][nc] && this.board[nr][nc] === color) {
293
+ vis[nr][nc] = true; q.push([nr, nc]);
294
+ }
295
+ });
296
+ }
297
+ if (grp.length >= MIN_MATCH) grp.forEach(([gr, gc]) => toRemove.add(`${gr},${gc}`));
298
+ }
299
+ }
300
+
301
+ if (toRemove.size > 0) {
302
+ if (!this.isDemo) playSfx(sfxMatch, 0.7);
303
+ this.combo++;
304
+ const garbageSet = new Set();
305
+ toRemove.forEach(k => {
306
+ const [r, c] = k.split(',').map(Number);
307
+ [[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
308
+ const nr = r + dr, nc = c + dc;
309
+ if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && this.board[nr][nc] === 4) garbageSet.add(`${nr},${nc}`);
310
+ });
311
+ });
312
+ garbageSet.forEach(k => toRemove.add(k));
313
+
314
+ const count = toRemove.size;
315
+ this.score += count * 10 * this.combo;
316
+ if (this.scoreEl) this.scoreEl.textContent = this.score;
317
+ this.showPop(`${count} BLOCKS!` + (this.combo > 1 ? `<br>${this.combo} COMBO!` : ''));
318
+
319
+ if (this.opponent && (this.combo >= 2 || count >= 4)) {
320
+ const atk = Math.max(1, count - 3) + (this.combo - 1) * 2;
321
+ this.sendGarbage(atk);
322
+ }
323
+
324
+ toRemove.forEach(k => {
325
+ const [r, c] = k.split(',').map(Number);
326
+ const el = this.getCellEl(r, c).querySelector('.block');
327
+ if (el) el.classList.add('clearing');
328
+ });
329
+
330
+ setTimeout(() => {
331
+ toRemove.forEach(k => { const [r, c] = k.split(',').map(Number); this.board[r][c] = -1; });
332
+ this.renderBoard();
333
+ setTimeout(() => this.processMatches(), 200);
334
+ }, 400);
335
+
336
+ } else {
337
+ this.isProcessing = false;
338
+ this.checkValidMoves();
339
+
340
+ // 処理終了後のアクション
341
+ if (gameMode === 'vs-turn' && !this.isGameOver) {
342
+ // ターン切り替え
343
+ switchTurn();
344
+ } else if (this.isAI && !this.isGameOver && gameMode !== 'vs-turn') {
345
+ // リアルタイムAIの連続操作
346
+ setTimeout(() => requestAI(this), 300);
347
+ }
348
+ }
349
+ }
350
+
351
+ showPop(txt) {
352
+ if (!this.msgEl) return;
353
+ this.msgEl.innerHTML = txt;
354
+ this.msgEl.style.transition = 'none'; this.msgEl.style.opacity = '1'; this.msgEl.style.transform = 'translate(-50%,-50%) scale(0.5)';
355
+ requestAnimationFrame(() => {
356
+ this.msgEl.style.transition = 'transform 0.3s cubic-bezier(0.175,0.885,0.32,1.275), opacity 0.5s';
357
+ this.msgEl.style.transform = 'translate(-50%,-50%) scale(1)';
358
+ setTimeout(() => this.msgEl.style.opacity = '0', 800);
359
+ });
360
+ }
361
+
362
+ sendGarbage(amount) {
363
+ if (this.pendingGarbage > 0) {
364
+ if (this.pendingGarbage >= amount) { this.pendingGarbage -= amount; amount = 0; }
365
+ else { amount -= this.pendingGarbage; this.pendingGarbage = 0; }
366
+ this.garbageTimer = this.garbageTimerMax;
367
+ this.updateGarbageUI();
368
+ }
369
+
370
+ if (amount > 0 && this.opponent) {
371
+ if (gameMode === 'vs-turn') {
372
+ // ターン制の場合は即時落下させるために相手側に保留させずそのまま落とす
373
+ this.opponent.receiveGarbageInstant(amount);
374
+ } else {
375
+ this.opponent.receiveGarbage(amount);
376
+ }
377
+ }
378
+ }
379
+
380
+ receiveGarbage(amount) {
381
+ this.pendingGarbage += amount;
382
+ this.garbageTimer = this.garbageTimerMax;
383
+ this.updateGarbageUI();
384
+ }
385
+
386
+ receiveGarbageInstant(amount) {
387
+ this.pendingGarbage += amount;
388
+ this.dropGarbage();
389
+ }
390
+
391
+ updateGarbageUI() {
392
+ if (!this.garbageUi) return;
393
+ if (gameMode === 'vs-turn') { this.garbageUi.style.display = 'none'; return; } // ターン制はUI不要
394
+
395
+ if (this.pendingGarbage > 0) {
396
+ this.garbageUi.style.display = 'block';
397
+ this.garbageText.textContent = `+${this.pendingGarbage}`;
398
+ this.garbageBar.style.transform = `scaleX(${Math.max(0, this.garbageTimer / this.garbageTimerMax)})`;
399
+ } else {
400
+ this.garbageUi.style.display = 'none';
401
+ }
402
+ }
403
+
404
+ tick(dt) {
405
+ if (this.isGameOver || this.isProcessing || gameMode === 'vs-turn') return;
406
+ if (this.pendingGarbage > 0) {
407
+ this.garbageTimer -= dt;
408
+ this.updateGarbageUI();
409
+ if (this.garbageTimer <= 0) {
410
+ this.dropGarbage();
411
+ }
412
+ }
413
+ }
414
+
415
+ dropGarbage() {
416
+ if (!this.isDemo) playSfx(sfxDamage, 0.8);
417
+ let empty = [];
418
+ for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) if (this.board[r][c] === -1) empty.push({ r, c });
419
+ for (let i = empty.length - 1; i > 0; i--) {
420
+ const j = Math.floor(Math.random() * (i + 1));
421
+ [empty[i], empty[j]] = [empty[j], empty[i]];
422
+ }
423
+ const drop = Math.min(this.pendingGarbage, empty.length);
424
+ for (let i = 0; i < drop; i++) this.board[empty[i].r][empty[i].c] = 4;
425
+
426
+ this.pendingGarbage -= drop;
427
+ if (this.pendingGarbage < 0) this.pendingGarbage = 0;
428
+ this.updateGarbageUI();
429
+ this.renderBoard();
430
+ this.checkValidMoves();
431
+ }
432
+
433
+ triggerGameOver() {
434
+ this.isGameOver = true;
435
+ if (this.isDemo) {
436
+ setTimeout(() => {
437
+ if (!isPlayingGame && isDemoActive) {
438
+ this.init();
439
+ requestAI(this);
440
+ }
441
+ }, 2000);
442
+ return;
443
+ }
444
+ checkGlobalGameOver();
445
+ }
446
+ }
447
+
448
+ // ==== グローバル制御 ====
449
+ let lastTime = 0;
450
+ let demoGame = null;
451
+
452
+ window.onload = () => {
453
+ demoGame = new QuadGame(document.getElementById('demo-area'), true, true);
454
+ demoGame.init();
455
+ requestAI(demoGame); // デモ用AI起動
456
+ };
457
+
458
+ document.getElementById('btn-1p').onclick = () => startGame('1p');
459
+ document.getElementById('btn-vs-real').onclick = () => startGame('vs-real');
460
+ document.getElementById('btn-vs-turn').onclick = () => startGame('vs-turn');
461
+
462
+ function startGame(mode) {
463
+ isDemoActive = false;
464
+ isPlayingGame = true;
465
+ gameMode = mode;
466
+ currentTurn = 1;
467
+
468
+ ensureAudioCreated();
469
+ bgmControl('game');
470
+
471
+ document.getElementById('title-screen').classList.add('hidden');
472
+ document.getElementById('main-wrapper').classList.add('show');
473
+
474
+ p1 = new QuadGame(document.getElementById('p1-area'), false);
475
+ p1.init();
476
+
477
+ const indicator = document.getElementById('turn-indicator');
478
+
479
+ if (mode === 'vs-real' || mode === 'vs-turn') {
480
+ document.getElementById('p2-area').style.display = 'flex';
481
+ p2 = new QuadGame(document.getElementById('p2-area'), true);
482
+ p2.init();
483
+ p1.opponent = p2; p2.opponent = p1;
484
+
485
+ if (mode === 'vs-turn') {
486
+ indicator.style.display = 'block';
487
+ updateTurnUI();
488
+ } else {
489
+ indicator.style.display = 'none';
490
+ setTimeout(() => requestAI(p2), 1000);
491
+ }
492
+ } else {
493
+ document.getElementById('p2-area').style.display = 'none';
494
+ indicator.style.display = 'none';
495
+ }
496
+
497
+ lastTime = performance.now();
498
+ requestAnimationFrame(gameLoop);
499
+ }
500
+
501
+ function gameLoop(time) {
502
+ const dt = time - lastTime; lastTime = time;
503
+ if (p1 && !p1.isGameOver) p1.tick(dt);
504
+ if (p2 && !p2.isGameOver) p2.tick(dt);
505
+ requestAnimationFrame(gameLoop);
506
+ }
507
+
508
+ function switchTurn() {
509
+ if (p1.isGameOver || p2.isGameOver) return;
510
+ currentTurn = currentTurn === 1 ? 2 : 1;
511
+ updateTurnUI();
512
+
513
+ // それぞれの手札の活性/非活性を更新
514
+ p1.checkValidMoves();
515
+ p2.checkValidMoves();
516
+
517
+ if (currentTurn === 2) {
518
+ setTimeout(() => requestAI(p2), 800); // ターン交代のゆとり
519
+ }
520
+ }
521
+
522
+ function updateTurnUI() {
523
+ const ind = document.getElementById('turn-indicator');
524
+ const p1a = document.getElementById('p1-area');
525
+ const p2a = document.getElementById('p2-area');
526
+
527
+ if (currentTurn === 1) {
528
+ ind.textContent = "PLAYER'S TURN";
529
+ ind.style.color = "#4d96ff";
530
+ p1a.classList.remove('waiting');
531
+ p2a.classList.add('waiting');
532
+ } else {
533
+ ind.textContent = "AI'S TURN";
534
+ ind.style.color = "#ff6b6b";
535
+ p1a.classList.add('waiting');
536
+ p2a.classList.remove('waiting');
537
+ }
538
+ }
539
+
540
+ function checkGlobalGameOver() {
541
+ if (gameMode !== '1p') {
542
+ if (p1.isGameOver || p2.isGameOver) {
543
+ bgmControl('stop');
544
+ if (p1.isGameOver) {
545
+ document.getElementById('result-title').textContent = "YOU LOSE...";
546
+ document.getElementById('result-title').style.color = "#ff6b6b";
547
+ playSfx(sfxGameover, 0.8);
548
+ } else {
549
+ document.getElementById('result-title').textContent = "YOU WIN!!";
550
+ document.getElementById('result-title').style.color = "#6bcb77";
551
+ playSfx(sfxWin, 0.8);
552
+ }
553
+ document.getElementById('game-over-overlay').classList.add('show');
554
+ }
555
+ } else {
556
+ if (p1.isGameOver) {
557
+ bgmControl('stop');
558
+ playSfx(sfxGameover, 0.8);
559
+ document.getElementById('game-over-overlay').classList.add('show');
560
+ }
561
+ }
562
+ }
563
+
564
+ async function requestAI(gameObj) {
565
+ if (gameObj.isGameOver || gameObj.isProcessing || gameObj.aiFetching) return;
566
+ if (!gameObj.hand.some(p => p !== null)) return;
567
+ if (gameObj.isDemo && !isDemoActive) return;
568
+
569
+ // 難易度(温度)設定
570
+ // 0: デモ用(最強)、 25: ターン制(少し手加減)、 35: リアルタイム(隙を見せる)
571
+ let temp = 0.0;
572
+ if (!gameObj.isDemo) {
573
+ temp = gameMode === 'vs-turn' ? 20.0 : 35.0;
574
+ }
575
+
576
+ gameObj.aiFetching = true;
577
+ try {
578
+ const res = await fetch('/api/ai_move', {
579
+ method: 'POST',
580
+ headers: { 'Content-Type': 'application/json' },
581
+ body: JSON.stringify({
582
+ board: gameObj.board,
583
+ hand: gameObj.hand,
584
+ temperature: temp
585
+ })
586
+ });
587
+ const move = await res.json();
588
+ gameObj.aiFetching = false;
589
+
590
+ if (gameObj.isDemo && !isDemoActive) return;
591
+
592
+ if (move && move.piece_idx !== undefined && !gameObj.isGameOver && !gameObj.isProcessing) {
593
+
594
+ // 人間らしい遅延の設定
595
+ // リアルタイム対戦の場合、思考にわざと時間をかける
596
+ let thinkTime = gameObj.isDemo ? 300 : (gameMode === 'vs-real' ? 1200 + Math.random() * 800 : 600);
597
+ await sleep(thinkTime);
598
+
599
+ if (gameObj.isGameOver || gameObj.isProcessing || (gameObj.isDemo && !isDemoActive)) return;
600
+
601
+ for (let i = 0; i < move.rotations; i++) {
602
+ gameObj.animateRotation(move.piece_idx);
603
+ await sleep(gameObj.isDemo ? 100 : 250); // 回転のディレイ
604
+ }
605
+
606
+ if (gameObj.canPlace(gameObj.hand[move.piece_idx], move.r, move.c)) {
607
+ gameObj.placePiece(move.piece_idx, move.r, move.c);
608
+ }
609
+ }
610
+ } catch (e) {
611
+ gameObj.aiFetching = false;
612
+ setTimeout(() => { if (!gameObj.isGameOver && !gameObj.isProcessing && (!gameObj.isDemo || isDemoActive)) requestAI(gameObj); }, 2000);
613
+ }
614
+ }
static/style.css ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2
+ body {
3
+ background: #0a0a1a; font-family: 'Segoe UI', sans-serif;
4
+ display: flex; justify-content: center; align-items: center;
5
+ min-height: 100vh; overflow: hidden; user-select: none; color: #fff;
6
+ }
7
+
8
+ #title-screen {
9
+ position: fixed; inset: 0; background: #0a0a1a;
10
+ display: flex; justify-content: center; align-items: center;
11
+ z-index: 500; transition: opacity 0.6s; overflow: hidden;
12
+ }
13
+ #title-screen.hidden { opacity: 0; pointer-events: none; }
14
+
15
+ #demo-container {
16
+ position: absolute; transform: scale(1.3); opacity: 0.4;
17
+ pointer-events: none; z-index: 1; filter: blur(0.5px);
18
+ }
19
+ #title-overlay {
20
+ position: absolute; inset: 0; background: rgba(10, 10, 26, 0.1); z-index: 2;
21
+ }
22
+ #title-content {
23
+ position: relative; z-index: 3; display: flex; flex-direction: column; align-items: center;
24
+ }
25
+
26
+ #title-logo {
27
+ font-size: 80px; font-weight: 900;
28
+ background: linear-gradient(135deg, #ff6b6b, #ffd93d, #6bcb77, #4d96ff);
29
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
30
+ letter-spacing: 12px; margin-bottom: 16px; animation: titlePulse 3s ease-in-out infinite;
31
+ }
32
+ @keyframes titlePulse { 50% { transform: scale(1.04); filter: brightness(1.2); } }
33
+
34
+ .btn {
35
+ padding: 16px 40px; margin: 10px; font-size: 18px; width: 260px;
36
+ background: linear-gradient(135deg, #4d96ff, #6bcb77);
37
+ color: #fff; border: none; border-radius: 30px; cursor: pointer;
38
+ font-weight: 700; letter-spacing: 2px; transition: transform 0.2s, box-shadow 0.2s;
39
+ }
40
+ .btn:hover { transform: scale(1.06); box-shadow: 0 0 30px rgba(77, 150, 255, 0.5); }
41
+ .btn.vs { background: linear-gradient(135deg, #ff6b6b, #ffd93d); }
42
+ .btn.vs:hover { box-shadow: 0 0 30px rgba(255, 107, 107, 0.5); }
43
+ .btn.vs-turn { background: linear-gradient(135deg, #9b59b6, #e74c3c); }
44
+ .btn.vs-turn:hover { box-shadow: 0 0 30px rgba(155, 89, 182, 0.5); }
45
+
46
+ #sound-toggle {
47
+ position: fixed; top: 16px; right: 16px; width: 44px; height: 44px;
48
+ border-radius: 50%; background: rgba(255, 255, 255, 0.1);
49
+ border: 1px solid rgba(255, 255, 255, 0.2); color: #fff; font-size: 20px;
50
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
51
+ z-index: 600; transition: background 0.2s;
52
+ }
53
+ #sound-toggle:hover { background: rgba(255, 255, 255, 0.2); }
54
+
55
+ #main-wrapper {
56
+ display: flex; flex-direction: column; align-items: center;
57
+ opacity: 0; transition: opacity 0.5s; position: relative; z-index: 100; gap: 20px;
58
+ }
59
+ #main-wrapper.show { opacity: 1; }
60
+
61
+ #turn-indicator {
62
+ font-size: 28px; font-weight: bold; letter-spacing: 4px;
63
+ background: rgba(255,255,255,0.1); padding: 8px 30px; border-radius: 30px;
64
+ display: none; transition: 0.3s;
65
+ }
66
+
67
+ #game-boards-container { display: flex; gap: 40px; flex-wrap: wrap; justify-content: center; }
68
+ .game-area { display: flex; flex-direction: column; align-items: center; gap: 15px; position: relative; transition: opacity 0.3s; }
69
+ .game-area.waiting { opacity: 0.5; pointer-events: none; }
70
+
71
+ .player-label { font-size: 24px; font-weight: bold; color: #aaa; letter-spacing: 2px; }
72
+
73
+ .board-wrapper {
74
+ position: relative; padding: 8px; border-radius: 16px;
75
+ background: linear-gradient(135deg, #1a1a2e, #16213e);
76
+ box-shadow: 0 0 40px rgba(77, 150, 255, 0.1), inset 0 0 20px rgba(0, 0, 0, 0.3);
77
+ border: 1px solid rgba(77, 150, 255, 0.15);
78
+ }
79
+ .board { display: grid; grid-template-columns: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr); gap: 2px; width: 360px; height: 360px; }
80
+
81
+ .cell {
82
+ width: 100%; height: 100%; border-radius: 6px; position: relative;
83
+ background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.04);
84
+ }
85
+ .cell.highlight { background: rgba(255, 255, 255, 0.08); }
86
+ .block, .ghost { position: absolute; inset: 0; border-radius: 6px; overflow: hidden; }
87
+ .block img, .ghost img { width: 100%; height: 100%; display: block; }
88
+ .ghost { opacity: 0.35; pointer-events: none; }
89
+ .clearing { animation: clearAnim 0.4s ease-out forwards; }
90
+ @keyframes clearAnim { 50% { transform: scale(1.3); opacity: 0.8; } 100% { transform: scale(0); opacity: 0; } }
91
+
92
+ .hand-area {
93
+ display: flex; gap: 16px; padding: 12px; min-height: 110px;
94
+ background: linear-gradient(135deg, #1a1a2e, #16213e); border-radius: 16px;
95
+ }
96
+ .hand-piece {
97
+ display: grid; grid-template-columns: repeat(2, 40px); grid-template-rows: repeat(2, 40px); gap: 3px;
98
+ padding: 6px; border-radius: 12px; cursor: grab; transition: transform 0.2s; position: relative;
99
+ }
100
+ .hand-piece.interactive:hover { transform: scale(1.08); background: rgba(255,255,255,0.05); }
101
+ .hand-piece.disabled { opacity: 0.25; cursor: not-allowed; pointer-events: none; filter: grayscale(1); }
102
+ .hand-cell-wrapper { position: relative; width: 40px; height: 40px; }
103
+ .hand-cell { position: absolute; width: 100%; height: 100%; border-radius: 6px; overflow: hidden; transition: 0.1s ease-in-out; }
104
+ .hand-cell img { width: 100%; height: 100%; display: block; }
105
+
106
+ .dragging-piece {
107
+ position: fixed; display: grid; grid-template-columns: repeat(2, 44px); grid-template-rows: repeat(2, 44px); gap: 3px;
108
+ pointer-events: none; z-index: 1000; filter: drop-shadow(0 4px 12px rgba(0,0,0,0.5));
109
+ }
110
+ .dragging-piece .drag-cell { border-radius: 6px; overflow: hidden; width: 100%; height: 100%; }
111
+ .dragging-piece img { width: 100%; height: 100%; }
112
+
113
+ .garbage-ui {
114
+ position: absolute; top: -30px; left: 0; right: 0;
115
+ height: 20px; background: rgba(255,0,0,0.2); border-radius: 10px; overflow: hidden; display: none;
116
+ }
117
+ .garbage-bar { height: 100%; background: #ff6b6b; width: 100%; transform-origin: left; transition: transform 0.1s linear; }
118
+ .garbage-text {
119
+ position: absolute; top: 0; left: 50%; transform: translateX(-50%);
120
+ font-size: 14px; font-weight: bold; color: #fff; line-height: 20px;
121
+ }
122
+
123
+ .message-pop {
124
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
125
+ font-size: 32px; font-weight: 900; color: #ffd93d; text-shadow: 0 0 20px rgba(255, 217, 61, 0.8);
126
+ opacity: 0; pointer-events: none; z-index: 100; text-align: center; white-space: nowrap;
127
+ }
128
+
129
+ #game-over-overlay {
130
+ position: fixed; inset: 0; background: rgba(0,0,0,0.85);
131
+ display: flex; flex-direction: column; justify-content: center; align-items: center;
132
+ z-index: 200; opacity: 0; pointer-events: none; transition: 0.5s;
133
+ }
134
+ #game-over-overlay.show { opacity: 1; pointer-events: all; }
static/tile1.png ADDED
static/tile2.png ADDED
static/tile3.png ADDED
static/tile4.png ADDED
static/tile5.png ADDED
static/title.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2888ea31b158ed7860def368a15524b02e0c0f1903a894b2ace75bda9822e50a
3
+ size 5182442
static/win.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b9fc0032a7a664b325a6c905b307357a298038fc528aa9eaaefb1b0a9e745862
3
+ size 30986
train.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.optim as optim
4
+ import random
5
+ import numpy as np
6
+ from collections import deque
7
+ import copy
8
+
9
+ class QuadEnv:
10
+ def __init__(self):
11
+ self.reset()
12
+
13
+ def reset(self):
14
+ self.board = np.full((8, 8), -1, dtype=int)
15
+ self.hand = [self.generate_piece() for _ in range(3)]
16
+ return self.get_state()
17
+
18
+ def generate_piece(self):
19
+ while True:
20
+ p = np.random.randint(0, 4, (2, 2)).tolist()
21
+ counts = [0] * 4
22
+ for r in range(2):
23
+ for c in range(2):
24
+ counts[p[r][c]] += 1
25
+ if max(counts) < 3:
26
+ return p
27
+
28
+ def get_state(self):
29
+ return torch.FloatTensor(self.board.flatten()).unsqueeze(0)
30
+
31
+ def can_place(self, piece, r, c):
32
+ for ir in range(2):
33
+ for ic in range(2):
34
+ if r+ir >= 8 or c+ic >= 8 or self.board[r+ir][c+ic] != -1:
35
+ return False
36
+ return True
37
+
38
+ def step(self, action_idx):
39
+ p_idx = action_idx // 196
40
+ rem = action_idx % 196
41
+ rot = rem // 49
42
+ rem2 = rem % 49
43
+ r = rem2 // 7
44
+ c = rem2 % 7
45
+
46
+ piece = self.hand[p_idx]
47
+ if piece is None or not self.can_place(piece, r, c):
48
+ return self.get_state(), -50.0, True # 置けない場合は即終了ペナルティ
49
+
50
+ for _ in range(rot):
51
+ piece = [[piece[1][0], piece[0][0]], [piece[1][1], piece[0][1]]]
52
+
53
+ for ir in range(2):
54
+ for ic in range(2):
55
+ self.board[r+ir][c+ic] = piece[ir][ic]
56
+
57
+ self.hand[p_idx] = self.generate_piece()
58
+ score, done = self.process_matches()
59
+
60
+ # 通常の配置完了で微小な報酬、スコアで大きな報酬
61
+ reward = 1.0 + (score / 10.0)
62
+ return self.get_state(), float(reward), done
63
+
64
+ def process_matches(self):
65
+ score = 0
66
+ combo = 0
67
+ while True:
68
+ visited = [[False]*8 for _ in range(8)]
69
+ to_remove = set()
70
+ for r in range(8):
71
+ for c in range(8):
72
+ color = self.board[r][c]
73
+ if 0 <= color <= 3 and not visited[r][c]:
74
+ q = [(r, c)]
75
+ visited[r][c] = True
76
+ group = [(r, c)]
77
+ while q:
78
+ cr, cc = q.pop(0)
79
+ for dr, dc in [(-1,0), (1,0), (0,-1), (0,1)]:
80
+ nr, nc = cr + dr, cc + dc
81
+ if 0 <= nr < 8 and 0 <= nc < 8 and not visited[nr][nc] and self.board[nr][nc] == color:
82
+ visited[nr][nc] = True
83
+ q.append((nr, nc))
84
+ group.append((nr, nc))
85
+ if len(group) >= 3:
86
+ for gr, gc in group:
87
+ to_remove.add((gr, gc))
88
+ if not to_remove:
89
+ break
90
+ combo += 1
91
+ score += len(to_remove) * 10 * combo
92
+ for rr, cc in to_remove:
93
+ self.board[rr][cc] = -1
94
+
95
+ # 置ける場所があるかチェック
96
+ any_valid = False
97
+ for p in self.hand:
98
+ if p is not None:
99
+ for rr in range(7):
100
+ for cc in range(7):
101
+ if self.can_place(p, rr, cc):
102
+ any_valid = True
103
+ break
104
+ if any_valid: break
105
+ if any_valid: break
106
+
107
+ return score, not any_valid
108
+
109
+ class DQN(nn.Module):
110
+ def __init__(self, input_size, output_size):
111
+ super(DQN, self).__init__()
112
+ self.fc = nn.Sequential(
113
+ nn.Linear(input_size, 256), nn.ReLU(),
114
+ nn.Linear(256, 256), nn.ReLU(),
115
+ nn.Linear(256, output_size)
116
+ )
117
+ def forward(self, x):
118
+ return self.fc(x)
119
+
120
+ def train():
121
+ env = QuadEnv()
122
+ policy_net = DQN(64, 588)
123
+ target_net = copy.deepcopy(policy_net)
124
+ target_net.eval()
125
+
126
+ optimizer = optim.Adam(policy_net.parameters(), lr=0.0005)
127
+ memory = deque(maxlen=20000)
128
+
129
+ batch_size = 64
130
+ gamma = 0.95
131
+ epsilon = 1.0
132
+ epsilon_min = 0.05
133
+ epsilon_decay = 0.995
134
+
135
+ epochs = 2000
136
+ for epoch in range(epochs):
137
+ state = env.reset()
138
+ done = False
139
+ total_reward = 0
140
+ step_count = 0
141
+
142
+ while not done:
143
+ if random.random() < epsilon:
144
+ action_idx = random.randint(0, 587)
145
+ else:
146
+ with torch.no_grad():
147
+ action_idx = policy_net(state).argmax().item()
148
+
149
+ next_state, reward, done = env.step(action_idx)
150
+ memory.append((state, action_idx, reward, next_state, done))
151
+ state = next_state
152
+ total_reward += reward
153
+ step_count += 1
154
+
155
+ # 経験再生
156
+ if len(memory) > batch_size:
157
+ batch = random.sample(memory, batch_size)
158
+ states, actions, rewards, next_states, dones = zip(*batch)
159
+
160
+ states = torch.cat(states)
161
+ actions = torch.tensor(actions).unsqueeze(1)
162
+ rewards = torch.tensor(rewards, dtype=torch.float32)
163
+ next_states = torch.cat(next_states)
164
+ dones = torch.tensor(dones, dtype=torch.float32)
165
+
166
+ q_values = policy_net(states).gather(1, actions).squeeze(1)
167
+ with torch.no_grad():
168
+ next_q_values = target_net(next_states).max(1)[0]
169
+ target_q_values = rewards + gamma * next_q_values * (1 - dones)
170
+
171
+ loss = nn.MSELoss()(q_values, target_q_values)
172
+ optimizer.zero_grad()
173
+ loss.backward()
174
+ optimizer.step()
175
+
176
+ if done: break
177
+
178
+ epsilon = max(epsilon_min, epsilon * epsilon_decay)
179
+
180
+ # 定期的にターゲットネットワークを更新
181
+ if epoch % 10 == 0:
182
+ target_net.load_state_dict(policy_net.state_dict())
183
+
184
+ if epoch % 10 == 0:
185
+ print(f"Epoch {epoch} | Total Reward: {total_reward:.1f} | Steps: {step_count} | Epsilon: {epsilon:.3f}")
186
+
187
+ torch.save(policy_net.state_dict(), "model.pth")
188
+ print("Training Complete. Model saved as model.pth")
189
+
190
+ if __name__ == "__main__":
191
+ train()