This commit is contained in:
2026-01-26 02:02:28 +03:00
parent 6c145b1a61
commit 243ef9e235
3 changed files with 593 additions and 1 deletions

View File

@ -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('<strong>Вой:</strong> ' + escapeHtml(meta.battlecry));
if (meta.deathrattle) parts.push('<strong>Предсмертный хрип:</strong> ' + escapeHtml(meta.deathrattle));
abilEl.innerHTML = parts.length ? parts.join('<br>') : '';
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('<strong>Вой:</strong> ' + escapeHtml(meta.battlecry));
if (meta.deathrattle) parts.push('<strong>Предсмертный хрип:</strong> ' + escapeHtml(meta.deathrattle));
abilEl.innerHTML = parts.length ? parts.join('<br>') : '';
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) {

View File

@ -2,7 +2,10 @@
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Star Wars Hearthstone — PvP по сети</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@ -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;
}
}