+
Số vệ tinh tự nhiên
{{ selectedPlanet.moons }}
-
+
Chu kỳ tự quay
{{ selectedPlanet.rotationLabel }}
@@ -168,8 +194,8 @@ function formatNumber(value: number, digits = 1): string {
v-if="seasonData.retrogradeRotation"
class="mt-3 text-xs leading-relaxed text-text-secondary"
>
- Hành tinh này quay nghịch chiều (retrograde), nên cảm nhận mùa được đảo hướng so với phần lớn
- các hành tinh còn lại.
+ Hành tinh này quay nghịch chiều (retrograde), nên cảm nhận mùa được đảo hướng so với phần
+ lớn các hành tinh còn lại.
diff --git a/src/views/solar-system-lab/composables/useSolarSimulation.ts b/src/views/solar-system-lab/composables/useSolarSimulation.ts
index 9013f6a4..4cc8d770 100644
--- a/src/views/solar-system-lab/composables/useSolarSimulation.ts
+++ b/src/views/solar-system-lab/composables/useSolarSimulation.ts
@@ -105,10 +105,12 @@ function computeOrbitalState(planet: PlanetDefinition, elapsedDays: number): Orb
)
const eccentricAnomalyRad = solveEccentricAnomaly(meanAnomalyRad, planet.eccentricity)
- const trueAnomalyRad = 2 * Math.atan2(
- Math.sqrt(1 + planet.eccentricity) * Math.sin(eccentricAnomalyRad / 2),
- Math.sqrt(1 - planet.eccentricity) * Math.cos(eccentricAnomalyRad / 2),
- )
+ const trueAnomalyRad =
+ 2 *
+ Math.atan2(
+ Math.sqrt(1 + planet.eccentricity) * Math.sin(eccentricAnomalyRad / 2),
+ Math.sqrt(1 - planet.eccentricity) * Math.cos(eccentricAnomalyRad / 2),
+ )
const radiusAU = planet.distanceAU * (1 - planet.eccentricity * Math.cos(eccentricAnomalyRad))
@@ -214,15 +216,7 @@ function drawPlanet(
ctx.strokeStyle = 'rgba(223, 196, 144, 0.55)'
ctx.lineWidth = Math.max(1.1, state.drawRadius * 0.28)
ctx.beginPath()
- ctx.ellipse(
- state.x,
- state.y,
- state.drawRadius * 1.95,
- state.drawRadius * 0.62,
- -0.22,
- 0,
- TAU,
- )
+ ctx.ellipse(state.x, state.y, state.drawRadius * 1.95, state.drawRadius * 0.62, -0.22, 0, TAU)
ctx.stroke()
}
@@ -353,7 +347,9 @@ export function useSolarSimulation() {
const selectedSizeVsEarth = computed(() => selectedPlanet.value.diameterKm / EARTH_DIAMETER_KM)
- const selectedYearVsEarth = computed(() => selectedPlanet.value.orbitalPeriodDays / EARTH_YEAR_DAYS)
+ const selectedYearVsEarth = computed(
+ () => selectedPlanet.value.orbitalPeriodDays / EARTH_YEAR_DAYS,
+ )
const selectedOrbitProgress = computed(() => {
const fraction =
@@ -519,7 +515,8 @@ export function useSolarSimulation() {
const semimajorPx = mapDistanceAUToPixels(planet.distanceAU, maxOrbitRadius)
const semiminorPx = semimajorPx * Math.sqrt(1 - planet.eccentricity * planet.eccentricity)
- const xFocus = semimajorPx * (Math.cos(orbitalState.eccentricAnomalyRad) - planet.eccentricity)
+ const xFocus =
+ semimajorPx * (Math.cos(orbitalState.eccentricAnomalyRad) - planet.eccentricity)
const yFocus = semiminorPx * Math.sin(orbitalState.eccentricAnomalyRad)
const projectedPoint = projectFromFocusCoordinates(
@@ -530,7 +527,11 @@ export function useSolarSimulation() {
)
const diameterRatio = planet.diameterKm / EARTH_DIAMETER_KM
- const drawRadius = clamp(earthBaseRadius * Math.pow(diameterRatio, 0.5) * sizeScale.value, 2.3, 24)
+ const drawRadius = clamp(
+ earthBaseRadius * Math.pow(diameterRatio, 0.5) * sizeScale.value,
+ 2.3,
+ 24,
+ )
projected[planet.id] = {
x: baseCenterX + projectedPoint.x,
@@ -577,9 +578,10 @@ export function useSolarSimulation() {
for (const planet of PLANETS) {
const path = buildOrbitPath(planet, projected[planet.id].semimajorPx, sunX, sunY)
- ctx.strokeStyle = planet.id === selectedPlanetId.value
- ? 'rgba(255, 184, 48, 0.62)'
- : 'rgba(139, 157, 181, 0.23)'
+ ctx.strokeStyle =
+ planet.id === selectedPlanetId.value
+ ? 'rgba(255, 184, 48, 0.62)'
+ : 'rgba(139, 157, 181, 0.23)'
ctx.lineWidth = planet.id === selectedPlanetId.value ? 1.3 : 1
ctx.setLineDash([6, 8])
@@ -601,9 +603,17 @@ export function useSolarSimulation() {
ctx.font = "12px 'Be Vietnam Pro', sans-serif"
ctx.fillStyle = 'rgba(255, 184, 48, 0.94)'
- ctx.fillText(inclinationText, focusState.x + cameraOffsetX + 10, focusState.y + cameraOffsetY - 14)
+ ctx.fillText(
+ inclinationText,
+ focusState.x + cameraOffsetX + 10,
+ focusState.y + cameraOffsetY - 14,
+ )
ctx.fillStyle = 'rgba(56, 189, 248, 0.94)'
- ctx.fillText(eccentricityText, focusState.x + cameraOffsetX + 10, focusState.y + cameraOffsetY + 2)
+ ctx.fillText(
+ eccentricityText,
+ focusState.x + cameraOffsetX + 10,
+ focusState.y + cameraOffsetY + 2,
+ )
}
}
@@ -626,7 +636,14 @@ export function useSolarSimulation() {
const sunRadius = clamp(Math.min(width, height) * 0.05 * sizeScale.value, 18, 44)
- const sunGlow = ctx.createRadialGradient(sunX, sunY, sunRadius * 0.2, sunX, sunY, sunRadius * 2.8)
+ const sunGlow = ctx.createRadialGradient(
+ sunX,
+ sunY,
+ sunRadius * 0.2,
+ sunX,
+ sunY,
+ sunRadius * 2.8,
+ )
sunGlow.addColorStop(0, 'rgba(255, 214, 120, 0.9)')
sunGlow.addColorStop(0.5, 'rgba(255, 167, 64, 0.35)')
sunGlow.addColorStop(1, 'rgba(255, 167, 64, 0)')
@@ -651,7 +668,9 @@ export function useSolarSimulation() {
ctx.arc(sunX, sunY, sunRadius, 0, TAU)
ctx.fill()
- const sortedPlanets = [...PLANETS].sort((left, right) => frameState[left.id].y - frameState[right.id].y)
+ const sortedPlanets = [...PLANETS].sort(
+ (left, right) => frameState[left.id].y - frameState[right.id].y,
+ )
for (const planet of sortedPlanets) {
const state = frameState[planet.id]
@@ -693,9 +712,10 @@ export function useSolarSimulation() {
}
const state = frameState[planet.id]
- ctx.fillStyle = planet.id === selectedPlanetId.value
- ? 'rgba(255, 184, 48, 0.98)'
- : 'rgba(240, 237, 230, 0.88)'
+ ctx.fillStyle =
+ planet.id === selectedPlanetId.value
+ ? 'rgba(255, 184, 48, 0.98)'
+ : 'rgba(240, 237, 230, 0.88)'
ctx.fillText(planet.name, state.x + state.drawRadius + 7, state.y)
}
diff --git a/src/views/solar-system-lab/data.ts b/src/views/solar-system-lab/data.ts
index 919a6347..b19929c7 100644
--- a/src/views/solar-system-lab/data.ts
+++ b/src/views/solar-system-lab/data.ts
@@ -218,8 +218,7 @@ export const LESSON_STEPS: LessonStep[] = [
id: 'step-4',
title: 'So sánh cực đoan',
objective: 'So sánh năm ngắn-và-dài và mùa cực đoan.',
- instructions:
- 'Chuyển sang Uranus và Neptune, quan sát tốc độ quỹ đạo và mùa dài rất lâu.',
+ instructions: 'Chuyển sang Uranus và Neptune, quan sát tốc độ quỹ đạo và mùa dài rất lâu.',
hint: 'Uranus có trục nghiêng gần nằm ngang nên mùa cực đoan hơn Trái Đất.',
focusPlanetId: 'uranus',
recommendedSpeed: 70,
diff --git a/src/views/solar-system-lab/index.vue b/src/views/solar-system-lab/index.vue
index bd097631..54398be9 100644
--- a/src/views/solar-system-lab/index.vue
+++ b/src/views/solar-system-lab/index.vue
@@ -194,22 +194,30 @@ function handleQuizAnswer(payload: { questionId: string; choiceId: string }) {
-
+
SIM DATE
{{ formattedSimulationDate }}
-
+
EARTH YEARS
{{ formatNumber(earthYearsElapsed, 2) }}
-
+
SELECTED PLANET
{{ selectedPlanet.name }}
-
+
LESSON STATUS
{{ lessonStatus }}
@@ -224,7 +232,10 @@ function handleQuizAnswer(payload: { questionId: string; choiceId: string }) {
Mô phỏng quỹ đạo
-
+
- TỐC ĐỘ THỜI GIAN
+ TỐC ĐỘ THỜI GIAN
{{ formatNumber(daysPerSecond, 0) }} ngày/giây
5 → 120
-
+
- TỈ LỆ KHOẢNG CÁCH
+ TỈ LỆ KHOẢNG CÁCH
{{ formatNumber(distanceScale, 2) }}x
0.75 → 1.40
@@ -324,16 +346,27 @@ function handleQuizAnswer(payload: { questionId: string; choiceId: string }) {
- TỈ LỆ KÍCH THƯỚC
+ TỈ LỆ KÍCH THƯỚC
{{ formatNumber(sizeScale, 2) }}x
0.8 → 1.8
-
+
-
LỚP DỮ LIỆU HIỂN THỊ
+
LỚP DỮ LIỆU HIỂN THỊ
{{ planet.name }}
diff --git a/src/views/solar-system-lab/meta.ts b/src/views/solar-system-lab/meta.ts
index af91025c..294d1b99 100644
--- a/src/views/solar-system-lab/meta.ts
+++ b/src/views/solar-system-lab/meta.ts
@@ -2,7 +2,8 @@ import type { PageMeta } from '@/types/page'
const meta: PageMeta = {
name: 'Solar System Lab',
- description: 'Mô phỏng hệ Mặt Trời trực quan với quỹ đạo, tốc độ thời gian và dữ liệu học tập của từng hành tinh.',
+ description:
+ 'Mô phỏng hệ Mặt Trời trực quan với quỹ đạo, tốc độ thời gian và dữ liệu học tập của từng hành tinh.',
author: 'trinhminhnhat',
facebook: 'https://www.facebook.com/iamtrinhminhnhat',
category: 'learn',
diff --git a/src/views/vchess/components/RulesContent.vue b/src/views/vchess/components/RulesContent.vue
index 82c916d9..5b48a2ea 100644
--- a/src/views/vchess/components/RulesContent.vue
+++ b/src/views/vchess/components/RulesContent.vue
@@ -1,3 +1,82 @@
+
+
@@ -89,5 +168,70 @@
trên bàn cờ.
+
+ Hình quân cờ & ghi nhận
+
+ Hình quân hai phe (đỏ và đen) minh hoạ dưới đây lấy từ
+ Flaticon
+ (miễn phí có ghi nguồn). Mỗi dòng có liên kết attribution theo hướng dẫn của tác giả.
+
+
+
+
+
+
+
+
+
Đỏ
+
+
+
+
+
+
Đen
+
+
+
+
+
+
diff --git a/src/views/vchess/composables/use-vchess-ai-worker.ts b/src/views/vchess/composables/use-vchess-ai-worker.ts
index c585090c..7f915e4b 100644
--- a/src/views/vchess/composables/use-vchess-ai-worker.ts
+++ b/src/views/vchess/composables/use-vchess-ai-worker.ts
@@ -1,5 +1,5 @@
import { onUnmounted, shallowRef } from 'vue'
-import type { VChessState } from '../engine/vchess-engine'
+import { positionToCoordinate, type Move, type VChessState } from '../engine/vchess-engine'
import type { SearchResult } from '../engine/vchess-search'
import type { VChessAiWorkerOut } from '../engine/vchess-ai.worker'
import VChessAiWorker from '../engine/vchess-ai.worker.ts?worker'
@@ -15,9 +15,30 @@ function stateForWorker(state: VChessState): VChessState {
return plain
}
+export type VChessAiSearchOutcome =
+ | { kind: 'ok'; result: SearchResult }
+ | { kind: 'no_result' }
+ | { kind: 'cancelled' }
+ | {
+ kind: 'failed'
+ reason: 'worker_crash' | 'search_exception' | 'post_message'
+ detail?: string
+ }
+
export function useVchessAiWorker() {
const workerRef = shallowRef
(null)
- const pending = new Map void>()
+ const pending = new Map void>()
+ const openGroups = new Set()
+
+ function formatMoveForMainConsole(move: Move): string {
+ const from = positionToCoordinate(move.from)
+ const to = positionToCoordinate(move.to)
+ const cap =
+ move.type === 'capture' && move.captureSquare
+ ? ` x${positionToCoordinate(move.captureSquare)}`
+ : ''
+ return `${from}-${to}${cap} (${move.type})`
+ }
function ensureWorker(): Worker {
const existing = workerRef.value
@@ -27,26 +48,52 @@ export function useVchessAiWorker() {
w.onmessage = (event: MessageEvent) => {
const msg = event.data
+ if (msg.type === 'debug') {
+ if (!openGroups.has(msg.id)) {
+ openGroups.add(msg.id)
+ console.groupCollapsed(`[vchess-ai] #${msg.id}`)
+ }
+ const { ply, bestMove, bestScore, elapsedMs, budgetMs } = msg.event
+ console.log(
+ `ply=${ply} best=${formatMoveForMainConsole(bestMove)} score=${bestScore} elapsedMs=${elapsedMs} (budgetMs=${budgetMs})`,
+ )
+ return
+ }
if (msg.type === 'result') {
const resolve = pending.get(msg.id)
if (!resolve) return
pending.delete(msg.id)
- resolve(msg.result)
+ if (openGroups.delete(msg.id)) console.groupEnd()
+ if (msg.result !== null) {
+ resolve({ kind: 'ok', result: msg.result })
+ } else {
+ resolve({ kind: 'no_result' })
+ }
return
}
if (msg.type === 'error') {
const resolve = pending.get(msg.id)
if (!resolve) return
pending.delete(msg.id)
- resolve(null)
+ if (openGroups.delete(msg.id)) console.groupEnd()
+ resolve({
+ kind: 'failed',
+ reason: 'search_exception',
+ detail: msg.message,
+ })
+ return
}
}
w.onerror = () => {
for (const resolve of pending.values()) {
- resolve(null)
+ resolve({ kind: 'failed', reason: 'worker_crash' })
}
pending.clear()
+ for (const id of openGroups) {
+ console.groupEnd()
+ openGroups.delete(id)
+ }
terminateWorkerInstance()
}
@@ -67,16 +114,20 @@ export function useVchessAiWorker() {
*/
function cancelPendingSearches() {
for (const resolve of pending.values()) {
- resolve(null)
+ resolve({ kind: 'cancelled' })
}
pending.clear()
+ for (const id of openGroups) {
+ console.groupEnd()
+ openGroups.delete(id)
+ }
terminateWorkerInstance()
}
/**
* Chạy tìm nước trong Web Worker — **không** gọi findBestMoveSync trên main thread.
*/
- function requestSearch(state: VChessState): Promise {
+ function requestSearch(state: VChessState): Promise {
return new Promise((resolve) => {
const id = ++requestSeq
pending.set(id, resolve)
@@ -89,7 +140,7 @@ export function useVchessAiWorker() {
})
} catch {
pending.delete(id)
- resolve(null)
+ resolve({ kind: 'failed', reason: 'post_message' })
}
})
}
diff --git a/src/views/vchess/composables/use-vchess-sounds.ts b/src/views/vchess/composables/use-vchess-sounds.ts
index 7f268560..2e41cfe1 100644
--- a/src/views/vchess/composables/use-vchess-sounds.ts
+++ b/src/views/vchess/composables/use-vchess-sounds.ts
@@ -40,11 +40,15 @@ export function useVchessSounds() {
window,
'pointerdown',
() => {
- void ensureDecoded().then(() => {
- void getContext().resume()
- })
+ // Một số trình duyệt có thể tự suspend AudioContext sau thời gian idle.
+ // Resume lại ở mọi lần tương tác để âm thanh nước đi không bị mất sau khi chờ lâu.
+ const c = getContext()
+ void c.resume()
+ if (!moveBuffer || !captureBuffer) {
+ void ensureDecoded()
+ }
},
- { passive: true, once: true },
+ { passive: true },
)
}
diff --git a/src/views/vchess/engine/vchess-ai.worker.ts b/src/views/vchess/engine/vchess-ai.worker.ts
index 17d3f229..68a2b42a 100644
--- a/src/views/vchess/engine/vchess-ai.worker.ts
+++ b/src/views/vchess/engine/vchess-ai.worker.ts
@@ -1,6 +1,11 @@
///
import type { VChessState } from './vchess-engine'
-import { findBestMoveSync, randomAiSearchBudgetMs, type SearchResult } from './vchess-search'
+import {
+ findBestMoveSync,
+ randomAiSearchBudgetMs,
+ type IterativePlyDebugEvent,
+ type SearchResult,
+} from './vchess-search'
import { computeHash } from './vchess-zobrist'
export type VChessAiWorkerIn = {
@@ -11,6 +16,7 @@ export type VChessAiWorkerIn = {
export type VChessAiWorkerOut =
| { type: 'result'; id: number; result: SearchResult | null }
+ | { type: 'debug'; id: number; event: IterativePlyDebugEvent }
| { type: 'error'; id: number; message: string }
self.onmessage = (event: MessageEvent) => {
@@ -19,7 +25,12 @@ self.onmessage = (event: MessageEvent) => {
const { id, state } = msg
try {
state.hash = computeHash(state)
- const result = findBestMoveSync(state, randomAiSearchBudgetMs())
+ const result = findBestMoveSync(state, randomAiSearchBudgetMs(), {
+ onIterativePly: (event) => {
+ const out: VChessAiWorkerOut = { type: 'debug', id, event }
+ self.postMessage(out)
+ },
+ })
const out: VChessAiWorkerOut = { type: 'result', id, result }
self.postMessage(out)
} catch (err) {
diff --git a/src/views/vchess/engine/vchess-engine.ts b/src/views/vchess/engine/vchess-engine.ts
index 627e2ba6..da08b1ad 100644
--- a/src/views/vchess/engine/vchess-engine.ts
+++ b/src/views/vchess/engine/vchess-engine.ts
@@ -1042,12 +1042,28 @@ export function isInCheck(state: VChessState, side: Side): boolean {
export type GameStatus = 'playing' | 'checkmate' | 'stalemate'
+/**
+ * Trạng thái ván: chiếu hết cổ điển (hết nước + vua đang đi bị chiếu), hòa stalemate,
+ * hoặc thế không hợp lệ sau xếp quân / FEN: vua đối phương đang bị chiếu dù không phải
+ * lượt họ — coi như ván kết thúc, phe đang tới lượt thắng (giống cách nhiều GUI cờ xử lý).
+ */
export function getGameStatus(state: VChessState): GameStatus {
+ if (!isInCheck(state, state.turn)) {
+ const other: Side = state.turn === 'red' ? 'black' : 'red'
+ if (isInCheck(state, other)) return 'checkmate'
+ }
const hasMoves = getAllLegalMoves(state).length > 0
if (hasMoves) return 'playing'
return isInCheck(state, state.turn) ? 'checkmate' : 'stalemate'
}
+/** Phe thắng khi `getGameStatus` là `checkmate` (gồm chiếu hết chuẩn và thế “vua đối phương bị chiếu sai lượt”). */
+export function getCheckmateWinner(state: VChessState): Side | null {
+ if (getGameStatus(state) !== 'checkmate') return null
+ if (!isInCheck(state, state.turn)) return state.turn
+ return state.turn === 'red' ? 'black' : 'red'
+}
+
/**
* Lightweight move apply for search — skips pseudo move re-generation and
* legality re-check since search already got legal moves from getAllLegalMoves.
diff --git a/src/views/vchess/engine/vchess-search.ts b/src/views/vchess/engine/vchess-search.ts
index cff0df75..6e5065a9 100644
--- a/src/views/vchess/engine/vchess-search.ts
+++ b/src/views/vchess/engine/vchess-search.ts
@@ -64,6 +64,12 @@ export const AI_MAX_PLY = 12
export const AI_SEARCH_MS_MIN = 8_000
export const AI_MAX_SEARCH_MS = 15_000
+/**
+ * Bật `true` để worker gửi sự kiện từng ply về main (xem `use-vchess-ai-worker.ts`).
+ * Không đổi kết quả tìm kiếm; chỉ thêm chi phí nhỏ (postMessage + clone) giữa các vòng deepening.
+ */
+export const VCHESS_AI_DEBUG_ITERATIVE_PLY = true
+
export function randomAiSearchBudgetMs(): number {
const span = AI_MAX_SEARCH_MS - AI_SEARCH_MS_MIN + 1
return AI_SEARCH_MS_MIN + Math.floor(Math.random() * span)
@@ -450,7 +456,23 @@ export interface SearchResult {
depthCompleted: number
}
-export function findBestMoveSync(state: VChessState, budgetMs: number): SearchResult | null {
+export type IterativePlyDebugEvent = {
+ ply: number
+ bestMove: Move
+ bestScore: number
+ elapsedMs: number
+ budgetMs: number
+}
+
+export type FindBestMoveOptions = {
+ onIterativePly?: (event: IterativePlyDebugEvent) => void
+}
+
+export function findBestMoveSync(
+ state: VChessState,
+ budgetMs: number,
+ options?: FindBestMoveOptions,
+): SearchResult | null {
const root = cloneState(state)
const moves = getAllLegalMoves(root)
if (moves.length === 0) return null
@@ -501,6 +523,17 @@ export function findBestMoveSync(state: VChessState, budgetMs: number): SearchRe
if (bestMove !== null) {
pvMove = bestMove
bestSoFar = { move: bestMove, score: bestScore, depthCompleted: depth }
+ // Gọi sau khi ply kết thúc; không chạm state bàn cờ / TT — chỉ báo cáo.
+ if (VCHESS_AI_DEBUG_ITERATIVE_PLY) {
+ const elapsedMs = Date.now() - start
+ options?.onIterativePly?.({
+ ply: depth,
+ bestMove,
+ bestScore,
+ elapsedMs,
+ budgetMs,
+ })
+ }
} else {
break
}
diff --git a/src/views/vchess/index.vue b/src/views/vchess/index.vue
index 4eec0e94..999e50b2 100644
--- a/src/views/vchess/index.vue
+++ b/src/views/vchess/index.vue
@@ -8,15 +8,17 @@ import {
useMediaQuery,
} from '@vueuse/core'
import { computed, ref, shallowRef, watch } from 'vue'
-import { useVchessAiWorker } from './composables/use-vchess-ai-worker'
+import { useVchessAiWorker, type VChessAiSearchOutcome } from './composables/use-vchess-ai-worker'
import { useVchessClock } from './composables/use-vchess-clock'
import { useVchessSounds } from './composables/use-vchess-sounds'
import { VCHESS_CLOCK_DEFAULTS, type VChessClockSettings } from './constants'
import {
BOARD_COLS,
BOARD_ROWS,
+ createStateFromBoard,
createInitialState,
findKing,
+ getCheckmateWinner,
getGameStatus,
getLegalMovesForSquare,
isInCheck,
@@ -26,8 +28,10 @@ import {
undoLastMove,
type MoveRecord,
type Piece,
+ type PieceKind,
type Position,
type Side,
+ type VChessState,
} from './engine/vchess-engine'
import { AI_MAX_PLY, AI_MAX_SEARCH_MS, AI_SEARCH_MS_MIN } from './engine/vchess-search'
import { getPieceImageSrc } from './piece-images'
@@ -45,6 +49,9 @@ type GameMode = 'solo' | 'vs-ai'
const screen = ref('menu')
const gameMode = ref('solo')
+/** Đang chạy tìm nước do nút «AI đi thay» (chế độ hai người). */
+const aiSingleMoveBusy = ref(false)
+
/** shallowRef: engine gọi apply/unapply tạm trên state — deep reactive sẽ kích hoạt lại computed giữa chừng và có thể lặp vô hạn. */
const gameState = shallowRef(createInitialState())
const selectedSquare = ref(null)
@@ -55,6 +62,9 @@ const matchClockSettings = ref(null)
/** Tạm dừng ván vs máy: dừng đồng hồ, không cho đi quân, không gọi AI. */
const aiGamePaused = ref(false)
+/** Chế độ xếp quân: đồng hồ tạm dừng cho đến khi tắt editor. */
+const boardEditorEnabled = ref(false)
+
const clockSettings = useLocalStorage('vchess-clock-settings', () => ({
initialMinutes: VCHESS_CLOCK_DEFAULTS.initialMinutes,
incrementSeconds: VCHESS_CLOCK_DEFAULTS.incrementSeconds,
@@ -85,13 +95,112 @@ const clockEnabled = computed(
screen.value === 'game' &&
gameMode.value === 'vs-ai' &&
matchClockSettings.value !== null &&
- !aiGamePaused.value,
+ !aiGamePaused.value &&
+ !boardEditorEnabled.value,
)
const { playForMoveType } = useVchessSounds()
const { requestSearch, cancelPendingSearches } = useVchessAiWorker()
const { copy: copyToClipboard } = useClipboard()
+/** Thông báo khi worker / tìm kiếm AI lỗi hoặc không trả nước (vs máy). */
+const aiSearchErrorMessage = ref(null)
+const aiRetryBusy = ref(false)
+
+function formatAiSearchFailure(
+ outcome: Extract,
+): string {
+ switch (outcome.reason) {
+ case 'worker_crash':
+ return 'Web Worker bị lỗi (thường do trình duyệt). Thử «Thử lại»; nếu vẫn lỗi, tải lại trang.'
+ case 'post_message':
+ return 'Không gửi được tác vụ tới máy. Thử «Thử lại» hoặc tải lại trang.'
+ case 'search_exception': {
+ const d = outcome.detail?.trim()
+ const short = d && d.length > 0 ? (d.length > 120 ? `${d.slice(0, 117)}…` : d) : ''
+ return short
+ ? `Lỗi khi tính nước: ${short}. Thử «Thử lại» hoặc «Hoàn nước».`
+ : 'Lỗi khi tính nước. Thử «Thử lại» hoặc «Hoàn nước».'
+ }
+ }
+}
+
+function handleAiSearchOutcome(outcome: VChessAiSearchOutcome, snapshot: VChessState): void {
+ if (outcome.kind === 'cancelled') return
+ if (outcome.kind === 'ok') {
+ aiSearchErrorMessage.value = null
+ playForMoveType(outcome.result.move.type === 'capture' ? 'capture' : 'move')
+ gameState.value = makeMove(snapshot, outcome.result.move)
+ selectedSquare.value = null
+ return
+ }
+ if (outcome.kind === 'no_result') {
+ aiSearchErrorMessage.value =
+ 'Máy không tìm được nước đi trong thời gian cho phép. Bạn có thể «Thử lại» hoặc «Hoàn nước» để đi lại nước Đỏ.'
+ return
+ }
+ aiSearchErrorMessage.value = formatAiSearchFailure(outcome)
+}
+
+async function retryAiMove() {
+ if (gameMode.value !== 'vs-ai' || screen.value !== 'game') return
+ if (aiGamePaused.value) return
+ if (status.value !== 'playing' || timeoutLoser.value) return
+ if (aiRetryBusy.value) return
+
+ aiRetryBusy.value = true
+ aiSearchErrorMessage.value = null
+ const snapshot = gameState.value
+ try {
+ const outcome = await requestSearch(snapshot)
+ if (screen.value !== 'game' || gameMode.value !== 'vs-ai') return
+ if (timeoutLoser.value) return
+ if (gameState.value !== snapshot) return
+ if (gameState.value.turn !== snapshot.turn) return
+ if (getGameStatus(gameState.value) !== 'playing') return
+
+ handleAiSearchOutcome(outcome, snapshot)
+ } finally {
+ aiRetryBusy.value = false
+ }
+}
+
+/** Gọi AI đúng một nước cho phe đang tới lượt (chỉ chế độ hai người). */
+async function requestAiMoveForCurrentTurn() {
+ if (screen.value !== 'game') return
+ if (boardEditorEnabled.value) return
+ if (status.value !== 'playing' || timeoutLoser.value) return
+ if (aiGamePaused.value) return
+ if (aiSingleMoveBusy.value) return
+ if (gameMode.value !== 'solo') return
+
+ aiSingleMoveBusy.value = true
+ aiSearchErrorMessage.value = null
+ const snapshot = gameState.value
+ try {
+ const outcome = await requestSearch(snapshot)
+ if (screen.value !== 'game') return
+ if (timeoutLoser.value) return
+ if (gameState.value !== snapshot) return
+ if (gameState.value.turn !== snapshot.turn) return
+ if (getGameStatus(gameState.value) !== 'playing') return
+
+ handleAiSearchOutcome(outcome, snapshot)
+ } finally {
+ aiSingleMoveBusy.value = false
+ }
+}
+
+const showRequestAiMoveButton = computed(
+ () =>
+ screen.value === 'game' &&
+ gameMode.value === 'solo' &&
+ !boardEditorEnabled.value &&
+ status.value === 'playing' &&
+ !timeoutLoser.value &&
+ !aiGamePaused.value,
+)
+
const pgnFeedback = ref('')
const pgnPasteText = ref('')
@@ -134,6 +243,7 @@ function applyPgnImport(text: string) {
return
}
aiGamePaused.value = false
+ aiSearchErrorMessage.value = null
gameState.value = result
selectedSquare.value = null
pgnPasteText.value = ''
@@ -150,6 +260,83 @@ function applyPgnFromTextarea() {
const fenPasteText = ref('')
const fenFeedback = ref('')
+const boardEditorFeedback = ref('')
+
+type BoardEditorTool =
+ | 'erase'
+ | 'red-king'
+ | 'black-king'
+ | 'red-rook'
+ | 'black-rook'
+ | 'red-knight'
+ | 'black-knight'
+ | 'red-elephant'
+ | 'black-elephant'
+ | 'red-gunner'
+ | 'black-gunner'
+ | 'red-pawn'
+ | 'black-pawn'
+ | 'red-assassin'
+ | 'black-assassin'
+ | 'red-eagle-ground'
+ | 'black-eagle-ground'
+ | 'red-eagle-flying'
+ | 'black-eagle-flying'
+
+interface BoardEditorPaletteItem {
+ tool: BoardEditorTool
+ label: string
+ piece: Piece | null
+}
+
+const boardEditorTool = ref('red-king')
+const boardEditorTurn = ref('red')
+const boardEditorKingTwoStepRed = ref(true)
+const boardEditorKingTwoStepBlack = ref(true)
+
+/** Chặn đặt vua thứ hai — mở modal giải thích. */
+const boardEditorSecondKingModalOpen = ref(false)
+
+/** Xác nhận làm trống bàn (thay window.confirm). */
+const boardEditorClearBoardModalOpen = ref(false)
+
+const boardEditorPalette: BoardEditorPaletteItem[] = [
+ { tool: 'red-king', label: 'Vua Đỏ', piece: { side: 'red', kind: 'king' } },
+ { tool: 'black-king', label: 'Vua Đen', piece: { side: 'black', kind: 'king' } },
+ { tool: 'red-rook', label: 'Xe Đỏ', piece: { side: 'red', kind: 'rook' } },
+ { tool: 'black-rook', label: 'Xe Đen', piece: { side: 'black', kind: 'rook' } },
+ { tool: 'red-knight', label: 'Mã Đỏ', piece: { side: 'red', kind: 'knight' } },
+ { tool: 'black-knight', label: 'Mã Đen', piece: { side: 'black', kind: 'knight' } },
+ { tool: 'red-elephant', label: 'Tượng Đỏ', piece: { side: 'red', kind: 'elephant' } },
+ { tool: 'black-elephant', label: 'Tượng Đen', piece: { side: 'black', kind: 'elephant' } },
+ { tool: 'red-gunner', label: 'Pháo Đỏ', piece: { side: 'red', kind: 'gunner' } },
+ { tool: 'black-gunner', label: 'Pháo Đen', piece: { side: 'black', kind: 'gunner' } },
+ { tool: 'red-pawn', label: 'Tốt Đỏ', piece: { side: 'red', kind: 'pawn' } },
+ { tool: 'black-pawn', label: 'Tốt Đen', piece: { side: 'black', kind: 'pawn' } },
+ { tool: 'red-assassin', label: 'Sát thủ Đỏ', piece: { side: 'red', kind: 'assassin' } },
+ { tool: 'black-assassin', label: 'Sát thủ Đen', piece: { side: 'black', kind: 'assassin' } },
+ {
+ tool: 'red-eagle-ground',
+ label: 'Đại bàng Đỏ (đất)',
+ piece: { side: 'red', kind: 'eagle', eagleMode: 'ground' },
+ },
+ {
+ tool: 'black-eagle-ground',
+ label: 'Đại bàng Đen (đất)',
+ piece: { side: 'black', kind: 'eagle', eagleMode: 'ground' },
+ },
+ {
+ tool: 'red-eagle-flying',
+ label: 'Đại bàng Đỏ (bay)',
+ piece: { side: 'red', kind: 'eagle', eagleMode: 'flying' },
+ },
+ {
+ tool: 'black-eagle-flying',
+ label: 'Đại bàng Đen (bay)',
+ piece: { side: 'black', kind: 'eagle', eagleMode: 'flying' },
+ },
+ { tool: 'erase', label: 'Tẩy quân', piece: null },
+]
function clearFenFeedbackSoon() {
window.setTimeout(() => {
@@ -175,6 +362,7 @@ function applyFenFromTextarea() {
return
}
aiGamePaused.value = false
+ aiSearchErrorMessage.value = null
gameState.value = result
selectedSquare.value = null
if (gameMode.value === 'vs-ai' && matchClockSettings.value) {
@@ -196,13 +384,160 @@ async function copyFenToClipboard() {
}
}
+function clonePieceForEditor(piece: Piece): Piece {
+ if (piece.kind !== 'eagle') return { side: piece.side, kind: piece.kind }
+ return {
+ side: piece.side,
+ kind: 'eagle',
+ eagleMode: piece.eagleMode === 'flying' ? 'flying' : 'ground',
+ }
+}
+
+function cloneBoardForEditor() {
+ return gameState.value.board.map((row) =>
+ row.map((piece) => (piece ? clonePieceForEditor(piece) : null)),
+ )
+}
+
+function getEditorPieceFromTool(tool: BoardEditorTool): Piece | null {
+ const item = boardEditorPalette.find((p) => p.tool === tool)
+ if (!item || !item.piece) return null
+ return clonePieceForEditor(item.piece)
+}
+
+function countPieces(kind: PieceKind, side: Side): number {
+ let count = 0
+ for (let row = 0; row < BOARD_ROWS; row++) {
+ for (let col = 0; col < BOARD_COLS; col++) {
+ const piece = gameState.value.board[row]?.[col]
+ if (piece?.kind === kind && piece.side === side) count += 1
+ }
+ }
+ return count
+}
+
+function countKingsOnBoard(board: (Piece | null)[][], side: Side): number {
+ let count = 0
+ for (let row = 0; row < BOARD_ROWS; row++) {
+ for (let col = 0; col < BOARD_COLS; col++) {
+ const piece = board[row]?.[col]
+ if (piece?.kind === 'king' && piece.side === side) count += 1
+ }
+ }
+ return count
+}
+
+const boardEditorSummary = computed(() => {
+ const redKings = countPieces('king', 'red')
+ const blackKings = countPieces('king', 'black')
+ if (redKings > 1 || blackKings > 1) {
+ return 'Nhiều vua cùng phe (thường do FEN): engine chỉ tính theo một ô. Nên sửa bàn hoặc FEN cho đúng 1 vua mỗi bên.'
+ }
+ if (redKings !== 1 || blackKings !== 1) {
+ return 'Nên có đúng 1 vua mỗi bên để thế cờ hợp lệ.'
+ }
+ if (isInCheck(gameState.value, gameState.value.turn)) {
+ const side = gameState.value.turn === 'red' ? 'Đỏ' : 'Đen'
+ return `Phe ${side} đang bị chiếu ở thế cờ hiện tại.`
+ }
+ return 'Thế cờ có thể chơi ngay.'
+})
+
+function syncBoardEditorControlsFromState() {
+ boardEditorTurn.value = gameState.value.turn
+ boardEditorKingTwoStepRed.value = gameState.value.kingTwoStepAvailable.red
+ boardEditorKingTwoStepBlack.value = gameState.value.kingTwoStepAvailable.black
+}
+
+function applyBoardEditorState(nextBoard: (Piece | null)[][]) {
+ gameState.value = createStateFromBoard(nextBoard, boardEditorTurn.value, {
+ red: boardEditorKingTwoStepRed.value,
+ black: boardEditorKingTwoStepBlack.value,
+ })
+ selectedSquare.value = null
+ aiSearchErrorMessage.value = null
+ aiGamePaused.value = false
+ cancelPendingSearches()
+}
+
+function dismissBoardEditorSecondKingModal() {
+ boardEditorSecondKingModalOpen.value = false
+}
+
+function handleBoardEditorSquareClick(row: number, col: number) {
+ const nextBoard = cloneBoardForEditor()
+ const piece = getEditorPieceFromTool(boardEditorTool.value)
+ nextBoard[row]![col] = piece
+ if (piece?.kind === 'king' && countKingsOnBoard(nextBoard, piece.side) > 1) {
+ boardEditorSecondKingModalOpen.value = true
+ return
+ }
+ applyBoardEditorState(nextBoard)
+}
+
+function toggleBoardEditor() {
+ boardEditorEnabled.value = !boardEditorEnabled.value
+ boardEditorFeedback.value = ''
+ boardEditorClearBoardModalOpen.value = false
+ selectedSquare.value = null
+ if (boardEditorEnabled.value) {
+ syncBoardEditorControlsFromState()
+ } else {
+ gameResultDismissed.value = false
+ }
+}
+
+function setBoardEditorTurn(side: Side) {
+ boardEditorTurn.value = side
+ applyBoardEditorState(cloneBoardForEditor())
+}
+
+function setBoardEditorKingTwoStep(side: Side, value: boolean) {
+ if (side === 'red') boardEditorKingTwoStepRed.value = value
+ else boardEditorKingTwoStepBlack.value = value
+ applyBoardEditorState(cloneBoardForEditor())
+}
+
+function openClearBoardModalForEditor() {
+ boardEditorClearBoardModalOpen.value = true
+}
+
+function dismissClearBoardModalForEditor() {
+ boardEditorClearBoardModalOpen.value = false
+}
+
+function confirmClearBoardForEditor() {
+ const nextBoard = Array.from({ length: BOARD_ROWS }, () =>
+ Array.from({ length: BOARD_COLS }, () => null as Piece | null),
+ )
+ applyBoardEditorState(nextBoard)
+ boardEditorFeedback.value = 'Đã làm trống bàn cờ.'
+ boardEditorClearBoardModalOpen.value = false
+}
+
+function restoreInitialPositionForEditor() {
+ const initial = createInitialState()
+ gameState.value = initial
+ selectedSquare.value = null
+ aiSearchErrorMessage.value = null
+ aiGamePaused.value = false
+ cancelPendingSearches()
+ syncBoardEditorControlsFromState()
+ boardEditorFeedback.value = 'Đã khôi phục thế cờ khai cuộc.'
+}
+
const status = computed(() => getGameStatus(gameState.value))
/** Ô vua đang bị chiếu — hiển thị nhấn mạnh trên bàn (khi ván đang diễn ra hoặc vừa chiếu hết). */
const kingInCheckSquare = computed((): Position | null => {
if (status.value !== 'playing' && status.value !== 'checkmate') return null
- if (!isInCheck(gameState.value, gameState.value.turn)) return null
- return findKing(gameState.value, gameState.value.turn)
+ const turn = gameState.value.turn
+ if (status.value === 'checkmate' && !isInCheck(gameState.value, turn)) {
+ const other: Side = turn === 'red' ? 'black' : 'red'
+ return findKing(gameState.value, other)
+ }
+ if (!isInCheck(gameState.value, turn)) return null
+ return findKing(gameState.value, turn)
})
const {
@@ -258,6 +593,7 @@ function beginVsAiFromSetup() {
}
gameMode.value = 'vs-ai'
aiGamePaused.value = false
+ aiSearchErrorMessage.value = null
gameState.value = createInitialState()
selectedSquare.value = null
screen.value = 'game'
@@ -328,16 +664,19 @@ const isAiThinking = computed(
screen.value === 'game' &&
gameMode.value === 'vs-ai' &&
!aiGamePaused.value &&
+ !boardEditorEnabled.value &&
status.value === 'playing' &&
!timeoutLoser.value &&
gameState.value.turn === 'black',
)
+const interactionBlocked = computed(() => isAiThinking.value || aiSingleMoveBusy.value)
+
/** Lật đại bàng (mặt đất → bay): chỉ khi đã chọn đúng quân và đến lượt — icon / phím L (cạnh bàn: nhấp ô quân khi không có ô phía trước trong bàn). */
const canFlipSelectedEagle = computed(() => {
if (status.value !== 'playing' || timeoutLoser.value) return false
if (gameMode.value === 'vs-ai' && aiGamePaused.value) return false
- if (isAiThinking.value) return false
+ if (interactionBlocked.value) return false
const sel = selectedSquare.value
if (!sel) return false
const p = gameState.value.board[sel.row]?.[sel.col]
@@ -394,7 +733,7 @@ useEventListener(
const modeLabel = computed(() =>
gameMode.value === 'solo'
- ? 'Hai người đi cùng máy (luân phiên lượt)'
+ ? 'Hai người đi cùng máy (luân phiên lượt) — có thể bấm «AI đi thay» cho lượt hiện tại.'
: `Bạn cầm Đỏ; máy cầm Đen (${Math.round(AI_SEARCH_MS_MIN / 1000)}–${Math.round(AI_MAX_SEARCH_MS / 1000)}s ngẫu nhiên / tối đa ${AI_MAX_PLY} ply mỗi nước)`,
)
@@ -407,26 +746,53 @@ const headline = computed(() => {
) {
return 'Tạm dừng'
}
+ if (boardEditorEnabled.value) {
+ if (status.value === 'stalemate') {
+ return 'Đang xếp quân — bàn chưa có nước hợp lệ (không tính hòa lúc xếp)'
+ }
+ if (status.value === 'checkmate') {
+ return 'Đang xếp quân — thế tạm chiếu hết (không tính kết quả lúc xếp)'
+ }
+ return 'Đang xếp quân — chọn quân rồi chạm ô để đặt'
+ }
if (timeoutLoser.value) {
const winner = timeoutLoser.value === 'red' ? 'Đen' : 'Đỏ'
return `Hết giờ - ${winner} thắng`
}
if (status.value === 'checkmate') {
- const winner = gameState.value.turn === 'red' ? 'Đen' : 'Đỏ'
- return `Chiếu hết - ${winner} thắng`
+ const w = getCheckmateWinner(gameState.value)
+ const winnerLabel = w === 'red' ? 'Đỏ' : 'Đen'
+ return `Chiếu hết - ${winnerLabel} thắng`
}
if (status.value === 'stalemate') return 'Hòa - hết nước đi'
if (isInCheck(gameState.value, gameState.value.turn))
return `Đang bị chiếu - lượt ${turnText.value}`
+ if (
+ gameMode.value === 'vs-ai' &&
+ aiSearchErrorMessage.value &&
+ status.value === 'playing' &&
+ !timeoutLoser.value
+ ) {
+ return 'Máy (Đen) chưa đi — xem thông báo'
+ }
+ if (aiSingleMoveBusy.value) return 'AI đang đi thay…'
if (isAiThinking.value) return 'Máy (Đen) đang đi…'
return `Lượt đi: ${turnText.value}`
})
watch(
() =>
- [gameState.value.turn, gameMode.value, screen.value, status.value, aiGamePaused.value] as const,
+ [
+ gameState.value.turn,
+ gameMode.value,
+ screen.value,
+ status.value,
+ aiGamePaused.value,
+ boardEditorEnabled.value,
+ ] as const,
(_cur, _prev, onCleanup) => {
if (screen.value !== 'game' || gameMode.value !== 'vs-ai') return
+ if (boardEditorEnabled.value) return
if (aiGamePaused.value) return
if (status.value !== 'playing' || timeoutLoser.value) return
if (gameState.value.turn !== 'black') return
@@ -437,17 +803,17 @@ watch(
const snapshot = gameState.value
if (snapshot.turn !== 'black') return
const historyLen = snapshot.history.length
- const result = await requestSearch(snapshot)
+ const outcome = await requestSearch(snapshot)
if (cancelled) return
if (screen.value !== 'game' || gameMode.value !== 'vs-ai') return
+ if (boardEditorEnabled.value) return
if (timeoutLoser.value) return
if (gameState.value !== snapshot) return
if (gameState.value.history.length !== historyLen) return
if (gameState.value.turn !== 'black') return
if (getGameStatus(gameState.value) !== 'playing') return
- if (!result) return
- gameState.value = makeMove(snapshot, result.move)
- selectedSquare.value = null
+
+ handleAiSearchOutcome(outcome, snapshot)
})()
}, 380)
@@ -465,9 +831,6 @@ watch(
(len, prevLen) => {
if (len < 1) return
if (prevLen !== undefined && len <= prevLen) return
- const record = gameState.value.history[len - 1]
- if (!record) return
- playForMoveType(record.move.type === 'capture' ? 'capture' : 'move')
if (screen.value === 'game' && gameMode.value === 'vs-ai') {
applyIncrementAfterMove()
}
@@ -479,6 +842,7 @@ function goMenu() {
selectedSquare.value = null
matchClockSettings.value = null
aiGamePaused.value = false
+ aiSearchErrorMessage.value = null
cancelPendingSearches()
}
@@ -507,6 +871,7 @@ function backFromRules() {
function startSoloGame() {
matchClockSettings.value = null
aiGamePaused.value = false
+ aiSearchErrorMessage.value = null
gameMode.value = 'solo'
gameState.value = createInitialState()
selectedSquare.value = null
@@ -515,6 +880,7 @@ function startSoloGame() {
function resetGame() {
aiGamePaused.value = false
+ aiSearchErrorMessage.value = null
gameState.value = createInitialState()
selectedSquare.value = null
if (gameMode.value === 'vs-ai' && matchClockSettings.value) {
@@ -524,6 +890,7 @@ function resetGame() {
function handleUndo() {
if (!canUndo.value) return
+ aiSearchErrorMessage.value = null
cancelPendingSearches()
const before = gameState.value
if (before.history.length === 0) return
@@ -551,6 +918,7 @@ function moveTo(row: number, col: number) {
)
const move = candidates[0]
if (!move) return
+ playForMoveType(move.type === 'capture' ? 'capture' : 'move')
gameState.value = makeMove(gameState.value, move)
selectedSquare.value = null
}
@@ -559,14 +927,19 @@ function maybeFlipSelected() {
if (!selectedSquare.value) return
const flipMove = legalMovesFromSelected.value.find((move) => move.type === 'flip')
if (!flipMove) return
+ playForMoveType('move')
gameState.value = makeMove(gameState.value, flipMove)
selectedSquare.value = null
}
function handleSquareClick(row: number, col: number) {
+ if (boardEditorEnabled.value) {
+ handleBoardEditorSquareClick(row, col)
+ return
+ }
if (status.value !== 'playing' || timeoutLoser.value) return
if (gameMode.value === 'vs-ai' && aiGamePaused.value) return
- if (isAiThinking.value) return
+ if (interactionBlocked.value) return
const flipUi = eagleFlipUiPlacement.value
if (flipUi?.kind === 'eagleEdge' && flipUi.row === row && flipUi.col === col) {
@@ -639,6 +1012,16 @@ const lastMoveRecord = computed((): MoveRecord | null => {
function isLastMoveFromSquare(row: number, col: number): boolean {
const rec = lastMoveRecord.value
if (!rec) return false
+ const { from, type } = rec.move
+ if (type === 'flip') return false
+ return from.row === row && from.col === col
+}
+
+/** Lật đại bàng: from === to — tô gradient ô đi + ô đích trên cùng một ô. */
+function isLastMoveFlipSquare(row: number, col: number): boolean {
+ const rec = lastMoveRecord.value
+ if (!rec) return false
+ if (rec.move.type !== 'flip') return false
const { from } = rec.move
return from.row === row && from.col === col
}
@@ -651,6 +1034,18 @@ function isLastMoveToSquare(row: number, col: number): boolean {
return to.row === row && to.col === col
}
+/** Sát thủ nhảy bắt: ô quân bị ăn khác ô đích — tô riêng. */
+function isLastMoveAssassinVictimSquare(row: number, col: number): boolean {
+ const rec = lastMoveRecord.value
+ if (!rec) return false
+ const m = rec.move
+ if (m.type !== 'capture' || !m.captureSquare) return false
+ const v = m.captureSquare
+ const t = m.to
+ if (v.row === t.row && v.col === t.col) return false
+ return v.row === row && v.col === col
+}
+
type GameOverInfo =
| { outcome: 'checkmate'; winner: Side }
| { outcome: 'timeout'; winner: Side }
@@ -658,13 +1053,14 @@ type GameOverInfo =
const gameOverInfo = computed((): GameOverInfo | null => {
if (screen.value !== 'game') return null
+ if (boardEditorEnabled.value) return null
if (timeoutLoser.value) {
const winner: Side = timeoutLoser.value === 'red' ? 'black' : 'red'
return { outcome: 'timeout', winner }
}
if (status.value === 'checkmate') {
- const winner: Side = gameState.value.turn === 'red' ? 'black' : 'red'
- return { outcome: 'checkmate', winner }
+ const winner = getCheckmateWinner(gameState.value)
+ if (winner) return { outcome: 'checkmate', winner }
}
if (status.value === 'stalemate') {
return { outcome: 'stalemate' }
@@ -672,6 +1068,11 @@ const gameOverInfo = computed((): GameOverInfo | null => {
return null
})
+/** Chiếu hết do thế “vua đối phương bị chiếu không đúng lượt” (sau xếp quân / FEN), không phải hết nước. */
+const isIllegalOpponentCheckmate = computed(
+ () => status.value === 'checkmate' && !isInCheck(gameState.value, gameState.value.turn),
+)
+
const gameResultDismissed = ref(false)
watch(
@@ -699,8 +1100,14 @@ function winnerDescription(winner: Side): string {
- ← Trang chủ vibe.j2team.org
+
+ Trang chủ J2TEAM
@@ -848,7 +1256,7 @@ function winnerDescription(winner: Side): string {
class="border border-border-default bg-bg-elevated px-5 py-2.5 text-sm text-text-secondary transition hover:border-accent-amber hover:text-text-primary"
@click="cancelAiSetup"
>
- Quay lại menu
+ Quay lại menu vChess
- ← Trang chủ vibe.j2team.org
+
+ Trang chủ J2TEAM
-
-
-
+
+
+
- {{ rulesBackTarget === 'game' ? 'Quay lại bàn cờ' : 'Quay lại menu' }}
+ {{ rulesBackTarget === 'game' ? 'Quay lại bàn cờ' : 'Quay lại menu vChess' }}
- Trang chủ
+
+ Trang chủ J2TEAM
-
-
- //
- Luật chơi vChess
-
-
- Bản rút gọn trong app; chi tiết đầy đủ có thể xem thêm trong
- rules.md (repo).
-
-
@@ -919,10 +1327,10 @@ function winnerDescription(winner: Side): string {
v-else
class="mx-auto flex w-full max-w-[100rem] flex-col gap-5 lg:flex-row lg:items-start lg:gap-6"
>
-
+
// vCHESS
@@ -939,6 +1347,147 @@ function winnerDescription(winner: Side): string {
— không dùng nhấp lại ô quân đại bàng.
+
+
// Xếp quân trực quan
+
+ Theo flow quen thuộc của Chess.com / Lichess / Xiangqi editor: chọn quân ở palette rồi
+ chạm vào ô để đặt. Không cần biết FEN/PGN.
+
+
+
+ {{ boardEditorEnabled ? 'Đang bật chế độ xếp quân' : 'Bật chế độ xếp quân' }}
+
+
+
+
+
+ Bàn trống
+
+
+
+ Thế chuẩn
+
+
+
+
+ Lượt đi sau khi xếp
+
+
+
+ Đỏ đi
+
+
+ Đen đi
+
+
+
+
+
+
+ Vua Đỏ còn đi 2 ô
+
+
+
+ Vua Đen còn đi 2 ô
+
+
+
+
+ Chọn quân để đặt
+
+
+
+
+
+
+
+
+
+
+
+ {{ boardEditorSummary }}
+
+
+ {{ boardEditorFeedback }}
+
+
+
// Xuất / nhập ván (PGN vChess)
@@ -1009,17 +1558,25 @@ function winnerDescription(winner: Side): string {
// Thế cờ (FEN vChess)
- 11 hàng / (từ hàng
- k xuống
- a), 9 cột/số. Quân:
- RNEGKPA + đại bàng
+ Một dòng gồm phần bàn rồi phần trạng thái. Phần bàn là 11 đoạn nối bằng
+ /
+ — mỗi đoạn là một hàng cờ (9 ô), đọc từ
+ k (trên, gần Đen) xuống
+ a (dưới, gần Đỏ). Trong mỗi đoạn: chữ số = số ô
+ trống liên tiếp, chữ cái = quân; chữ hoa = Đỏ, chữ thường = Đen. Quân:
+ R N E G K P A; đại bàng
H/h
- (đất) / F/F/f
- (bay). Hoa = Đỏ. Sau bàn: r|b lượt,
- 0|1 quyền vua đi 2 ô (Đỏ, Đen),
- - 0 1 giữ chỗ.
+ (bay). Phần sau (cách khoảng): r hoặc
+ b — ai đi; hai số
+ 0 hoặc
+ 1
+ — lần lượt quyền vua đi hai ô lần đầu (Đỏ, rồi Đen); ba ký tự
+ - 0 1 — ô dự phòng (giữ cấu trúc giống FEN cờ
+ Tây).
+
+
- ← Trang chủ
+
+ Trang chủ J2TEAM
- Menu
+ Menu vChess
{{ aiGamePaused ? 'Tiếp tục' : 'Tạm dừng' }}
+
+
+ AI đi thay (lượt này)
+
// Lượt đi
{{ headline }}
+
+
+ // Lỗi tìm nước máy
+
+
{{ aiSearchErrorMessage }}
+
+
+
+ Thử lại
+
+
+
+ Hoàn nước
+
+
+
- Muốn đổi: Menu → Chơi với máy để tạo ván mới với thời gian khác.
+ Muốn đổi: Menu vChess → Chơi với máy để tạo ván mới với thời gian khác.
@@ -1538,6 +2149,98 @@ function winnerDescription(winner: Side): string {
+
+
+
+
+
+
// Xếp quân
+
+ Xóa toàn bộ quân trên bàn?
+
+
+ Mọi quân sẽ bị gỡ khỏi bàn. Bạn vẫn có thể dùng «Thế chuẩn» để khôi phục khai cuộc.
+
+
+
+
+ Xóa bàn
+
+
+ Hủy
+
+
+
+
+
+
+
+
+
+
+
// Xếp quân
+
+ Chỉ được một vua mỗi phe
+
+
+ Luật chuẩn và engine vChess chỉ hỗ trợ đúng một vua Đỏ và một vua Đen. Để đổi chỗ vua,
+ hãy tẩy ô cũ hoặc đặt vua lên ô đã có vua cùng màu để thay thế — không thể thêm vua thứ
+ hai trên ô khác.
+
+
+
+ Đã hiểu
+
+
+
+
+
@@ -1576,13 +2279,29 @@ function winnerDescription(winner: Side): string {
height: min(2.25rem, calc(var(--vchess-cell) * 0.58));
}
-/* Nước đi cuối: ô đi (amber) / ô đến (sky) — tách biệt màu với ô chiếu (coral). */
+/* Nước đi cuối: ô đi / ô đến — nền phẳng (kiểu highlight cờ truyền thống). */
.vchess-last-move-from {
- background: linear-gradient(135deg, rgba(255, 184, 48, 0.38) 0%, rgba(255, 184, 48, 0.14) 100%);
+ background: rgba(52, 211, 153, 0.32);
}
.vchess-last-move-to {
- background: linear-gradient(135deg, rgba(56, 189, 248, 0.32) 0%, rgba(56, 189, 248, 0.12) 100%);
+ background: rgba(56, 189, 248, 0.38);
+}
+
+/* Sát thủ nhảy bắt — ô quân bị ăn (khác ô đích). */
+.vchess-last-move-assassin-victim {
+ background: rgba(255, 184, 48, 0.44);
+}
+
+/* Lật đại bàng: hai nửa màu ô đi / ô đích — cạnh cứng (không pha trộn). */
+.vchess-last-move-flip {
+ background: linear-gradient(
+ 135deg,
+ rgba(52, 211, 153, 0.32) 0%,
+ rgba(52, 211, 153, 0.32) 50%,
+ rgba(56, 189, 248, 0.38) 50%,
+ rgba(56, 189, 248, 0.38) 100%
+ );
}
/* Ô vua bị chiếu — nền + nhấp nháy nhẹ để thu hút chú ý (accent coral). */
diff --git a/src/views/vchess/utils/vchess-fen.ts b/src/views/vchess/utils/vchess-fen.ts
index b522612b..dbdb8a77 100644
--- a/src/views/vchess/utils/vchess-fen.ts
+++ b/src/views/vchess/utils/vchess-fen.ts
@@ -9,9 +9,10 @@ import {
} from '../engine/vchess-engine'
/**
- * FEN vChess — bàn 9×11, hàng trong chuỗi từ **trên xuống** (hàng k → a).
- * Quân: R N E G K P A; đại bàng đất H/h, bay F/f. Hoa = Đỏ, thường = Đen.
- * Phần sau bàn: `r|b` lượt; `0|1` quyền vua đi 2 ô lần đầu (Đỏ rồi Đen); `- 0 1` giữ chỗ (giống FIDE).
+ * FEN vChess — một dòng: [phần bàn] [phần trạng thái].
+ * Phần bàn: 11 rank nối bằng `/`, mỗi rank 9 ô; rank đầu = hàng k (phía Đen), rank cuối = hàng a (phía Đỏ).
+ * Trong mỗi rank: chữ số = ô trống liên tiếp; chữ = quân (hoa Đỏ, thường Đen). Quân: R N E G K P A; đại bàng H/h đất, F/f bay.
+ * Phần sau: `r` hoặc `b` lượt; hai bit 0/1 quyền vua đi 2 ô lần đầu (Đỏ, Đen); `- 0 1` placeholder (FIDE-style).
*
* Ví dụ (một dòng): `.../... r 1 1 - 0 1`
*/