123
This commit is contained in:
156
public/game.js
156
public/game.js
@ -1776,7 +1776,8 @@
|
|||||||
connect(url);
|
connect(url);
|
||||||
socket.once('connect', () => {
|
socket.once('connect', () => {
|
||||||
console.log('Подключено, создаём комнату');
|
console.log('Подключено, создаём комнату');
|
||||||
socket.emit('createRoom', name);
|
const aiMode = $('ai-mode')?.checked || false;
|
||||||
|
socket.emit('createRoom', { name, aiMode });
|
||||||
});
|
});
|
||||||
socket.once('connect_error', (e) => {
|
socket.once('connect_error', (e) => {
|
||||||
console.error('Ошибка подключения:', e);
|
console.error('Ошибка подключения:', e);
|
||||||
@ -1785,7 +1786,8 @@
|
|||||||
} else {
|
} else {
|
||||||
console.log('Уже подключен, создаём комнату');
|
console.log('Уже подключен, создаём комнату');
|
||||||
// Уже подключен, сразу отправляем
|
// Уже подключен, сразу отправляем
|
||||||
socket.emit('createRoom', name);
|
const aiMode = $('ai-mode')?.checked || false;
|
||||||
|
socket.emit('createRoom', { name, aiMode });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1804,13 +1806,15 @@
|
|||||||
if (!socket || !socket.connected) {
|
if (!socket || !socket.connected) {
|
||||||
connect(url);
|
connect(url);
|
||||||
socket.once('connect', () => {
|
socket.once('connect', () => {
|
||||||
socket.emit('createRoom', name);
|
const aiMode = $('ai-mode')?.checked || false;
|
||||||
|
socket.emit('createRoom', { name, aiMode });
|
||||||
});
|
});
|
||||||
socket.once('connect_error', (e) => {
|
socket.once('connect_error', (e) => {
|
||||||
showError('Не удалось подключиться к серверу. Проверьте, что сервер запущен.');
|
showError('Не удалось подключиться к серверу. Проверьте, что сервер запущен.');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
socket.emit('createRoom', name);
|
const aiMode = $('ai-mode')?.checked || false;
|
||||||
|
socket.emit('createRoom', { name, aiMode });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -2257,8 +2261,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addCardToForge(cardId, source) {
|
function addCardToForge(cardId, source) {
|
||||||
|
if (!cardId) return;
|
||||||
if (forgeSelected.includes(cardId)) return; // Уже добавлена
|
if (forgeSelected.includes(cardId)) return; // Уже добавлена
|
||||||
if (forgeSelected.length >= 3) return; // Максимум 3 карты
|
if (forgeSelected.length >= 3) {
|
||||||
|
// Показываем сообщение, что максимум 3 карты
|
||||||
|
const selectedEl = $('forge-selected');
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.style.animation = 'shake 0.5s';
|
||||||
|
setTimeout(() => selectedEl.style.animation = '', 500);
|
||||||
|
}
|
||||||
|
return; // Максимум 3 карты
|
||||||
|
}
|
||||||
|
|
||||||
const meta = cardDb[cardId];
|
const meta = cardDb[cardId];
|
||||||
if (!meta || meta.type !== 'minion') return; // Только миньоны
|
if (!meta || meta.type !== 'minion') return; // Только миньоны
|
||||||
@ -2275,6 +2288,9 @@
|
|||||||
renderForgeDeck(gameState);
|
renderForgeDeck(gameState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Настраиваем drag-and-drop заново после добавления
|
||||||
|
setTimeout(() => setupForgeDragAndDrop(gameState), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupForgeDragAndDrop(state) {
|
function setupForgeDragAndDrop(state) {
|
||||||
@ -2334,6 +2350,131 @@
|
|||||||
selectedEl.addEventListener('dragleave', handleDragLeave);
|
selectedEl.addEventListener('dragleave', handleDragLeave);
|
||||||
selectedEl.addEventListener('drop', handleDrop);
|
selectedEl.addEventListener('drop', handleDrop);
|
||||||
|
|
||||||
|
// Поддержка touch для мобильных устройств
|
||||||
|
if (isTouchDevice()) {
|
||||||
|
let touchStartCard = null;
|
||||||
|
let touchStartSource = null;
|
||||||
|
let touchStartElement = null;
|
||||||
|
|
||||||
|
// Обработка touch для карт в руке - упрощённая версия: просто тап добавляет карту
|
||||||
|
$all('#your-hand .card-wrap').forEach(cardWrap => {
|
||||||
|
const cardId = cardWrap.dataset.cardId;
|
||||||
|
const meta = cardDb[cardId];
|
||||||
|
if (cardId && meta && meta.type === 'minion') {
|
||||||
|
// Удаляем старые обработчики
|
||||||
|
cardWrap.ontouchstart = null;
|
||||||
|
cardWrap.ontouchend = null;
|
||||||
|
|
||||||
|
cardWrap.ontouchstart = function(e) {
|
||||||
|
if (sidebar && !sidebar.classList.contains('hidden')) {
|
||||||
|
touchStartCard = cardId;
|
||||||
|
touchStartSource = 'hand';
|
||||||
|
touchStartElement = cardWrap;
|
||||||
|
cardWrap._touchStartTime = Date.now();
|
||||||
|
cardWrap.classList.add('touch-active');
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cardWrap.ontouchend = function(e) {
|
||||||
|
if (touchStartCard === cardId && sidebar && !sidebar.classList.contains('hidden')) {
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const timeDiff = Date.now() - (touchStartElement?._touchStartTime || Date.now());
|
||||||
|
|
||||||
|
// Если это быстрый тап (менее 300мс), просто добавляем карту
|
||||||
|
if (timeDiff < 300) {
|
||||||
|
addCardToForge(cardId, 'hand');
|
||||||
|
} else {
|
||||||
|
// Иначе проверяем, куда перетащили
|
||||||
|
const dropEl = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
if (dropEl && (dropEl === selectedEl || selectedEl.contains(dropEl) ||
|
||||||
|
dropEl.closest('#forge-selected'))) {
|
||||||
|
addCardToForge(cardId, 'hand');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cardWrap.classList.remove('touch-active');
|
||||||
|
touchStartCard = null;
|
||||||
|
touchStartSource = null;
|
||||||
|
touchStartElement = null;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cardWrap.ontouchcancel = function() {
|
||||||
|
if (touchStartCard === cardId) {
|
||||||
|
cardWrap.classList.remove('touch-active');
|
||||||
|
touchStartCard = null;
|
||||||
|
touchStartSource = null;
|
||||||
|
touchStartElement = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработка touch для карт в колоде - упрощённая версия
|
||||||
|
$all('.forge-deck-card').forEach(cardWrap => {
|
||||||
|
const cardId = cardWrap.dataset.cardId;
|
||||||
|
if (cardId) {
|
||||||
|
// Удаляем старые обработчики
|
||||||
|
cardWrap.ontouchstart = null;
|
||||||
|
cardWrap.ontouchend = null;
|
||||||
|
|
||||||
|
cardWrap.ontouchstart = function(e) {
|
||||||
|
touchStartCard = cardId;
|
||||||
|
touchStartSource = 'deck';
|
||||||
|
touchStartElement = cardWrap;
|
||||||
|
cardWrap._touchStartTime = Date.now();
|
||||||
|
cardWrap.classList.add('touch-active');
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
cardWrap.ontouchend = function(e) {
|
||||||
|
if (touchStartCard === cardId) {
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
const timeDiff = Date.now() - (touchStartElement?._touchStartTime || Date.now());
|
||||||
|
|
||||||
|
// Если это быстрый тап (менее 300мс), просто добавляем карту
|
||||||
|
if (timeDiff < 300) {
|
||||||
|
addCardToForge(cardId, 'deck');
|
||||||
|
} else {
|
||||||
|
// Иначе проверяем, куда перетащили
|
||||||
|
const dropEl = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
if (dropEl && (dropEl === selectedEl || selectedEl.contains(dropEl) ||
|
||||||
|
dropEl.closest('#forge-selected'))) {
|
||||||
|
addCardToForge(cardId, 'deck');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cardWrap.classList.remove('touch-active');
|
||||||
|
touchStartCard = null;
|
||||||
|
touchStartSource = null;
|
||||||
|
touchStartElement = null;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cardWrap.ontouchcancel = function() {
|
||||||
|
if (touchStartCard === cardId) {
|
||||||
|
cardWrap.classList.remove('touch-active');
|
||||||
|
touchStartCard = null;
|
||||||
|
touchStartSource = null;
|
||||||
|
touchStartElement = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Также добавляем обработчик на саму область выбранных карт для визуальной обратной связи
|
||||||
|
selectedEl.ontouchstart = function(e) {
|
||||||
|
selectedEl.classList.add('drag-over');
|
||||||
|
};
|
||||||
|
|
||||||
|
selectedEl.ontouchend = function(e) {
|
||||||
|
selectedEl.classList.remove('drag-over');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Делаем карты в руке перетаскиваемыми в кузницу
|
// Делаем карты в руке перетаскиваемыми в кузницу
|
||||||
$all('#your-hand .card-wrap').forEach(cardWrap => {
|
$all('#your-hand .card-wrap').forEach(cardWrap => {
|
||||||
const cardId = cardWrap.dataset.cardId;
|
const cardId = cardWrap.dataset.cardId;
|
||||||
@ -2414,13 +2555,16 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).filter(Boolean).join('');
|
}).filter(Boolean).join('');
|
||||||
|
|
||||||
// Клик по карте в колоде тоже добавляет её
|
// Клик по карте в колоде тоже добавляет её (для десктопа)
|
||||||
|
// На мобильных это обрабатывается через touch события
|
||||||
|
if (!isTouchDevice()) {
|
||||||
$all('.forge-deck-card').forEach(card => {
|
$all('.forge-deck-card').forEach(card => {
|
||||||
card.onclick = function() {
|
card.onclick = function() {
|
||||||
const cardId = card.dataset.cardId;
|
const cardId = card.dataset.cardId;
|
||||||
addCardToForge(cardId, 'deck');
|
addCardToForge(cardId, 'deck');
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
renderForgeSelected();
|
renderForgeSelected();
|
||||||
updateForgeCraftButton();
|
updateForgeCraftButton();
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
<span class="logo-sw">STAR WARS</span>
|
<span class="logo-sw">STAR WARS</span>
|
||||||
<span class="logo-hs">HEARTHSTONE</span>
|
<span class="logo-hs">HEARTHSTONE</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="subtitle">PvP до 4 игроков · Работает через Radmin VPN</p>
|
<p class="subtitle">PvP до 4 игроков · Игра с ИИ · Работает через Radmin VPN</p>
|
||||||
<button type="button" id="btn-instructions-lobby" class="btn-instructions-lobby" title="Как играть">?</button>
|
<button type="button" id="btn-instructions-lobby" class="btn-instructions-lobby" title="Как играть">?</button>
|
||||||
<button type="button" id="btn-cards-gallery" class="btn-instructions-lobby" title="Галерея карт" style="right: 3.5rem;">📚</button>
|
<button type="button" id="btn-cards-gallery" class="btn-instructions-lobby" title="Галерея карт" style="right: 3.5rem;">📚</button>
|
||||||
<button type="button" id="btn-settings-lobby" class="btn-settings-lobby" title="Настройки">⚙</button>
|
<button type="button" id="btn-settings-lobby" class="btn-settings-lobby" title="Настройки">⚙</button>
|
||||||
@ -40,6 +40,10 @@
|
|||||||
<div id="host-panel" class="panel active">
|
<div id="host-panel" class="panel active">
|
||||||
<label>Ваше имя</label>
|
<label>Ваше имя</label>
|
||||||
<input type="text" id="host-name" placeholder="Игрок 1" maxlength="20" />
|
<input type="text" id="host-name" placeholder="Игрок 1" maxlength="20" />
|
||||||
|
<label style="margin-top: 1rem; display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
|
||||||
|
<input type="checkbox" id="ai-mode" style="width: 18px; height: 18px; cursor: pointer;" />
|
||||||
|
<span>Играть против ИИ</span>
|
||||||
|
</label>
|
||||||
<button type="button" id="btn-host" class="btn btn-primary">Создать комнату</button>
|
<button type="button" id="btn-host" class="btn btn-primary">Создать комнату</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,8 @@
|
|||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
background: var(--space);
|
background: var(--space);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
@ -42,12 +43,16 @@ html, body {
|
|||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Static starfield - no animation */
|
/* Static starfield - no animation */
|
||||||
@ -1587,6 +1592,12 @@ html, body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-10px); }
|
||||||
|
75% { transform: translateX(10px); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.forge-sidebar {
|
.forge-sidebar {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@ -2075,50 +2086,72 @@ html, body {
|
|||||||
html, body {
|
html, body {
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen {
|
.screen {
|
||||||
padding: 1rem;
|
padding: 0.5rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lobby-card {
|
.lobby-card {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-header {
|
.game-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--space);
|
||||||
|
border-bottom: 1px solid rgba(0,180,255,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left, .header-center, .header-right {
|
.header-left, .header-center, .header-right {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
order: -1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 0.85rem 1.5rem;
|
padding: 0.85rem 1.5rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-end-turn {
|
.btn-end-turn {
|
||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
padding: 0.7rem 1.6rem;
|
padding: 0.7rem 1.6rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-instructions, .btn-settings {
|
.btn-instructions, .btn-settings {
|
||||||
width: 40px;
|
width: 44px;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-wrap {
|
.card-wrap {
|
||||||
width: 140px;
|
width: 140px;
|
||||||
height: 196px;
|
height: 196px;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board .card-wrap {
|
.board .card-wrap {
|
||||||
@ -2131,22 +2164,51 @@ html, body {
|
|||||||
height: 140px;
|
height: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opponents {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opponent-block {
|
||||||
|
min-width: auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
padding: 0.5rem;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hand .card-wrap {
|
.hand .card-wrap {
|
||||||
margin-left: -40px;
|
margin-left: -30px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hand .card-wrap:first-child {
|
.hand .card-wrap:first-child {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hand .card-wrap:last-child {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.hand-container {
|
.hand-container {
|
||||||
min-height: 180px;
|
min-height: 200px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-fan {
|
.deck-fan {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
height: 126px;
|
height: 126px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-stack {
|
.deck-stack {
|
||||||
@ -2162,21 +2224,31 @@ html, body {
|
|||||||
.board {
|
.board {
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.your-board {
|
.your-board {
|
||||||
min-height: 110px;
|
min-height: 110px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opponent-block {
|
.your-board .card-wrap {
|
||||||
min-width: 140px;
|
scroll-snap-align: start;
|
||||||
padding: 0.5rem;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attack-mode {
|
.attack-mode {
|
||||||
bottom: 200px;
|
bottom: 200px;
|
||||||
padding: 0.7rem 1.2rem;
|
padding: 0.7rem 1.2rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attack-mode p {
|
.attack-mode p {
|
||||||
@ -2187,6 +2259,8 @@ html, body {
|
|||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin: 1rem;
|
margin: 1rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cards-gallery-grid {
|
.cards-gallery-grid {
|
||||||
@ -2199,14 +2273,38 @@ html, body {
|
|||||||
height: 140px !important;
|
height: 140px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forge-sidebar {
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.forge-deck-list {
|
.forge-deck-list {
|
||||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forge-deck-card {
|
.forge-deck-card {
|
||||||
width: 80px !important;
|
width: 80px !important;
|
||||||
height: 112px !important;
|
height: 112px !important;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forge-selected {
|
||||||
|
min-height: 120px;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forge-selected-card {
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mana-display, .health-display {
|
.mana-display, .health-display {
|
||||||
@ -2225,6 +2323,8 @@ html, body {
|
|||||||
.game-log {
|
.game-log {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
max-height: 60px;
|
max-height: 60px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-name {
|
.card-name {
|
||||||
@ -2233,6 +2333,7 @@ html, body {
|
|||||||
|
|
||||||
.card-text {
|
.card-text {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-cost, .card-atk-hp {
|
.card-cost, .card-atk-hp {
|
||||||
@ -2240,9 +2341,11 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-btn-info {
|
.card-btn-info {
|
||||||
width: 24px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.9rem;
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Touch feedback */
|
/* Touch feedback */
|
||||||
@ -2256,11 +2359,21 @@ html, body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Prevent text selection on touch */
|
/* Prevent text selection on touch */
|
||||||
.card-wrap, .btn, .tab {
|
.card-wrap, .btn, .tab, .deck-fan {
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch для интерактивных элементов */
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrap:active:not(.disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Small mobile devices */
|
/* Small mobile devices */
|
||||||
|
|||||||
225
server.js
225
server.js
@ -113,8 +113,28 @@ function initGame(room) {
|
|||||||
manualDrawUsed: false,
|
manualDrawUsed: false,
|
||||||
fatigueCounter: 0,
|
fatigueCounter: 0,
|
||||||
heroAbilityUsed: false,
|
heroAbilityUsed: false,
|
||||||
|
isAI: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Если режим ИИ, добавляем ИИ игрока
|
||||||
|
if (room.aiMode) {
|
||||||
|
players.push({
|
||||||
|
id: 'AI_' + Date.now(),
|
||||||
|
name: 'ИИ Противник',
|
||||||
|
deck: createDeck(factionChoice),
|
||||||
|
hand: [],
|
||||||
|
board: [],
|
||||||
|
mana: 0,
|
||||||
|
maxMana: 0,
|
||||||
|
health: 30,
|
||||||
|
hero: 'vader',
|
||||||
|
manualDrawUsed: false,
|
||||||
|
fatigueCounter: 0,
|
||||||
|
heroAbilityUsed: false,
|
||||||
|
isAI: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
players.forEach((p, i) => {
|
players.forEach((p, i) => {
|
||||||
p.hand = drawCards(p.deck, i < 2 ? 3 : 4);
|
p.hand = drawCards(p.deck, i < 2 ? 3 : 4);
|
||||||
});
|
});
|
||||||
@ -131,6 +151,193 @@ function initGame(room) {
|
|||||||
room.gameStarted = true;
|
room.gameStarted = true;
|
||||||
startTurnTimer(room);
|
startTurnTimer(room);
|
||||||
broadcastGameState(room);
|
broadcastGameState(room);
|
||||||
|
|
||||||
|
// Если первый ход ИИ, делаем его ход
|
||||||
|
if (room.aiMode && players[0].isAI) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (room.gameState && room.gameState.phase === 'playing') {
|
||||||
|
makeAITurn(room);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для хода ИИ
|
||||||
|
function makeAITurn(room) {
|
||||||
|
const gameState = room.gameState;
|
||||||
|
if (!gameState || gameState.phase !== 'playing') return;
|
||||||
|
|
||||||
|
const aiPlayerIndex = gameState.currentPlayerIndex;
|
||||||
|
const aiPlayer = gameState.players[aiPlayerIndex];
|
||||||
|
|
||||||
|
if (!aiPlayer || !aiPlayer.isAI || aiPlayer.health <= 0) return;
|
||||||
|
if (gameState.currentPlayerIndex !== aiPlayerIndex) return;
|
||||||
|
|
||||||
|
// Применяем синергии перед ходом
|
||||||
|
applySynergies(room);
|
||||||
|
|
||||||
|
// Небольшая задержка для визуализации
|
||||||
|
setTimeout(() => {
|
||||||
|
let actionsDone = 0;
|
||||||
|
const maxActions = 5; // Максимум действий за ход
|
||||||
|
|
||||||
|
// 1. Играем карты (приоритет дешёвым и эффективным)
|
||||||
|
const playableCards = aiPlayer.hand
|
||||||
|
.map((cardId, index) => {
|
||||||
|
const card = cardDb[cardId];
|
||||||
|
if (!card) return null;
|
||||||
|
const cost = card.cost || 0;
|
||||||
|
if (cost > aiPlayer.mana) return null;
|
||||||
|
if (card.type === 'minion' && aiPlayer.board.length >= 7) return null;
|
||||||
|
|
||||||
|
// Оценка карты (чем выше, тем лучше)
|
||||||
|
let value = 0;
|
||||||
|
if (card.type === 'minion') {
|
||||||
|
value = (card.attack || 0) + (card.health || 0) - cost * 2;
|
||||||
|
if (card.battlecryId) value += 2;
|
||||||
|
if (card.legendary) value += 1;
|
||||||
|
} else if (card.type === 'spell') {
|
||||||
|
value = 3 - cost;
|
||||||
|
if (card.spellEffect === 'deal_3_minion' || card.spellEffect === 'deal_3_any') value += 2;
|
||||||
|
if (card.spellEffect === 'buff_3_2') value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { index, card, cost, value };
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// Играем лучшие карты
|
||||||
|
let cardIndex = 0;
|
||||||
|
const playNextCard = () => {
|
||||||
|
if (cardIndex >= playableCards.length || actionsDone >= 3) {
|
||||||
|
// Переходим к атакам
|
||||||
|
setTimeout(() => {
|
||||||
|
performAIAttacks(room, aiPlayerIndex);
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playable = playableCards[cardIndex];
|
||||||
|
const currentPlayer = gameState.players[aiPlayerIndex];
|
||||||
|
|
||||||
|
if (!currentPlayer || playable.cost > currentPlayer.mana) {
|
||||||
|
cardIndex++;
|
||||||
|
playNextCard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playable.card.type === 'minion') {
|
||||||
|
// Играем миньона (пропускаем карты с выбором цели для упрощения)
|
||||||
|
if (playable.card.battlecryId === 'return_hand_enemy') {
|
||||||
|
cardIndex++;
|
||||||
|
playNextCard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const boardPos = currentPlayer.board.length;
|
||||||
|
playCard(room, aiPlayer.id, playable.index, boardPos);
|
||||||
|
actionsDone++;
|
||||||
|
cardIndex++;
|
||||||
|
setTimeout(playNextCard, 600);
|
||||||
|
} else if (playable.card.type === 'spell') {
|
||||||
|
// Используем заклинание
|
||||||
|
const enemies = gameState.players.filter((p, i) => i !== aiPlayerIndex && p.health > 0);
|
||||||
|
if (enemies.length > 0) {
|
||||||
|
const targetEnemy = enemies[0];
|
||||||
|
let targetBoardIndex = -1;
|
||||||
|
|
||||||
|
if (playable.card.spellTarget === 'enemy_minion' || playable.card.spellTarget === 'any_minion') {
|
||||||
|
if (targetEnemy.board && targetEnemy.board.length > 0) {
|
||||||
|
targetBoardIndex = 0;
|
||||||
|
} else {
|
||||||
|
targetBoardIndex = -1;
|
||||||
|
}
|
||||||
|
} else if (playable.card.spellTarget === 'friendly_minion') {
|
||||||
|
if (currentPlayer.board && currentPlayer.board.length > 0) {
|
||||||
|
targetBoardIndex = 0;
|
||||||
|
} else {
|
||||||
|
cardIndex++;
|
||||||
|
playNextCard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (playable.card.spellTarget === 'enemy_player') {
|
||||||
|
// Для заклинаний типа "Грабеж" пропускаем (требует выбор карт)
|
||||||
|
if (playable.card.spellEffect === 'steal_cards') {
|
||||||
|
cardIndex++;
|
||||||
|
playNextCard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetBoardIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
playSpell(room, aiPlayer.id, playable.index, gameState.players.indexOf(targetEnemy), targetBoardIndex);
|
||||||
|
actionsDone++;
|
||||||
|
cardIndex++;
|
||||||
|
setTimeout(playNextCard, 600);
|
||||||
|
} else {
|
||||||
|
cardIndex++;
|
||||||
|
playNextCard();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cardIndex++;
|
||||||
|
playNextCard();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
playNextCard();
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для атак ИИ
|
||||||
|
function performAIAttacks(room, aiPlayerIndex) {
|
||||||
|
const gameState = room.gameState;
|
||||||
|
if (!gameState || gameState.phase !== 'playing') {
|
||||||
|
endTurn(room);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiPlayer = gameState.players[aiPlayerIndex];
|
||||||
|
if (!aiPlayer || aiPlayer.health <= 0) {
|
||||||
|
endTurn(room);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attackableMinions = aiPlayer.board.filter(m => m.canAttack && !m.frozen);
|
||||||
|
const enemies = gameState.players.filter((p, i) => i !== aiPlayerIndex && p.health > 0);
|
||||||
|
|
||||||
|
if (attackableMinions.length === 0 || enemies.length === 0) {
|
||||||
|
setTimeout(() => endTurn(room), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attackIndex = 0;
|
||||||
|
const performNextAttack = () => {
|
||||||
|
if (attackIndex >= attackableMinions.length) {
|
||||||
|
setTimeout(() => endTurn(room), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minion = attackableMinions[attackIndex];
|
||||||
|
const boardIndex = aiPlayer.board.indexOf(minion);
|
||||||
|
const targetEnemy = enemies[0];
|
||||||
|
const targetPlayerIndex = gameState.players.indexOf(targetEnemy);
|
||||||
|
let targetBoardIndex = -1;
|
||||||
|
|
||||||
|
// Выбираем цель: сначала слабые миньоны, потом герой
|
||||||
|
if (targetEnemy.board && targetEnemy.board.length > 0) {
|
||||||
|
const weakMinion = targetEnemy.board.find(m => m.health <= minion.attack) ||
|
||||||
|
targetEnemy.board[0];
|
||||||
|
targetBoardIndex = targetEnemy.board.indexOf(weakMinion);
|
||||||
|
} else {
|
||||||
|
targetBoardIndex = -1; // Атакуем героя
|
||||||
|
}
|
||||||
|
|
||||||
|
attack(room, aiPlayer.id, boardIndex, targetPlayerIndex, targetBoardIndex);
|
||||||
|
attackIndex++;
|
||||||
|
setTimeout(performNextAttack, 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
performNextAttack();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTurnTimer(room) {
|
function clearTurnTimer(room) {
|
||||||
@ -364,6 +571,15 @@ function endTurn(room) {
|
|||||||
checkGameOver(room);
|
checkGameOver(room);
|
||||||
startTurnTimer(room);
|
startTurnTimer(room);
|
||||||
broadcastGameState(room);
|
broadcastGameState(room);
|
||||||
|
|
||||||
|
// Если следующий игрок - ИИ, делаем его ход
|
||||||
|
if (np.isAI && gameState.phase === 'playing') {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (room.gameState && room.gameState.phase === 'playing') {
|
||||||
|
makeAITurn(room);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function manualDraw(room, socketId) {
|
function manualDraw(room, socketId) {
|
||||||
@ -1519,7 +1735,9 @@ function attack(room, socketId, attackerBoardIndex, targetPlayerIndex, targetBoa
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('createRoom', (name) => {
|
socket.on('createRoom', (data) => {
|
||||||
|
const name = typeof data === 'string' ? data : (data?.name || 'Host');
|
||||||
|
const aiMode = typeof data === 'object' && data?.aiMode === true;
|
||||||
const code = generateRoomCode();
|
const code = generateRoomCode();
|
||||||
const room = {
|
const room = {
|
||||||
code,
|
code,
|
||||||
@ -1527,6 +1745,7 @@ io.on('connection', (socket) => {
|
|||||||
gameState: null,
|
gameState: null,
|
||||||
gameStarted: false,
|
gameStarted: false,
|
||||||
faction: null,
|
faction: null,
|
||||||
|
aiMode: aiMode,
|
||||||
turnTimerInterval: null,
|
turnTimerInterval: null,
|
||||||
turnTimeLeft: TURN_SECONDS,
|
turnTimeLeft: TURN_SECONDS,
|
||||||
turnTimerWarnFired: false,
|
turnTimerWarnFired: false,
|
||||||
@ -1539,6 +1758,7 @@ io.on('connection', (socket) => {
|
|||||||
lobby: room.lobby,
|
lobby: room.lobby,
|
||||||
serverIP: serverIP,
|
serverIP: serverIP,
|
||||||
serverPort: serverPort,
|
serverPort: serverPort,
|
||||||
|
aiMode: aiMode,
|
||||||
});
|
});
|
||||||
cleanupEmptyRooms();
|
cleanupEmptyRooms();
|
||||||
});
|
});
|
||||||
@ -1593,7 +1813,8 @@ io.on('connection', (socket) => {
|
|||||||
socket.emit('error', 'Только хост может начать игру');
|
socket.emit('error', 'Только хост может начать игру');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (room.lobby.length < MIN_PLAYERS) {
|
// В режиме ИИ можно начать игру с одним игроком
|
||||||
|
if (!room.aiMode && room.lobby.length < MIN_PLAYERS) {
|
||||||
socket.emit('error', `Нужно минимум ${MIN_PLAYERS} игроков`);
|
socket.emit('error', `Нужно минимум ${MIN_PLAYERS} игроков`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user