diff --git a/public/game.js b/public/game.js
index ffdffb8..5241497 100644
--- a/public/game.js
+++ b/public/game.js
@@ -18,9 +18,42 @@
let prevGameState = null;
let combatTimeout = null;
const DEFAULT_PORT = 3542;
+
+ // Touch support variables
+ let touchState = {
+ startX: 0,
+ startY: 0,
+ startTime: 0,
+ target: null,
+ isSwipe: false,
+ minSwipeDistance: 50,
+ maxSwipeTime: 500,
+ longPressTimer: null,
+ longPressDelay: 500
+ };
const $ = (id) => document.getElementById(id);
const $all = (sel) => document.querySelectorAll(sel);
+
+ // Helper to detect if device is touch-enabled
+ const isTouchDevice = () => {
+ return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
+ };
+
+ // Helper to get touch coordinates
+ const getTouchCoords = (e) => {
+ if (e.touches && e.touches.length > 0) {
+ return { x: e.touches[0].clientX, y: e.touches[0].clientY };
+ }
+ return { x: e.clientX, y: e.clientY };
+ };
+
+ // Helper to find element at coordinates
+ const elementFromPoint = (x, y) => {
+ const el = document.elementFromPoint(x, y);
+ if (!el) return null;
+ return el.closest('.card-wrap, .drop-target, .hero-target, .spell-target, .hero-ability-target');
+ };
function spawnEffect(type, x, y) {
const layer = $('effects-layer');
@@ -761,6 +794,162 @@
return;
}
};
+
+ // Touch support for mobile
+ if (isTouchDevice()) {
+ wrap.ontouchstart = function (e) {
+ if (e.target.closest('.card-btn-info')) {
+ // Handle info button separately
+ const btn = e.target.closest('.card-btn-info');
+ if (btn) {
+ const cid = btn.dataset.cardId;
+ if (cid) {
+ const meta = cardDb[cid];
+ if (meta) {
+ const nameEl = $('card-info-name');
+ const metaEl = $('card-info-meta');
+ const textEl = $('card-info-text');
+ const abilEl = $('card-info-abilities');
+ const bioEl = $('card-info-bio');
+ if (nameEl) nameEl.textContent = meta.name || cid;
+ if (metaEl) {
+ if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0);
+ else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0);
+ }
+ if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); }
+ if (abilEl) {
+ var parts = [];
+ if (meta.battlecry) parts.push('Вой: ' + escapeHtml(meta.battlecry));
+ if (meta.deathrattle) parts.push('Предсмертный хрип: ' + escapeHtml(meta.deathrattle));
+ abilEl.innerHTML = parts.length ? parts.join('
') : '';
+ abilEl.classList.toggle('hidden', !parts.length);
+ }
+ if (bioEl) { bioEl.textContent = meta.bio || ''; bioEl.classList.toggle('hidden', !meta.bio); }
+ $('card-info-overlay')?.classList.remove('hidden');
+ }
+ }
+ }
+ return;
+ }
+
+ if (!isYourTurn || attackMode.active || heroAbilityMode.active) return;
+ if (!meta) return;
+
+ const touch = e.touches[0];
+ touchState.startX = touch.clientX;
+ touchState.startY = touch.clientY;
+ touchState.startTime = Date.now();
+ touchState.target = wrap;
+ touchState.isSwipe = false;
+ wrap.classList.add('touch-active');
+
+ // Long press for card info
+ touchState.longPressTimer = setTimeout(function() {
+ if (touchState.target === wrap && meta) {
+ const nameEl = $('card-info-name');
+ const metaEl = $('card-info-meta');
+ const textEl = $('card-info-text');
+ const abilEl = $('card-info-abilities');
+ const bioEl = $('card-info-bio');
+ if (nameEl) nameEl.textContent = meta.name || wrap.dataset.cardId;
+ if (metaEl) {
+ if (meta.type === 'spell') metaEl.textContent = 'Заклинание · Мана: ' + (meta.cost ?? 0);
+ else metaEl.textContent = 'Минайон · Мана: ' + (meta.cost ?? 0) + ', Атака: ' + (meta.attack ?? 0) + ', Здоровье: ' + (meta.health ?? 0);
+ }
+ if (textEl) { textEl.textContent = meta.text || ''; textEl.classList.toggle('hidden', !meta.text); }
+ if (abilEl) {
+ var parts = [];
+ if (meta.battlecry) parts.push('Вой: ' + escapeHtml(meta.battlecry));
+ if (meta.deathrattle) parts.push('Предсмертный хрип: ' + escapeHtml(meta.deathrattle));
+ abilEl.innerHTML = parts.length ? parts.join('
') : '';
+ abilEl.classList.toggle('hidden', !parts.length);
+ }
+ if (bioEl) { bioEl.textContent = meta.bio || ''; bioEl.classList.toggle('hidden', !meta.bio); }
+ $('card-info-overlay')?.classList.remove('hidden');
+ wrap.classList.add('long-pressed');
+ }
+ }, touchState.longPressDelay);
+ };
+
+ wrap.ontouchmove = function (e) {
+ if (!touchState.target || touchState.target !== wrap) return;
+
+ // Cancel long press if moved
+ if (touchState.longPressTimer) {
+ clearTimeout(touchState.longPressTimer);
+ touchState.longPressTimer = null;
+ }
+
+ const touch = e.touches[0];
+ const dx = touch.clientX - touchState.startX;
+ const dy = touch.clientY - touchState.startY;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance > touchState.minSwipeDistance) {
+ touchState.isSwipe = true;
+ wrap.classList.add('swiping');
+ wrap.classList.remove('long-pressed');
+ }
+ };
+
+ wrap.ontouchend = function (e) {
+ if (!touchState.target || touchState.target !== wrap) return;
+
+ // Cancel long press timer
+ if (touchState.longPressTimer) {
+ clearTimeout(touchState.longPressTimer);
+ touchState.longPressTimer = null;
+ }
+
+ wrap.classList.remove('touch-active', 'swiping', 'long-pressed');
+
+ const touch = e.changedTouches[0];
+ const dx = touch.clientX - touchState.startX;
+ const dy = touch.clientY - touchState.startY;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ const time = Date.now() - touchState.startTime;
+
+ // If it was a swipe or long press, don't trigger card play
+ if (touchState.isSwipe || distance > touchState.minSwipeDistance || time > touchState.longPressDelay) {
+ touchState.target = null;
+ return;
+ }
+
+ // Tap - trigger card play
+ if (time < touchState.maxSwipeTime && distance < 10 && you.mana >= (meta?.cost || 0)) {
+ if (meta?.type === 'minion' && (you.board?.length ?? 0) < 7) {
+ if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
+ var r = wrap.getBoundingClientRect();
+ spawnEffect('play', r.left + r.width / 2, r.top + r.height / 2);
+ wrap.querySelector('.card')?.classList.add('play-anim');
+ socket.emit('playCard', { handIndex: handIndex, boardPos: you.board.length });
+ setTimeout(function () { wrap.querySelector('.card')?.classList.remove('play-anim'); }, 500);
+ } else if (meta?.type === 'spell') {
+ if (spellMode.active || heroAbilityMode.active) return;
+ var needTarget = meta.spellTarget && meta.spellTarget !== 'none';
+ if (!needTarget) {
+ if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
+ socket.emit('playSpell', { handIndex: handIndex });
+ } else {
+ spellMode = { active: true, handIndex: handIndex, cardId: wrap.dataset.cardId, spellTarget: meta.spellTarget };
+ $('spell-mode')?.classList.remove('hidden');
+ renderGame(state);
+ }
+ }
+ }
+
+ touchState.target = null;
+ };
+
+ wrap.ontouchcancel = function () {
+ if (touchState.longPressTimer) {
+ clearTimeout(touchState.longPressTimer);
+ touchState.longPressTimer = null;
+ }
+ wrap.classList.remove('touch-active', 'swiping', 'long-pressed');
+ touchState.target = null;
+ };
+ }
});
$all('.card-btn-info').forEach(function (btn) {
@@ -805,6 +994,43 @@
$('attack-mode')?.classList.remove('hidden');
renderGame(state);
};
+
+ // Touch support for attack
+ if (isTouchDevice()) {
+ wrap.ontouchstart = function (e) {
+ if (!isYourTurn || attackMode.active || spellMode.active || heroAbilityMode.active) return;
+ const playerIndex = parseInt(wrap.dataset.playerIndex, 10);
+ const boardIndex = parseInt(wrap.dataset.boardIndex, 10);
+ if (playerIndex !== state.yourIndex) return;
+
+ const touch = e.touches[0];
+ touchState.startX = touch.clientX;
+ touchState.startY = touch.clientY;
+ touchState.startTime = Date.now();
+ touchState.target = wrap;
+ };
+
+ wrap.ontouchend = function (e) {
+ if (!touchState.target || touchState.target !== wrap) return;
+ const touch = e.changedTouches[0];
+ const dx = touch.clientX - touchState.startX;
+ const dy = touch.clientY - touchState.startY;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ const time = Date.now() - touchState.startTime;
+
+ if (time < touchState.maxSwipeTime && distance < 20) {
+ const playerIndex = parseInt(wrap.dataset.playerIndex, 10);
+ const boardIndex = parseInt(wrap.dataset.boardIndex, 10);
+ if (playerIndex === state.yourIndex) {
+ attackMode = { active: true, attackerPlayer: playerIndex, attackerBoard: boardIndex };
+ $('attack-mode')?.classList.remove('hidden');
+ renderGame(state);
+ }
+ }
+
+ touchState.target = null;
+ };
+ }
});
$all('.card-wrap.targetable, .card-wrap.hero-target').forEach((wrap) => {
@@ -823,6 +1049,26 @@
attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 };
$('attack-mode')?.classList.add('hidden');
};
+
+ // Touch support for target selection
+ if (isTouchDevice()) {
+ wrap.ontouchstart = function (e) {
+ if (spellMode.active || heroAbilityMode.active) return;
+ if (!attackMode.active) return;
+ e.preventDefault();
+ const targetPlayer = parseInt(wrap.dataset.playerIndex ?? wrap.dataset.dropPlayer, 10);
+ const targetBoard = parseInt(wrap.dataset.boardIndex ?? wrap.dataset.dropBoard, 10);
+ if (typeof window.Sounds !== 'undefined') window.Sounds.attack();
+ socket.emit('attack', {
+ attackerPlayerIndex: state.yourIndex,
+ attackerBoardIndex: attackMode.attackerBoard,
+ targetPlayerIndex: targetPlayer,
+ targetBoardIndex: targetBoard,
+ });
+ attackMode = { active: false, attackerPlayer: -1, attackerBoard: -1 };
+ $('attack-mode')?.classList.add('hidden');
+ };
+ }
});
$all('.spell-target, .drop-target-hero.spell-target').forEach(function (el) {
@@ -837,6 +1083,22 @@
$('spell-mode')?.classList.add('hidden');
renderGame(state);
};
+
+ // Touch support for spell targets
+ if (isTouchDevice()) {
+ el.ontouchstart = function (e) {
+ if (!spellMode.active) return;
+ e.preventDefault();
+ e.stopPropagation();
+ var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10);
+ var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10);
+ if (typeof window.Sounds !== 'undefined') window.Sounds.playCard();
+ socket.emit('playSpell', { handIndex: spellMode.handIndex, targetPlayerIndex: tp, targetBoardIndex: tb });
+ spellMode = { active: false, handIndex: -1, cardId: '', spellTarget: '' };
+ $('spell-mode')?.classList.add('hidden');
+ renderGame(state);
+ };
+ }
});
$all('.hero-ability-target, .drop-target-hero.hero-ability-target').forEach(function (el) {
@@ -851,6 +1113,22 @@
$('hero-ability-mode')?.classList.add('hidden');
renderGame(state);
};
+
+ // Touch support for hero ability targets
+ if (isTouchDevice()) {
+ el.ontouchstart = function (e) {
+ if (!heroAbilityMode.active) return;
+ e.preventDefault();
+ e.stopPropagation();
+ var tp = parseInt(el.dataset.dropPlayer ?? el.dataset.playerIndex, 10);
+ var tb = parseInt(el.dataset.dropBoard ?? el.dataset.boardIndex, 10);
+ if (typeof window.Sounds !== 'undefined') window.Sounds.attack();
+ socket.emit('heroAbility', { targetPlayerIndex: tp, targetBoardIndex: tb });
+ heroAbilityMode = { active: false };
+ $('hero-ability-mode')?.classList.add('hidden');
+ renderGame(state);
+ };
+ }
});
var heroAbilityBtn = $('btn-hero-ability');
@@ -927,9 +1205,22 @@
socket.emit('drawCard');
};
deckEl.style.cursor = 'pointer';
+
+ // Touch support for deck
+ if (isTouchDevice()) {
+ deckEl.ontouchstart = function (e) {
+ e.preventDefault();
+ var r = deckEl.getBoundingClientRect();
+ spawnEffect('draw', r.left + r.width / 2, r.top + r.height / 2);
+ socket.emit('drawCard');
+ };
+ }
} else if (deckEl) {
deckEl.onclick = null;
deckEl.style.cursor = 'default';
+ if (isTouchDevice()) {
+ deckEl.ontouchstart = null;
+ }
}
$all('.card-wrap.attackable[draggable="true"]').forEach(function (wrap) {
diff --git a/public/index.html b/public/index.html
index 8a74f73..18df195 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2,7 +2,10 @@