<div class="mini-timer">
<div id="red-box" class="ptimer red-glass">
<div class="timer-details">
<span class="label">{{ __("Đỏ") }}</span>
<span id="red-move-clock" class="move-clock">2:00</span>
</div>
<span id="red-clock" class="clock">10:00</span>
</div>
<div id="black-box" class="ptimer black-glass">
<div class="timer-details">
<span class="label">{{ __("Đen") }}</span>
<span id="black-move-clock" class="move-clock">2:00</span>
</div>
<span id="black-clock" class="clock">10:00</span>
</div>
</div>
<style>
@import url('https://fonts.googleapis.com/css2?family=Exo+2:ital,wght@0,100..900;1,100..900&display=swap');
/* ==========================================================================
GLOSSY & COMPACT DUAL-TIMERS (10M + 2M/Move)
========================================================================== */
.mini-timer {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin: 10px auto;
font-family: "Exo 2", "Plus Jakarta Sans", sans-serif;
user-select: none;
}
.ptimer {
position: relative;
display: flex;
align-items: center;
gap: 15px;
padding: 8px 20px;
border-radius: 40px;
min-width: 150px;
overflow: hidden;
background: linear-gradient(145deg, #2a2d38, #1a1c23);
box-shadow:
6px 6px 12px rgba(0, 0, 0, 0.6),
-2px -2px 8px rgba(255, 255, 255, 0.03),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.05);
color: #e0e0e0;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.ptimer::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; height: 45%;
background: linear-gradient(to bottom, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0) 100%);
border-radius: 40px 40px 0 0;
pointer-events: none;
}
.timer-details {
display: flex;
flex-direction: column;
align-items: center;
z-index: 1;
}
.timer-details .label {
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.9;
display: flex;
align-items: center;
gap: 4px;
}
.timer-details .move-clock {
font-size: 0.75rem;
font-weight: 700;
font-family: "JetBrains Mono", "Courier New", monospace;
background: rgba(0, 0, 0, 0.5);
padding: 2px 8px;
border-radius: 6px;
margin-top: 4px;
box-shadow: inset 0 1px 4px rgba(0,0,0,0.8);
transition: all 0.2s ease;
}
.ptimer .clock {
font-family: "JetBrains Mono", "Courier New", monospace;
font-size: 1.4rem;
font-weight: 900;
margin-left: auto;
z-index: 1;
text-shadow: 0 2px 4px rgba(0,0,0,0.6);
}
/* Colors for RED */
.red-glass .clock, .red-glass .label { color: #ff5252; }
.red-glass .move-clock { color: #ff8a80; }
.red-glass.active {
background: linear-gradient(145deg, #5c1616, #300808);
border-color: rgba(255, 82, 82, 0.4);
box-shadow: 0 0 20px rgba(255, 82, 82, 0.3), inset 0 2px 4px rgba(255, 138, 128, 0.2);
transform: translateY(-3px) scale(1.03);
}
/* Colors for BLACK */
.black-glass .clock, .black-glass .label { color: #ffd54f; }
.black-glass .move-clock { color: #ffe082; }
.black-glass.active {
background: linear-gradient(145deg, #2b2b2b, #111111);
border-color: rgba(255, 213, 79, 0.4);
box-shadow: 0 0 20px rgba(255, 213, 79, 0.2), inset 0 2px 4px rgba(255, 224, 130, 0.2);
transform: translateY(-3px) scale(1.03);
}
/* Utility Classes */
.ptimer.paused-offline {
opacity: 0.5;
filter: grayscale(80%);
pointer-events: none;
transform: translateY(0) scale(1);
box-shadow: none;
}
.move-clock.danger {
color: #ffffff !important;
background: #e53935 !important;
box-shadow: 0 0 12px #e53935, inset 0 1px 2px rgba(0,0,0,0.5) !important;
animation: urgent-strobe 0.6s infinite alternate cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes urgent-strobe {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0.85; transform: scale(1.05); }
}
</style>
<script>
const roomCode = "{{ $roomCode }}";
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const apiBase = "{{ url('/api') }}";
const apiReq = async (endpoint, body = null) => {
try {
const res = await fetch(`${apiBase}/${endpoint}`, {
method: body ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
body: body ? JSON.stringify(body) : null
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return await res.json();
} catch (err) { console.error(err); }
};
const ui = {
red: { box: document.getElementById("red-box"), clock: document.getElementById("red-clock"), moveClock: document.getElementById("red-move-clock") },
black: { box: document.getElementById("black-box"), clock: document.getElementById("black-clock"), moveClock: document.getElementById("black-move-clock") }
};
const MOVE_TIME_LIMIT = 120;
let time = { red: 600, black: 600 };
let serverMoveElapsed = 0, activePlayer = null, isGameOver = false;
let tickInterval = null, localLastUpdate = Date.now();
let playersOnline = 0;
// Chỉ cần duy nhất biến này để quản lý trạng thái khóa cờ/đồng hồ ban đầu
window.hasMatchStarted = false;
const formatTime = (s) => `${Math.floor(s / 60)}:${(Math.floor(s) % 60).toString().padStart(2, '0')}`;
const stopAll = () => clearInterval(tickInterval);
const startAll = () => { stopAll(); localLastUpdate = Date.now(); tickInterval = setInterval(updateUI, 100); };
const updateUI = () => {
if (isGameOver) return;
// NEW: Chỉ cần biết có activePlayer là đếm giờ, bất chấp người chơi có online hay không!
const elapsed = activePlayer ? (Date.now() - localLastUpdate) / 1000 : 0;
const current = { red: time.red, black: time.black };
const moveTime = { red: MOVE_TIME_LIMIT, black: MOVE_TIME_LIMIT };
// Xử lý trừ thời gian cho phe đang đi
if (activePlayer) {
current[activePlayer] = Math.max(0, current[activePlayer] - elapsed);
moveTime[activePlayer] = Math.max(0, MOVE_TIME_LIMIT - (serverMoveElapsed + elapsed));
if (moveTime[activePlayer] <= 0) current[activePlayer] = 0;
}
// Render UI
['red', 'black'].forEach(side => {
ui[side].clock.innerText = formatTime(Math.ceil(current[side]));
ui[side].moveClock.innerText = formatTime(Math.ceil(moveTime[side]));
ui[side].moveClock.classList.toggle('danger', moveTime[side] <= 10 && activePlayer === side);
ui[side].box.classList.toggle('active', activePlayer === side);
});
// Trigger Hết Giờ
if (current.red <= 0 || current.black <= 0) {
isGameOver = true;
stopAll();
activePlayer = null;
ui.red.box.classList.remove("active");
ui.black.box.classList.remove("active");
if (typeof updateResult === 'function') {
const result = current.red <= 0 && current.black <= 0 ? '0' : (current.red <= 0 ? '-1' : '1');
updateResult(roomCode, result);
}
}
};
const syncTimerState = (serverData) => {
if (isGameOver || !serverData) return;
time.red = parseFloat(serverData.red_time);
time.black = parseFloat(serverData.black_time);
serverMoveElapsed = parseFloat(serverData.move_elapsed || 0);
localLastUpdate = Date.now();
const sPlayer = serverData.active_player;
// Đồng bộ trạng thái khóa trận đấu từ Server
if (!sPlayer || sPlayer === 'waiting') {
activePlayer = null;
window.hasMatchStarted = false;
} else {
// Nếu có ai đó đang đi (red, black hoặc paused:red...), nghĩa là trận ĐÃ BẮT ĐẦU
activePlayer = sPlayer.startsWith('paused:') ? sPlayer.split(':')[1] : sPlayer;
window.hasMatchStarted = true;
}
updateUI();
// LUÔN LUÔN chạy đồng hồ miễn là có activePlayer
if (activePlayer) startAll();
else stopAll();
};
const handlePresenceChange = () => {
// TRIGGER 1 LẦN DUY NHẤT: Bắt đầu trận đấu khi đủ 2 người
if (!window.hasMatchStarted && playersOnline >= 2) {
window.hasMatchStarted = true; // Khóa ngay lập tức để không gọi API lần 2
// Hiện ứng UI nhấp nháy/mờ có thể tắt đi vì trận đã bắt đầu
ui.red.box.classList.remove('paused-offline');
ui.black.box.classList.remove('paused-offline');
apiReq(`startMatch/${roomCode}`).then(() => {
apiReq(`getTime/${roomCode}`).then(syncTimerState);
});
}
// (Tùy chọn) Thêm hiệu ứng mờ nhẹ nếu ai đó thoát lúc chưa bắt đầu
if (!window.hasMatchStarted && playersOnline < 2) {
ui.red.box.classList.add('paused-offline');
ui.black.box.classList.add('paused-offline');
}
};
window.switchTurn = async (rc, currentPlayer) => {
const elapsed = (Date.now() - localLastUpdate) / 1000;
if (time[currentPlayer]) {
time[currentPlayer] = Math.max(0, time[currentPlayer] - elapsed);
if (serverMoveElapsed + elapsed >= MOVE_TIME_LIMIT) time[currentPlayer] = 0;
}
activePlayer = currentPlayer === 'red' ? 'black' : 'red';
serverMoveElapsed = 0;
localLastUpdate = Date.now();
updateUI();
startAll();
await apiReq(`switchTurn/${rc}`, { current_player: currentPlayer });
};
document.addEventListener('DOMContentLoaded', () => {
// Echo Initialization
if (typeof window.Echo === 'undefined' && typeof window.Pusher !== 'undefined' && typeof Echo !== 'undefined') {
window.Echo = new Echo({
broadcaster: 'pusher',
key: '{{ env("PUSHER_APP_KEY") }}',
cluster: '{{ env("PUSHER_APP_CLUSTER", "ap1") }}',
forceTLS: true,
authEndpoint: '/custom/broadcasting/auth',
auth: { headers: { 'X-CSRF-Token': csrfToken } }
});
}
if (window.Echo) {
window.Echo.join(`room.${roomCode}`)
.here(users => { playersOnline = users.length; handlePresenceChange(); })
.joining(() => { playersOnline++; handlePresenceChange(); })
.leaving(() => { playersOnline = Math.max(0, playersOnline - 1); handlePresenceChange(); });
window.Echo.channel(`room.${roomCode}`)
.listen('.room.updated', (e) => {
if (e.room) apiReq(`getTime/${roomCode}`).then(syncTimerState);
});
}
apiReq(`getTime/${roomCode}`).then(syncTimerState);
});
</script>