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 @@ - + + + + Star Wars Hearthstone — PvP по сети diff --git a/public/styles.css b/public/styles.css index 575d03f..6771271 100644 --- a/public/styles.css +++ b/public/styles.css @@ -29,6 +29,7 @@ box-sizing: border-box; margin: 0; padding: 0; + -webkit-tap-highlight-color: transparent; } html, body { @@ -37,6 +38,10 @@ html, body { font-family: var(--font-body); background: var(--space); color: #e2e8f0; + touch-action: manipulation; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; } #app { @@ -532,6 +537,47 @@ html, body { z-index: 20; } +/* Touch feedback for mobile */ +.card-wrap.touch-active { + transform: rotate(var(--hand-rotate, 0deg)) translateY(-15px) scale(1.05); + z-index: 20; + transition: transform 0.1s ease; +} + +.card-wrap.swiping { + opacity: 0.8; + transform: rotate(var(--hand-rotate, 0deg)) translateY(-10px) scale(1.02); +} + +.card-wrap.long-pressed { + transform: rotate(var(--hand-rotate, 0deg)) translateY(-25px) scale(1.12); + z-index: 25; + box-shadow: 0 0 30px rgba(0,212,255,0.6); +} + +/* Prevent text selection on touch devices */ +.card-wrap, .btn, .tab, .deck-fan { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; +} + +/* Better touch targets */ +@media (hover: none) and (pointer: coarse) { + .card-wrap.in-hand:active:not(.disabled) { + transform: rotate(var(--hand-rotate, 0deg)) translateY(-15px) scale(1.05); + z-index: 20; + } + + .btn:active { + transform: scale(0.95); + } + + .card-wrap.attackable:active { + transform: scale(1.1); + } +} + .card { width: 100%; height: 100%; @@ -1551,6 +1597,7 @@ html, body { } } +/* Mobile optimizations */ @media (max-width: 900px) { .hand .card-wrap { margin-left: -48px; } .card-wrap { width: 154px; height: 214px; } @@ -1564,3 +1611,254 @@ html, body { line-height: 1.4; } } + +/* Touch device optimizations */ +@media (max-width: 768px) { + html, body { + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + } + + .screen { + padding: 1rem; + } + + .lobby-card { + padding: 1.5rem; + max-width: 100%; + } + + .game-header { + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + } + + .header-left, .header-center, .header-right { + width: 100%; + justify-content: space-between; + flex-wrap: wrap; + } + + .btn { + min-height: 44px; + padding: 0.85rem 1.5rem; + font-size: 1rem; + } + + .btn-end-turn { + min-height: 48px; + padding: 0.7rem 1.6rem; + font-size: 1.05rem; + } + + .btn-instructions, .btn-settings { + width: 40px; + height: 40px; + font-size: 1.2rem; + } + + .card-wrap { + width: 140px; + height: 196px; + } + + .board .card-wrap { + width: 100px; + height: 140px; + } + + .opponents .card-wrap { + width: 100px; + height: 140px; + } + + .hand .card-wrap { + margin-left: -40px; + } + + .hand .card-wrap:first-child { + margin-left: 0; + } + + .hand-container { + min-height: 180px; + padding: 0.5rem; + } + + .deck-fan { + width: 90px; + height: 126px; + } + + .deck-stack { + width: 85px; + height: 120px; + } + + .deck-stack .deck-card { + width: 85px; + height: 120px; + } + + .board { + min-height: 150px; + padding: 0.5rem; + } + + .your-board { + min-height: 110px; + } + + .opponent-block { + min-width: 140px; + padding: 0.5rem; + } + + .attack-mode { + bottom: 200px; + padding: 0.7rem 1.2rem; + font-size: 0.9rem; + } + + .attack-mode p { + font-size: 0.85rem; + } + + .modal { + max-width: 95vw; + padding: 1.5rem; + margin: 1rem; + } + + .cards-gallery-grid { + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 0.5rem; + } + + .gallery-card { + width: 100px !important; + height: 140px !important; + } + + .forge-deck-list { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 0.5rem; + } + + .forge-deck-card { + width: 80px !important; + height: 112px !important; + } + + .mana-display, .health-display { + font-size: 1rem; + } + + .turn-badge { + font-size: 0.85rem; + padding: 0.35rem 0.7rem; + } + + .turn-timer { + font-size: 0.85rem; + } + + .game-log { + font-size: 0.75rem; + max-height: 60px; + } + + .card-name { + font-size: 0.8rem; + } + + .card-text { + font-size: 0.68rem; + } + + .card-cost, .card-atk-hp { + font-size: 0.9rem; + } + + .card-btn-info { + width: 24px; + height: 24px; + font-size: 0.8rem; + } + + /* Touch feedback */ + .card-wrap.touch-active { + transform: scale(1.05); + transition: transform 0.1s; + } + + .card-wrap.swiping { + opacity: 0.7; + } + + /* Prevent text selection on touch */ + .card-wrap, .btn, .tab { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + } +} + +/* Small mobile devices */ +@media (max-width: 480px) { + .lobby-card { + padding: 1rem; + } + + .card-wrap { + width: 120px; + height: 168px; + } + + .board .card-wrap { + width: 85px; + height: 119px; + } + + .opponents .card-wrap { + width: 85px; + height: 119px; + } + + .hand .card-wrap { + margin-left: -35px; + } + + .hand-container { + min-height: 160px; + } + + .game-header { + padding: 0.5rem; + font-size: 0.9rem; + } + + .btn { + padding: 0.75rem 1.2rem; + font-size: 0.95rem; + } + + .attack-announcement { + font-size: 1.5rem; + padding: 1.5rem 2rem; + } + + .modal { + padding: 1rem; + } + + .cards-gallery-grid { + grid-template-columns: repeat(auto-fill, minmax(85px, 1fr)); + } + + .gallery-card { + width: 85px !important; + height: 119px !important; + } +}