Initial commit

This commit is contained in:
2026-03-13 16:01:39 -04:00
commit 27e8c9a6fe
22 changed files with 8681 additions and 0 deletions

1
client/dist/assets/index-DLWfvwrg.css vendored Normal file

File diff suppressed because one or more lines are too long

40
client/dist/assets/index-FiZX5YT8.js vendored Normal file

File diff suppressed because one or more lines are too long

14
client/dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arcane Duels</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#x2694;</text></svg>" />
<script type="module" crossorigin src="/assets/index-FiZX5YT8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DLWfvwrg.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arcane Duels</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#x2694;</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1803
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
client/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "arcane-duels-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"socket.io-client": "^4.7.4"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}

981
client/src/App.css Normal file
View File

@@ -0,0 +1,981 @@
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:wght@400;500;600&display=swap');
:root {
--bg-dark: #0d0f14;
--bg-card: #1a1d26;
--bg-surface: #14161e;
--bg-hover: #1f2233;
--text-primary: #e8e6e3;
--text-secondary: #9a9a9a;
--text-muted: #666;
--accent-gold: #d4a843;
--accent-glow: #f0d060;
--border: #2a2d3a;
--danger: #cc4422;
--success: #44aa55;
--radiance: #f0d060;
--tide: #4499dd;
--shadow: #8a6098;
--flame: #ee5533;
--growth: #55aa44;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
overflow: hidden;
}
.app {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* ========== TITLE SCREEN ========== */
.title-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: radial-gradient(ellipse at center, #1a1530 0%, var(--bg-dark) 70%);
}
.game-title {
font-family: 'Cinzel', serif;
font-size: 4.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-gold), #fff8e0, var(--accent-gold));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-shadow: none;
letter-spacing: 4px;
margin-bottom: 8px;
}
.game-subtitle {
font-size: 1.1rem;
color: var(--text-secondary);
margin-bottom: 48px;
letter-spacing: 2px;
}
.name-form {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.name-input {
width: 320px;
padding: 14px 20px;
font-size: 1.1rem;
border: 2px solid var(--border);
border-radius: 8px;
background: var(--bg-surface);
color: var(--text-primary);
outline: none;
transition: border-color 0.2s;
text-align: center;
font-family: 'Inter', sans-serif;
}
.name-input:focus {
border-color: var(--accent-gold);
}
/* ========== BUTTONS ========== */
.btn {
padding: 10px 24px;
font-size: 0.95rem;
font-weight: 600;
border: 2px solid var(--border);
border-radius: 6px;
background: var(--bg-surface);
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s;
font-family: 'Inter', sans-serif;
}
.btn:hover {
background: var(--bg-hover);
border-color: var(--accent-gold);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: linear-gradient(135deg, #3a3020, #2a2518);
border-color: var(--accent-gold);
color: var(--accent-glow);
}
.btn-primary:hover {
background: linear-gradient(135deg, #4a4030, #3a3020);
}
.btn-small {
padding: 6px 14px;
font-size: 0.8rem;
}
.btn-back {
border: none;
background: none;
color: var(--text-secondary);
padding: 8px 12px;
}
.btn-back:hover {
color: var(--text-primary);
background: none;
}
.btn-join {
padding: 8px 20px;
font-size: 0.85rem;
}
.btn-pass {
background: linear-gradient(135deg, #1a2a1a, #152015);
border-color: var(--success);
color: #88dd88;
}
.btn-target {
background: linear-gradient(135deg, #2a1a1a, #201515);
border-color: var(--danger);
color: #ee8888;
animation: pulse-border 1s infinite;
}
@keyframes pulse-border {
0%, 100% { box-shadow: 0 0 0 0 rgba(204, 68, 34, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(204, 68, 34, 0.1); }
}
.btn-start {
width: 100%;
padding: 16px;
font-size: 1.1rem;
margin-top: 24px;
}
/* ========== LOBBY ========== */
.lobby {
height: 100vh;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.lobby-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
}
.game-title-small {
font-family: 'Cinzel', serif;
font-size: 1.5rem;
color: var(--accent-gold);
}
.player-badge {
color: var(--text-secondary);
font-size: 0.9rem;
}
.lobby-content {
flex: 1;
max-width: 700px;
margin: 0 auto;
padding: 32px 24px;
width: 100%;
}
.create-room {
margin-bottom: 40px;
}
.create-room h3, .room-list h3 {
font-family: 'Cinzel', serif;
font-size: 1.2rem;
color: var(--accent-gold);
margin-bottom: 16px;
}
.create-room-form {
display: flex;
gap: 12px;
}
.room-input {
flex: 1;
padding: 12px 16px;
border: 2px solid var(--border);
border-radius: 6px;
background: var(--bg-surface);
color: var(--text-primary);
font-size: 0.95rem;
outline: none;
font-family: 'Inter', sans-serif;
}
.room-input:focus {
border-color: var(--accent-gold);
}
.room-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-surface);
margin-bottom: 8px;
transition: border-color 0.2s;
}
.room-item:hover {
border-color: var(--accent-gold);
}
.room-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.room-name {
font-weight: 600;
}
.room-meta {
font-size: 0.85rem;
color: var(--text-secondary);
}
.no-rooms {
padding: 40px;
text-align: center;
color: var(--text-muted);
border: 1px dashed var(--border);
border-radius: 8px;
}
/* ========== ROOM VIEW ========== */
.room-view {
max-width: 800px;
margin: 0 auto;
padding: 32px 24px;
}
.room-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
}
.room-header h2 {
font-family: 'Cinzel', serif;
color: var(--accent-gold);
}
.room-players {
margin-bottom: 32px;
}
.room-players h3 {
font-family: 'Cinzel', serif;
color: var(--accent-gold);
margin-bottom: 12px;
}
.room-player {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
background: var(--bg-surface);
}
.room-player.ready {
border-color: var(--success);
}
.room-player.waiting {
color: var(--text-muted);
border-style: dashed;
justify-content: center;
}
.player-status {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* ========== DECK SELECTION ========== */
.deck-selection h3 {
font-family: 'Cinzel', serif;
color: var(--accent-gold);
margin-bottom: 16px;
}
.deck-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.deck-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
border: 2px solid var(--border);
border-radius: 10px;
background: var(--bg-surface);
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.deck-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.deck-card.selected {
border-color: var(--accent-gold);
box-shadow: 0 0 20px rgba(212, 168, 67, 0.3);
}
.deck-card.color-radiance:hover, .deck-card.color-radiance.selected { border-color: var(--radiance); box-shadow: 0 0 20px rgba(240, 208, 96, 0.2); }
.deck-card.color-tide:hover, .deck-card.color-tide.selected { border-color: var(--tide); box-shadow: 0 0 20px rgba(68, 153, 221, 0.2); }
.deck-card.color-shadow:hover, .deck-card.color-shadow.selected { border-color: var(--shadow); box-shadow: 0 0 20px rgba(138, 96, 152, 0.2); }
.deck-card.color-flame:hover, .deck-card.color-flame.selected { border-color: var(--flame); box-shadow: 0 0 20px rgba(238, 85, 51, 0.2); }
.deck-card.color-growth:hover, .deck-card.color-growth.selected { border-color: var(--growth); box-shadow: 0 0 20px rgba(85, 170, 68, 0.2); }
.deck-symbol {
font-size: 2.5rem;
}
.deck-name {
font-family: 'Cinzel', serif;
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
}
.deck-desc {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* ========== GAME BOARD ========== */
.game-board {
display: flex;
flex-direction: column;
height: 100vh;
background: radial-gradient(ellipse at center, #111520 0%, var(--bg-dark) 100%);
}
.game-top-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 12px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
z-index: 10;
}
.phase-tracker {
display: flex;
gap: 2px;
flex: 1;
justify-content: center;
flex-wrap: wrap;
}
.phase-pip {
padding: 3px 8px;
font-size: 0.7rem;
border-radius: 3px;
color: var(--text-muted);
background: transparent;
transition: all 0.2s;
}
.phase-pip.active {
background: var(--accent-gold);
color: #000;
font-weight: 700;
}
.phase-pip.active.my-turn {
background: var(--success);
}
.turn-info {
font-size: 0.8rem;
color: var(--text-secondary);
white-space: nowrap;
}
/* ========== PLAYER AREAS ========== */
.opponent-area, .my-area {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 4px 12px;
}
.opponent-area {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.player-info {
display: flex;
align-items: center;
gap: 16px;
padding: 6px 12px;
font-size: 0.85rem;
flex-shrink: 0;
}
.life-total {
font-size: 1.2rem;
font-weight: 700;
color: #ee5555;
}
.hand-count, .library-count {
color: var(--text-secondary);
font-size: 0.8rem;
}
.mana-pool {
display: flex;
gap: 8px;
margin-left: auto;
}
.mana {
font-size: 0.85rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
}
.mana-radiance { color: var(--radiance); }
.mana-tide { color: var(--tide); }
.mana-shadow { color: var(--shadow); }
.mana-flame { color: var(--flame); }
.mana-growth { color: var(--growth); }
.mana-colorless { color: var(--text-secondary); }
/* ========== BATTLEFIELD ========== */
.battlefield {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow-x: auto;
}
.battlefield.opponent {
flex-direction: column-reverse;
}
.bf-row {
display: flex;
gap: 6px;
align-items: center;
min-height: 36px;
flex-wrap: wrap;
}
.bf-creatures {
flex: 1;
justify-content: center;
align-content: center;
}
.bf-lands {
justify-content: center;
}
.bf-others {
justify-content: center;
}
.bf-empty {
color: var(--text-muted);
font-size: 0.8rem;
font-style: italic;
}
.bf-creature-slot {
position: relative;
}
.bf-creature-slot.attacking .card {
transform: translateY(-8px);
box-shadow: 0 0 12px rgba(238, 85, 51, 0.5);
}
.bf-creature-slot.blocking .card {
box-shadow: 0 0 12px rgba(68, 153, 221, 0.5);
}
.combat-badge {
position: absolute;
top: -4px;
right: -4px;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.6rem;
font-weight: 700;
z-index: 5;
}
.attack-badge {
background: var(--flame);
color: #fff;
}
.block-badge {
background: var(--tide);
color: #fff;
}
/* ========== COMBAT ZONE ========== */
.combat-zone {
flex-shrink: 0;
min-height: 0;
}
.combat-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px;
background: rgba(212, 168, 67, 0.08);
border-top: 1px solid rgba(212, 168, 67, 0.2);
border-bottom: 1px solid rgba(212, 168, 67, 0.2);
font-size: 0.85rem;
}
/* ========== HAND ========== */
.hand-area {
flex-shrink: 0;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid var(--border);
padding: 6px 12px 8px;
display: flex;
align-items: center;
gap: 8px;
}
.hand {
display: flex;
gap: 4px;
overflow-x: auto;
flex: 1;
padding: 4px 0;
}
.hand-slot {
flex-shrink: 0;
}
.hand-actions {
flex-shrink: 0;
}
/* ========== CARDS ========== */
.card {
width: 130px;
min-height: 180px;
border: 2px solid var(--card-border, var(--border));
border-radius: 8px;
background: var(--card-bg, var(--bg-card));
display: flex;
flex-direction: column;
overflow: hidden;
cursor: pointer;
transition: all 0.15s;
position: relative;
font-size: 0.75rem;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
z-index: 10;
}
.card.card-small {
width: 100px;
min-height: 130px;
font-size: 0.65rem;
}
.card.tapped {
transform: rotate(90deg);
margin: 10px 15px;
}
.card.tapped:hover {
transform: rotate(90deg) translateY(-4px);
}
.card.selected {
box-shadow: 0 0 0 3px var(--accent-gold), 0 0 20px rgba(212, 168, 67, 0.4);
transform: translateY(-8px);
}
.card.selectable {
cursor: pointer;
}
.card.selectable:hover {
box-shadow: 0 0 0 2px var(--accent-gold);
}
.card.summoning-sick {
opacity: 0.75;
}
.card-facedown {
width: 100px;
min-height: 130px;
border: 2px solid var(--border);
border-radius: 8px;
background: linear-gradient(135deg, #1a1530, #2a1a40);
display: flex;
align-items: center;
justify-content: center;
}
.card-back {
font-family: 'Cinzel', serif;
font-size: 0.7rem;
color: var(--accent-gold);
text-align: center;
transform: rotate(-30deg);
opacity: 0.6;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 6px 8px 4px;
gap: 4px;
}
.card-name {
font-weight: 700;
font-size: 0.7em;
line-height: 1.2;
flex: 1;
}
.card-cost {
font-size: 0.75em;
white-space: nowrap;
flex-shrink: 0;
}
.card-art {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
flex: 1;
background: rgba(0, 0, 0, 0.15);
min-height: 40px;
overflow: hidden;
}
.card-type-line {
padding: 3px 8px;
font-size: 0.6em;
color: inherit;
opacity: 0.7;
border-top: 1px solid rgba(0, 0, 0, 0.15);
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
}
.card-text {
padding: 4px 8px;
flex: 0;
font-size: 0.6em;
line-height: 1.3;
}
.card-keywords {
font-weight: 700;
font-style: italic;
margin-bottom: 2px;
}
.card-flavor {
font-style: italic;
opacity: 0.6;
margin-top: 2px;
font-size: 0.9em;
}
.card-pt {
padding: 4px 8px;
text-align: right;
font-weight: 700;
font-size: 0.9em;
}
.card-pt .damaged {
color: var(--danger);
}
/* ========== MODALS ========== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-surface);
border: 2px solid var(--accent-gold);
border-radius: 12px;
padding: 32px;
max-width: 400px;
text-align: center;
}
.modal h3 {
font-family: 'Cinzel', serif;
color: var(--accent-gold);
margin-bottom: 20px;
}
.x-picker {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
}
.x-picker button {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--bg-card);
color: var(--text-primary);
font-size: 1.2rem;
cursor: pointer;
}
.x-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent-gold);
min-width: 40px;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.mana-choice-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mana-choice-btn {
padding: 12px 16px !important;
font-size: 1rem !important;
}
.mana-choice-btn.color-radiance { border-color: var(--radiance) !important; color: var(--radiance) !important; }
.mana-choice-btn.color-tide { border-color: var(--tide) !important; color: var(--tide) !important; }
.mana-choice-btn.color-shadow { border-color: var(--shadow) !important; color: var(--shadow) !important; }
.mana-choice-btn.color-flame { border-color: var(--flame) !important; color: var(--flame) !important; }
.mana-choice-btn.color-growth { border-color: var(--growth) !important; color: var(--growth) !important; }
.mana-choice-btn.color-colorless { border-color: var(--text-secondary) !important; }
/* ========== TARGET BANNER ========== */
.target-banner {
position: fixed;
top: 50px;
left: 50%;
transform: translateX(-50%);
background: rgba(204, 68, 34, 0.9);
color: #fff;
padding: 10px 20px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
z-index: 50;
animation: slide-down 0.2s ease-out;
}
@keyframes slide-down {
from { transform: translateX(-50%) translateY(-20px); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
/* ========== GAME LOG ========== */
.game-log-panel {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 320px;
background: var(--bg-surface);
border-left: 1px solid var(--border);
z-index: 50;
display: flex;
flex-direction: column;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.log-header h3 {
font-family: 'Cinzel', serif;
color: var(--accent-gold);
}
.log-entries {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.log-entry {
padding: 6px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
font-size: 0.8rem;
display: flex;
gap: 8px;
}
.log-turn {
color: var(--accent-gold);
font-weight: 600;
flex-shrink: 0;
}
.log-msg {
color: var(--text-secondary);
}
/* ========== GAME OVER ========== */
.game-over-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.game-over-modal {
text-align: center;
padding: 48px;
}
.game-over-modal h1 {
font-family: 'Cinzel', serif;
font-size: 3rem;
background: linear-gradient(135deg, var(--accent-gold), #fff8e0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 16px;
}
.game-over-modal p {
color: var(--text-secondary);
margin-bottom: 32px;
font-size: 1.1rem;
}
/* ========== ERROR TOAST ========== */
.error-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(204, 68, 34, 0.95);
color: #fff;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
z-index: 300;
animation: toast-in 0.2s ease-out;
}
@keyframes toast-in {
from { transform: translateX(-50%) translateY(20px); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
/* ========== COLOR-SPECIFIC CARD BORDERS ========== */
.card.color-radiance { border-color: var(--radiance); }
.card.color-tide { border-color: var(--tide); }
.card.color-shadow { border-color: var(--shadow); background: #1a1520; color: #ddd; }
.card.color-flame { border-color: var(--flame); }
.card.color-growth { border-color: var(--growth); }
.card.color-colorless { border-color: #888; }

143
client/src/App.jsx Normal file
View File

@@ -0,0 +1,143 @@
import React, { useState, useEffect, useCallback } from 'react';
import socket from './socket';
import Lobby from './components/Lobby';
import GameBoard from './components/GameBoard';
const SCREENS = { NAME: 'name', LOBBY: 'lobby', ROOM: 'room', GAME: 'game' };
export default function App() {
const [screen, setScreen] = useState(SCREENS.NAME);
const [playerName, setPlayerName] = useState('');
const [playerId, setPlayerId] = useState(null);
const [rooms, setRooms] = useState([]);
const [currentRoom, setCurrentRoom] = useState(null);
const [gameState, setGameState] = useState(null);
const [gameId, setGameId] = useState(null);
const [error, setError] = useState(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
socket.connect();
socket.on('connect', () => {
setConnected(true);
setPlayerId(socket.id);
});
socket.on('disconnect', () => setConnected(false));
socket.on('nameSet', (data) => {
setPlayerId(data.id);
setScreen(SCREENS.LOBBY);
});
socket.on('roomsUpdated', (data) => setRooms(data));
socket.on('roomCreated', (room) => {
setCurrentRoom(room);
setScreen(SCREENS.ROOM);
});
socket.on('roomJoined', (room) => {
setCurrentRoom(room);
setScreen(SCREENS.ROOM);
});
socket.on('roomUpdated', (room) => setCurrentRoom(room));
socket.on('gameStarted', (data) => {
setGameId(data.gameId);
setGameState(data.state);
setScreen(SCREENS.GAME);
});
socket.on('gameStateUpdated', (state) => setGameState(state));
socket.on('error', (data) => {
setError(data.message);
setTimeout(() => setError(null), 3000);
});
socket.on('actionError', (data) => {
setError(data.message);
setTimeout(() => setError(null), 3000);
});
socket.on('playerDisconnected', (data) => {
setError(`${data.playerName} disconnected`);
setTimeout(() => setError(null), 3000);
});
socket.on('needsResponse', () => {});
return () => {
socket.removeAllListeners();
socket.disconnect();
};
}, []);
const handleSetName = useCallback((e) => {
e.preventDefault();
if (!playerName.trim()) return;
socket.emit('setName', playerName.trim());
}, [playerName]);
const handleBackToLobby = useCallback(() => {
socket.emit('leaveRoom');
setCurrentRoom(null);
setGameState(null);
setGameId(null);
setScreen(SCREENS.LOBBY);
socket.emit('getRooms');
}, []);
if (screen === SCREENS.NAME) {
return (
<div className="app">
<div className="title-screen">
<h1 className="game-title">Arcane Duels</h1>
<p className="game-subtitle">A game of strategy, mana, and might</p>
<form onSubmit={handleSetName} className="name-form">
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Enter your name..."
className="name-input"
maxLength={20}
autoFocus
/>
<button type="submit" className="btn btn-primary" disabled={!connected}>
{connected ? 'Enter the Arena' : 'Connecting...'}
</button>
</form>
</div>
{error && <div className="error-toast">{error}</div>}
</div>
);
}
if (screen === SCREENS.GAME && gameState) {
return (
<div className="app">
<GameBoard
gameState={gameState}
playerId={playerId}
onLeave={handleBackToLobby}
/>
{error && <div className="error-toast">{error}</div>}
</div>
);
}
return (
<div className="app">
<Lobby
playerId={playerId}
playerName={playerName}
rooms={rooms}
currentRoom={currentRoom}
screen={screen}
onBackToLobby={handleBackToLobby}
/>
{error && <div className="error-toast">{error}</div>}
</div>
);
}

View File

@@ -0,0 +1,128 @@
import React from 'react';
import CardArt from './CardArt';
const COLOR_MAP = {
radiance: { bg: '#fff8e7', border: '#d4a843', accent: '#f0d060' },
tide: { bg: '#e7f0ff', border: '#4477bb', accent: '#6699dd' },
shadow: { bg: '#2a2a2a', border: '#666', accent: '#8a7090', text: '#ddd' },
flame: { bg: '#fff0e7', border: '#cc4422', accent: '#ee6644' },
growth: { bg: '#eaf5e7', border: '#448833', accent: '#66aa55' },
colorless: { bg: '#f0f0f0', border: '#999', accent: '#bbb' },
};
const KEYWORD_DISPLAY = {
swift: 'Swift',
vigilant: 'Vigilant',
soaring: 'Soaring',
guardian: 'Guardian',
fortified: 'Fortified',
draining: 'Draining',
overwhelming: 'Overwhelming',
venomous: 'Venomous',
reaching: 'Reaching',
};
function formatManaCost(cost) {
if (!cost) return '';
const parts = [];
if (cost.colorless) parts.push(String(cost.colorless));
const symbols = { radiance: '\u2600', tide: '\u{1F30A}', shadow: '\u{1F480}', flame: '\u{1F525}', growth: '\u{1F33F}' };
for (const [color, sym] of Object.entries(symbols)) {
for (let i = 0; i < (cost[color] || 0); i++) parts.push(sym);
}
return parts.join(' ');
}
export default function Card({
card,
instance,
onClick,
selected,
selectable,
small,
faceDown,
zone,
}) {
if (faceDown) {
return (
<div className="card card-facedown" onClick={onClick}>
<div className="card-back">Arcane Duels</div>
</div>
);
}
const data = instance?.cardData || card;
if (!data) return null;
const colors = COLOR_MAP[data.color] || COLOR_MAP.colorless;
const isCreature = data.type === 'creature';
const isLand = data.type === 'land';
const tapped = instance?.tapped;
const keywords = data.keywords || [];
const power = instance ? instance.effectivePower : data.power;
const toughness = instance ? (instance.effectiveToughness - (instance.damage || 0)) : data.toughness;
const cardClass = [
'card',
`card-${data.type}`,
`color-${data.color}`,
tapped ? 'tapped' : '',
selected ? 'selected' : '',
selectable ? 'selectable' : '',
small ? 'card-small' : '',
zone ? `zone-${zone}` : '',
instance?.summoningSickness ? 'summoning-sick' : '',
].filter(Boolean).join(' ');
const style = {
'--card-bg': colors.bg,
'--card-border': colors.border,
'--card-accent': colors.accent,
color: colors.text || '#222',
};
return (
<div className={cardClass} style={style} onClick={onClick}>
<div className="card-header">
<span className="card-name">{data.name}</span>
{!isLand && <span className="card-cost">{formatManaCost(data.cost)}</span>}
</div>
<div className="card-art">
<CardArt
cardName={data.name}
color={data.color}
type={data.type}
width={small ? 100 : 130}
height={small ? 45 : 70}
/>
</div>
<div className="card-type-line">
{data.type.charAt(0).toUpperCase() + data.type.slice(1)}
{data.subtype ? ` \u2014 ${data.subtype}` : ''}
</div>
<div className="card-text">
{keywords.length > 0 && (
<div className="card-keywords">
{keywords.map((k) => KEYWORD_DISPLAY[k] || k).join(', ')}
</div>
)}
{data.flavor && !small && (
<div className="card-flavor">{data.flavor}</div>
)}
</div>
{isCreature && (
<div className="card-pt">
<span className={instance?.damage > 0 ? 'damaged' : ''}>
{power}/{toughness}
</span>
</div>
)}
</div>
);
}
export { formatManaCost, COLOR_MAP };

View File

@@ -0,0 +1,940 @@
import React, { useRef, useEffect, memo } from 'react';
function hashString(str) {
let h = 5381;
for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
return h;
}
function mulberry32(seed) {
let s = seed | 0;
return () => {
s = (s + 0x6D2B79F5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function seedRng(name) {
const fn = mulberry32(hashString(name || 'card'));
fn(); fn(); fn();
return {
f: () => fn(),
r: (a, b) => a + fn() * (b - a),
i: (a, b) => Math.floor(a + fn() * (b - a)),
pick: (arr) => arr[Math.floor(fn() * arr.length)],
};
}
const PAL = {
radiance: {
bg: ['#7a6420', '#b89530', '#d4a843', '#f0d060', '#fff8e7'],
fills: ['#f0d060', '#e8c040', '#d4a843', '#ffeebb', '#c49840'],
glow: '#fffce8',
deep: '#5a4010',
},
tide: {
bg: ['#0d1825', '#1a3355', '#2266aa', '#4499dd'],
fills: ['#3388cc', '#55aaee', '#4499dd', '#77ccff', '#2266aa'],
glow: '#cceeff',
deep: '#08101a',
},
shadow: {
bg: ['#05020a', '#0f0a18', '#1a1520', '#2a1530'],
fills: ['#5a3070', '#8a6098', '#44224d', '#6b4880', '#3a1845'],
glow: '#c8a0d8',
deep: '#030108',
},
flame: {
bg: ['#180600', '#331000', '#662200', '#993300'],
fills: ['#ee5533', '#ff8844', '#cc3311', '#ffbb33', '#ff6622'],
glow: '#ffeecc',
deep: '#100300',
},
growth: {
bg: ['#081a05', '#153010', '#224411', '#336622'],
fills: ['#55aa44', '#88cc66', '#44884a', '#66bb55', '#339933'],
glow: '#d4eec8',
deep: '#040d02',
},
colorless: {
bg: ['#303030', '#505050', '#707070', '#909090'],
fills: ['#aaaaaa', '#bbbbbb', '#999999', '#cccccc', '#888888'],
glow: '#e8e8ee',
deep: '#202020',
},
};
function drawRadiance(ctx, R, p, w, h) {
const fx = R.r(w * 0.3, w * 0.7);
const fy = R.r(h * 0.15, h * 0.45);
const bg = ctx.createRadialGradient(fx, fy, 0, fx, fy, Math.max(w, h) * 1.1);
bg.addColorStop(0, '#fff8e7');
bg.addColorStop(0.25, '#f5e6b8');
bg.addColorStop(0.5, '#d4a843');
bg.addColorStop(0.8, '#b89530');
bg.addColorStop(1, '#7a6420');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const rayCount = R.i(7, 14);
for (let i = 0; i < rayCount; i++) {
const angle = R.r(0, Math.PI * 2);
const len = R.r(w * 0.5, Math.max(w, h) * 1.3);
const spread = R.r(0.04, 0.14);
ctx.save();
ctx.beginPath();
ctx.moveTo(fx, fy);
ctx.lineTo(fx + Math.cos(angle - spread) * len, fy + Math.sin(angle - spread) * len);
ctx.lineTo(fx + Math.cos(angle + spread) * len, fy + Math.sin(angle + spread) * len);
ctx.closePath();
const rg = ctx.createLinearGradient(fx, fy, fx + Math.cos(angle) * len, fy + Math.sin(angle) * len);
rg.addColorStop(0, `rgba(255,248,220,${R.r(0.35, 0.65)})`);
rg.addColorStop(0.4, `rgba(240,208,96,${R.r(0.15, 0.35)})`);
rg.addColorStop(1, 'rgba(212,168,67,0)');
ctx.fillStyle = rg;
ctx.fill();
ctx.restore();
}
const haloCount = R.i(2, 5);
for (let i = 0; i < haloCount; i++) {
const radius = R.r(8, Math.min(w, h) * 0.6);
ctx.save();
ctx.beginPath();
ctx.arc(fx, fy, radius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255,248,200,${R.r(0.12, 0.35)})`;
ctx.lineWidth = R.r(0.5, 2.5);
ctx.stroke();
ctx.restore();
}
ctx.save();
const cg = ctx.createRadialGradient(fx, fy, 0, fx, fy, R.r(10, 22));
cg.addColorStop(0, 'rgba(255,255,240,0.9)');
cg.addColorStop(0.4, 'rgba(255,240,200,0.4)');
cg.addColorStop(1, 'rgba(240,208,96,0)');
ctx.fillStyle = cg;
ctx.fillRect(0, 0, w, h);
ctx.restore();
const sparkCount = R.i(18, 40);
for (let i = 0; i < sparkCount; i++) {
const sx = R.r(0, w);
const sy = R.r(0, h);
const ss = R.r(0.4, 2.5);
ctx.save();
ctx.beginPath();
ctx.arc(sx, sy, ss, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,250,210,${R.r(0.2, 0.8)})`;
ctx.fill();
ctx.restore();
}
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const softCount = R.i(3, 6);
for (let i = 0; i < softCount; i++) {
const sx = R.r(0, w);
const sy = R.r(0, h);
const sr = R.r(10, 30);
const sg = ctx.createRadialGradient(sx, sy, 0, sx, sy, sr);
sg.addColorStop(0, `rgba(240,210,100,${R.r(0.04, 0.12)})`);
sg.addColorStop(1, 'rgba(240,210,100,0)');
ctx.fillStyle = sg;
ctx.fillRect(sx - sr, sy - sr, sr * 2, sr * 2);
}
ctx.restore();
}
function drawTide(ctx, R, p, w, h) {
const bg = ctx.createLinearGradient(R.r(0, w * 0.3), 0, R.r(w * 0.7, w), h);
bg.addColorStop(0, '#0d1825');
bg.addColorStop(0.35, '#1a3355');
bg.addColorStop(0.65, '#2266aa');
bg.addColorStop(1, '#4499dd');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const deepGlow = ctx.createRadialGradient(w * 0.5, h * 0.7, 0, w * 0.5, h * 0.7, h * 0.6);
deepGlow.addColorStop(0, 'rgba(60,140,200,0.15)');
deepGlow.addColorStop(1, 'rgba(20,60,100,0)');
ctx.fillStyle = deepGlow;
ctx.fillRect(0, 0, w, h);
ctx.restore();
const bandCount = R.i(4, 8);
for (let b = 0; b < bandCount; b++) {
const baseY = R.r(h * 0.05, h * 0.95);
const amp = R.r(2, h * 0.12);
const freq = R.r(0.03, 0.09);
const phase = R.r(0, Math.PI * 2);
const freq2 = freq * R.r(1.8, 2.8);
const phase2 = R.r(0, Math.PI * 2);
ctx.save();
ctx.beginPath();
ctx.moveTo(-2, h + 2);
for (let x = -2; x <= w + 2; x += 2) {
const y = baseY + Math.sin(x * freq + phase) * amp + Math.sin(x * freq2 + phase2) * amp * 0.3;
ctx.lineTo(x, y);
}
ctx.lineTo(w + 2, h + 2);
ctx.closePath();
ctx.globalAlpha = R.r(0.08, 0.28);
ctx.fillStyle = R.pick(p.fills);
ctx.fill();
ctx.globalAlpha = 1;
ctx.restore();
}
const rippleCount = R.i(2, 5);
for (let i = 0; i < rippleCount; i++) {
const cx = R.r(w * 0.1, w * 0.9);
const cy = R.r(h * 0.1, h * 0.9);
const rings = R.i(2, 5);
for (let r = 0; r < rings; r++) {
const radius = R.r(4, 12) + r * R.r(3, 7);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(180,220,255,${R.r(0.06, 0.22)})`;
ctx.lineWidth = R.r(0.3, 1.2);
ctx.stroke();
ctx.restore();
}
}
const causticCount = R.i(5, 14);
for (let i = 0; i < causticCount; i++) {
const cx = R.r(0, w);
const cy = R.r(0, h);
const cr = R.r(3, 10);
ctx.save();
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
cg.addColorStop(0, `rgba(200,235,255,${R.r(0.08, 0.25)})`);
cg.addColorStop(1, 'rgba(200,235,255,0)');
ctx.fillStyle = cg;
ctx.fillRect(cx - cr, cy - cr, cr * 2, cr * 2);
ctx.restore();
}
const foamCount = R.i(12, 30);
for (let i = 0; i < foamCount; i++) {
ctx.save();
ctx.beginPath();
ctx.arc(R.r(0, w), R.r(0, h), R.r(0.3, 1.8), 0, Math.PI * 2);
ctx.fillStyle = `rgba(210,235,255,${R.r(0.12, 0.5)})`;
ctx.fill();
ctx.restore();
}
}
function drawShadow(ctx, R, p, w, h) {
const cx = R.r(w * 0.3, w * 0.7);
const cy = R.r(h * 0.3, h * 0.7);
const bg = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h) * 0.8);
bg.addColorStop(0, '#2a1530');
bg.addColorStop(0.4, '#1a1520');
bg.addColorStop(0.7, '#0f0a18');
bg.addColorStop(1, '#05020a');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const tendrilCount = R.i(5, 9);
for (let i = 0; i < tendrilCount; i++) {
const edge = R.i(0, 4);
let sx, sy;
if (edge === 0) { sx = R.r(0, w); sy = -2; }
else if (edge === 1) { sx = w + 2; sy = R.r(0, h); }
else if (edge === 2) { sx = R.r(0, w); sy = h + 2; }
else { sx = -2; sy = R.r(0, h); }
const ex = R.r(w * 0.15, w * 0.85);
const ey = R.r(h * 0.15, h * 0.85);
const cp1x = R.r(0, w);
const cp1y = R.r(0, h);
const cp2x = R.r(0, w);
const cp2y = R.r(0, h);
ctx.save();
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, ex, ey);
ctx.strokeStyle = `rgba(${R.i(60, 110)},${R.i(30, 70)},${R.i(80, 140)},${R.r(0.15, 0.45)})`;
ctx.lineWidth = R.r(1.5, 5);
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
}
const wispCount = R.i(5, 10);
for (let i = 0; i < wispCount; i++) {
const wx = R.r(-5, w + 5);
const wy = R.r(-5, h + 5);
const wr = R.r(8, 28);
ctx.save();
const wg = ctx.createRadialGradient(wx, wy, 0, wx, wy, wr);
wg.addColorStop(0, `rgba(${R.i(70, 120)},${R.i(40, 80)},${R.i(90, 140)},${R.r(0.08, 0.25)})`);
wg.addColorStop(0.6, `rgba(50,25,70,${R.r(0.03, 0.1)})`);
wg.addColorStop(1, 'rgba(30,15,50,0)');
ctx.fillStyle = wg;
ctx.fillRect(wx - wr, wy - wr, wr * 2, wr * 2);
ctx.restore();
}
const shardCount = R.i(3, 8);
for (let i = 0; i < shardCount; i++) {
const scx = R.r(0, w);
const scy = R.r(0, h);
const ssize = R.r(3, 14);
const sangle = R.r(0, Math.PI * 2);
const pts = R.i(3, 6);
ctx.save();
ctx.beginPath();
for (let j = 0; j < pts; j++) {
const a = sangle + (j / pts) * Math.PI * 2;
const sr = ssize * (0.4 + R.f() * 0.6);
const px = scx + Math.cos(a) * sr;
const py = scy + Math.sin(a) * sr;
if (j === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fillStyle = `rgba(${R.i(80, 150)},${R.i(40, 100)},${R.i(100, 170)},${R.r(0.08, 0.25)})`;
ctx.fill();
ctx.restore();
}
const glowX = R.r(w * 0.2, w * 0.8);
const glowY = R.r(h * 0.2, h * 0.8);
const glowR = R.r(12, 28);
ctx.save();
const gg = ctx.createRadialGradient(glowX, glowY, 0, glowX, glowY, glowR);
const glowTint = R.pick([
`rgba(90,200,120,${R.r(0.06, 0.15)})`,
`rgba(160,80,80,${R.r(0.06, 0.15)})`,
`rgba(100,80,180,${R.r(0.08, 0.18)})`,
]);
gg.addColorStop(0, glowTint);
gg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gg;
ctx.fillRect(0, 0, w, h);
ctx.restore();
const partCount = R.i(15, 35);
for (let i = 0; i < partCount; i++) {
ctx.save();
ctx.beginPath();
ctx.arc(R.r(0, w), R.r(0, h), R.r(0.3, 1.5), 0, Math.PI * 2);
ctx.fillStyle = `rgba(${R.i(100, 190)},${R.i(60, 130)},${R.i(120, 210)},${R.r(0.15, 0.55)})`;
ctx.fill();
ctx.restore();
}
}
function drawFlame(ctx, R, p, w, h) {
const bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, '#180600');
bg.addColorStop(0.3, '#331000');
bg.addColorStop(0.6, '#662200');
bg.addColorStop(0.85, '#993300');
bg.addColorStop(1, '#bb4400');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const hotX = R.r(w * 0.25, w * 0.75);
ctx.save();
const hg = ctx.createRadialGradient(hotX, h + 5, 0, hotX, h * 0.5, h * 0.9);
hg.addColorStop(0, 'rgba(255,200,50,0.45)');
hg.addColorStop(0.35, 'rgba(255,120,30,0.25)');
hg.addColorStop(0.7, 'rgba(180,50,10,0.1)');
hg.addColorStop(1, 'rgba(80,15,0,0)');
ctx.fillStyle = hg;
ctx.fillRect(0, 0, w, h);
ctx.restore();
const flameCount = R.i(6, 12);
for (let i = 0; i < flameCount; i++) {
const bx = R.r(w * -0.1, w * 1.1);
const halfW = R.r(4, 16);
const fh = R.r(h * 0.25, h * 0.95);
const lean = R.r(-10, 10);
ctx.save();
ctx.beginPath();
ctx.moveTo(bx - halfW, h + 2);
ctx.bezierCurveTo(
bx - halfW * R.r(0.3, 0.8) + lean * 0.3, h - fh * R.r(0.2, 0.45),
bx + lean * 0.6 - halfW * R.r(0.1, 0.3), h - fh * R.r(0.6, 0.85),
bx + lean, h - fh
);
ctx.bezierCurveTo(
bx + lean * 0.6 + halfW * R.r(0.1, 0.3), h - fh * R.r(0.6, 0.85),
bx + halfW * R.r(0.3, 0.8) + lean * 0.3, h - fh * R.r(0.2, 0.45),
bx + halfW, h + 2
);
ctx.closePath();
const fg = ctx.createLinearGradient(bx, h, bx + lean, h - fh);
fg.addColorStop(0, `rgba(255,220,60,${R.r(0.3, 0.6)})`);
fg.addColorStop(0.3, `rgba(255,140,30,${R.r(0.25, 0.5)})`);
fg.addColorStop(0.6, `rgba(230,70,20,${R.r(0.15, 0.35)})`);
fg.addColorStop(1, `rgba(150,30,10,${R.r(0.05, 0.15)})`);
ctx.fillStyle = fg;
ctx.fill();
ctx.restore();
}
const emberCount = R.i(25, 55);
for (let i = 0; i < emberCount; i++) {
const ex = R.r(0, w);
const ey = R.r(h * 0.05, h);
const es = R.r(0.4, 2.8);
const bright = R.f();
const er = Math.floor(200 + bright * 55);
const eg = Math.floor(80 + bright * 120);
const eb = Math.floor(bright * 50);
ctx.save();
ctx.beginPath();
ctx.arc(ex, ey, es, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${er},${eg},${eb},${R.r(0.3, 0.9)})`;
ctx.fill();
ctx.restore();
}
ctx.save();
ctx.globalAlpha = 0.12;
for (let i = 0; i < 4; i++) {
const sy = R.r(1, h * 0.25);
ctx.beginPath();
ctx.moveTo(0, sy);
const shimFreq = R.r(0.08, 0.2);
const shimPhase = R.r(0, 10);
for (let x = 0; x <= w; x += 2) {
ctx.lineTo(x, sy + Math.sin(x * shimFreq + shimPhase) * R.r(1, 3));
}
ctx.strokeStyle = R.pick(['#ffaa44', '#ff8833', '#ffcc66']);
ctx.lineWidth = R.r(0.4, 1.2);
ctx.stroke();
}
ctx.restore();
}
function drawGrowth(ctx, R, p, w, h) {
const bg = ctx.createLinearGradient(0, 0, R.r(0, w * 0.3), h);
bg.addColorStop(0, '#0e2208');
bg.addColorStop(0.4, '#1a3310');
bg.addColorStop(0.7, '#224411');
bg.addColorStop(1, '#2a5520');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const lightCount = R.i(1, 3);
for (let i = 0; i < lightCount; i++) {
const lx = R.r(w * 0.1, w * 0.9);
const ly = R.r(-5, h * 0.3);
const lr = R.r(15, 40);
ctx.save();
const lg = ctx.createRadialGradient(lx, ly, 0, lx, ly, lr);
lg.addColorStop(0, `rgba(180,220,140,${R.r(0.12, 0.3)})`);
lg.addColorStop(0.5, `rgba(120,180,90,${R.r(0.04, 0.12)})`);
lg.addColorStop(1, 'rgba(80,140,60,0)');
ctx.fillStyle = lg;
ctx.fillRect(lx - lr, ly - lr, lr * 2, lr * 2);
ctx.restore();
}
const hillCount = R.i(3, 5);
for (let hi = 0; hi < hillCount; hi++) {
const hillBase = h - R.r(h * 0.05, h * 0.45);
const hillFreq = R.r(0.015, 0.06);
const hillAmp = R.r(3, 12);
const hillPhase = R.r(0, 20);
ctx.save();
ctx.beginPath();
ctx.moveTo(-2, h + 2);
for (let x = -2; x <= w + 2; x += 2) {
const y = hillBase + Math.sin(x * hillFreq + hillPhase) * hillAmp
+ Math.sin(x * hillFreq * 2.7 + hillPhase * 1.3) * hillAmp * 0.3;
ctx.lineTo(x, y);
}
ctx.lineTo(w + 2, h + 2);
ctx.closePath();
ctx.globalAlpha = R.r(0.15, 0.45);
ctx.fillStyle = R.pick(p.fills);
ctx.fill();
ctx.restore();
}
const branchCount = R.i(2, 5);
for (let b = 0; b < branchCount; b++) {
const bStartX = R.pick([R.r(-5, w * 0.15), R.r(w * 0.85, w + 5)]);
const bStartY = R.r(h * 0.4, h + 5);
const bAngle = bStartX < w * 0.5 ? R.r(-1.2, -0.3) : R.r(-2.8, -1.9);
drawBranch(ctx, R, bStartX, bStartY, bAngle, R.r(12, 28), R.i(2, 4), R.r(1.5, 3.5));
}
const leafCount = R.i(10, 25);
for (let i = 0; i < leafCount; i++) {
const lx = R.r(0, w);
const ly = R.r(0, h);
const ls = R.r(1.5, 5);
const la = R.r(0, Math.PI * 2);
ctx.save();
ctx.translate(lx, ly);
ctx.rotate(la);
ctx.beginPath();
ctx.ellipse(0, 0, ls, ls * 0.35, 0, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${R.i(70, 150)},${R.i(150, 230)},${R.i(50, 120)},${R.r(0.12, 0.4)})`;
ctx.fill();
ctx.restore();
}
const dotCount = R.i(10, 20);
for (let i = 0; i < dotCount; i++) {
ctx.save();
ctx.beginPath();
ctx.arc(R.r(0, w), R.r(0, h), R.r(0.3, 1.3), 0, Math.PI * 2);
ctx.fillStyle = `rgba(200,240,170,${R.r(0.15, 0.55)})`;
ctx.fill();
ctx.restore();
}
}
function drawBranch(ctx, R, x, y, angle, length, depth, thickness) {
if (depth <= 0 || length < 2) return;
const ex = x + Math.cos(angle) * length;
const ey = y + Math.sin(angle) * length;
ctx.save();
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(ex, ey);
ctx.strokeStyle = `rgba(${R.i(50, 90)},${R.i(70, 130)},${R.i(30, 70)},${R.r(0.25, 0.6)})`;
ctx.lineWidth = thickness;
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
const forks = R.i(1, 3);
for (let i = 0; i < forks; i++) {
drawBranch(ctx, R, ex, ey, angle + R.r(-0.9, 0.9), length * R.r(0.45, 0.75), depth - 1, thickness * 0.55);
}
}
function drawColorless(ctx, R, p, w, h) {
const ga = R.r(0, Math.PI);
const gx = Math.cos(ga);
const gy = Math.sin(ga);
const bg = ctx.createLinearGradient(
w * 0.5 - gx * w * 0.6, h * 0.5 - gy * h * 0.6,
w * 0.5 + gx * w * 0.6, h * 0.5 + gy * h * 0.6
);
bg.addColorStop(0, '#383838');
bg.addColorStop(0.3, '#606068');
bg.addColorStop(0.5, '#808088');
bg.addColorStop(0.7, '#606068');
bg.addColorStop(1, '#404048');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
const hexSize = R.r(7, 13);
const hexH = hexSize * Math.sqrt(3);
const hexOff = R.r(-0.25, 0.25);
ctx.save();
ctx.translate(R.r(-8, 8), R.r(-8, 8));
ctx.rotate(hexOff);
for (let row = -2; row < h / hexH + 3; row++) {
for (let col = -2; col < w / (hexSize * 1.5) + 3; col++) {
const hcx = col * hexSize * 1.5;
const hcy = row * hexH + (col % 2 ? hexH * 0.5 : 0);
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = (Math.PI / 3) * i + Math.PI / 6;
const px = hcx + Math.cos(a) * hexSize * 0.88;
const py = hcy + Math.sin(a) * hexSize * 0.88;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.strokeStyle = `rgba(190,195,210,${R.r(0.04, 0.18)})`;
ctx.lineWidth = R.r(0.3, 0.9);
ctx.stroke();
}
}
ctx.restore();
const crystalCount = R.i(3, 7);
for (let i = 0; i < crystalCount; i++) {
const ccx = R.r(w * 0.05, w * 0.95);
const ccy = R.r(h * 0.05, h * 0.95);
const csize = R.r(5, 18);
const csides = R.pick([4, 5, 6, 8]);
const crot = R.r(0, Math.PI * 2);
ctx.save();
ctx.beginPath();
for (let s = 0; s < csides; s++) {
const a = crot + (s / csides) * Math.PI * 2;
const cr = csize * (0.6 + R.f() * 0.4);
const px = ccx + Math.cos(a) * cr;
const py = ccy + Math.sin(a) * cr;
if (s === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fillStyle = `rgba(170,180,200,${R.r(0.04, 0.18)})`;
ctx.fill();
ctx.strokeStyle = `rgba(210,220,240,${R.r(0.08, 0.3)})`;
ctx.lineWidth = R.r(0.3, 1);
ctx.stroke();
ctx.restore();
}
const lineCount = R.i(4, 10);
for (let i = 0; i < lineCount; i++) {
ctx.save();
ctx.beginPath();
ctx.moveTo(R.r(0, w), R.r(0, h));
ctx.lineTo(R.r(0, w), R.r(0, h));
ctx.strokeStyle = `rgba(220,225,240,${R.r(0.04, 0.16)})`;
ctx.lineWidth = R.r(0.2, 0.8);
ctx.stroke();
ctx.restore();
}
const sheenX = R.r(w * 0.15, w * 0.85);
ctx.save();
const sg = ctx.createLinearGradient(sheenX - 18, 0, sheenX + 18, 0);
sg.addColorStop(0, 'rgba(255,255,255,0)');
sg.addColorStop(0.35, 'rgba(255,255,255,0.06)');
sg.addColorStop(0.5, 'rgba(255,255,255,0.12)');
sg.addColorStop(0.65, 'rgba(255,255,255,0.06)');
sg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = sg;
ctx.fillRect(0, 0, w, h);
ctx.restore();
}
const COLOR_DRAW = {
radiance: drawRadiance,
tide: drawTide,
shadow: drawShadow,
flame: drawFlame,
growth: drawGrowth,
colorless: drawColorless,
};
function drawCreature(ctx, R, p, w, h) {
const silType = R.i(0, 5);
const cx = w * 0.5;
const cy = h * 0.5;
const sc = Math.min(w, h) * 0.28;
ctx.save();
ctx.globalAlpha = R.r(0.07, 0.18);
ctx.fillStyle = p.glow;
if (silType === 0) {
ctx.beginPath();
ctx.arc(cx, cy - sc * 0.65, sc * 0.22, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx - sc * 0.45, cy + sc * 0.85);
ctx.lineTo(cx - sc * 0.35, cy - sc * 0.25);
ctx.lineTo(cx, cy - sc * 0.4);
ctx.lineTo(cx + sc * 0.35, cy - sc * 0.25);
ctx.lineTo(cx + sc * 0.45, cy + sc * 0.85);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx - sc * 0.35, cy - sc * 0.1);
ctx.lineTo(cx - sc * 0.7, cy + sc * 0.15);
ctx.lineTo(cx - sc * 0.65, cy + sc * 0.25);
ctx.lineTo(cx - sc * 0.3, cy + sc * 0.05);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx + sc * 0.35, cy - sc * 0.1);
ctx.lineTo(cx + sc * 0.7, cy + sc * 0.15);
ctx.lineTo(cx + sc * 0.65, cy + sc * 0.25);
ctx.lineTo(cx + sc * 0.3, cy + sc * 0.05);
ctx.closePath();
ctx.fill();
} else if (silType === 1) {
ctx.beginPath();
ctx.ellipse(cx, cy, sc * 0.55, sc * 0.3, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(cx + sc * 0.5, cy - sc * 0.22, sc * 0.2, sc * 0.18, -0.3, 0, Math.PI * 2);
ctx.fill();
const legW = sc * 0.07;
[cx - sc * 0.38, cx - sc * 0.15, cx + sc * 0.12, cx + sc * 0.35].forEach(function(lx) {
ctx.fillRect(lx - legW, cy + sc * 0.22, legW * 2, sc * 0.55);
});
} else if (silType === 2) {
ctx.beginPath();
ctx.ellipse(cx, cy, sc * 0.18, sc * 0.35, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx - sc * 0.1, cy - sc * 0.15);
ctx.quadraticCurveTo(cx - sc * 0.85, cy - sc * 0.7, cx - sc * 0.75, cy + sc * 0.15);
ctx.lineTo(cx - sc * 0.2, cy + sc * 0.05);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(cx + sc * 0.1, cy - sc * 0.15);
ctx.quadraticCurveTo(cx + sc * 0.85, cy - sc * 0.7, cx + sc * 0.75, cy + sc * 0.15);
ctx.lineTo(cx + sc * 0.2, cy + sc * 0.05);
ctx.closePath();
ctx.fill();
} else if (silType === 3) {
ctx.strokeStyle = p.glow;
ctx.lineWidth = sc * 0.18;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cx - sc * 0.6, cy + sc * 0.4);
ctx.bezierCurveTo(
cx - sc * 0.2, cy - sc * 0.6,
cx + sc * 0.2, cy + sc * 0.6,
cx + sc * 0.6, cy - sc * 0.4
);
ctx.stroke();
} else {
ctx.fillRect(cx - sc * 0.45, cy - sc * 0.35, sc * 0.9, sc * 0.9);
ctx.fillRect(cx - sc * 0.2, cy - sc * 0.75, sc * 0.4, sc * 0.45);
}
ctx.restore();
}
function drawSpell(ctx, R, p, w, h) {
const cx = w * 0.5 + R.r(-w * 0.12, w * 0.12);
const cy = h * 0.5 + R.r(-h * 0.12, h * 0.12);
const radius = Math.min(w, h) * R.r(0.28, 0.42);
ctx.save();
ctx.globalAlpha = R.r(0.12, 0.28);
ctx.strokeStyle = p.glow;
ctx.lineWidth = R.r(0.5, 1.5);
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.55, 0, Math.PI * 2);
ctx.stroke();
const spokes = R.i(4, 9);
const spokeOff = R.r(0, Math.PI * 2);
for (let i = 0; i < spokes; i++) {
const a = spokeOff + (i / spokes) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * radius * 0.55, cy + Math.sin(a) * radius * 0.55);
ctx.lineTo(cx + Math.cos(a) * radius, cy + Math.sin(a) * radius);
ctx.stroke();
}
const runeCount = R.i(6, 14);
ctx.fillStyle = p.glow;
for (let i = 0; i < runeCount; i++) {
const a = (i / runeCount) * Math.PI * 2;
const dr = radius * R.r(0.75, 1.25);
ctx.beginPath();
ctx.arc(cx + Math.cos(a) * dr, cy + Math.sin(a) * dr, R.r(0.5, 2), 0, Math.PI * 2);
ctx.fill();
}
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.15, 0, Math.PI * 2);
ctx.fillStyle = p.glow;
ctx.fill();
ctx.restore();
}
function drawEnchantment(ctx, R, p, w, h) {
ctx.save();
const edgeSize = R.r(5, 12);
ctx.globalAlpha = R.r(0.08, 0.2);
let g = ctx.createLinearGradient(0, 0, 0, edgeSize);
g.addColorStop(0, p.glow);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, edgeSize);
g = ctx.createLinearGradient(0, h, 0, h - edgeSize);
g.addColorStop(0, p.glow);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0, h - edgeSize, w, edgeSize);
g = ctx.createLinearGradient(0, 0, edgeSize, 0);
g.addColorStop(0, p.glow);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, edgeSize, h);
g = ctx.createLinearGradient(w, 0, w - edgeSize, 0);
g.addColorStop(0, p.glow);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(w - edgeSize, 0, edgeSize, h);
const pCount = R.i(6, 16);
for (let i = 0; i < pCount; i++) {
const px = R.r(0, w);
const py = R.r(0, h);
const pr = R.r(1.5, 4);
const pg = ctx.createRadialGradient(px, py, 0, px, py, pr);
pg.addColorStop(0, p.glow);
pg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = R.r(0.1, 0.35);
ctx.fillStyle = pg;
ctx.fillRect(px - pr, py - pr, pr * 2, pr * 2);
}
ctx.restore();
}
function drawLand(ctx, R, p, w, h) {
ctx.save();
const horizonY = h * R.r(0.32, 0.52);
const skyGrad = ctx.createLinearGradient(0, 0, 0, horizonY);
skyGrad.addColorStop(0, p.deep);
skyGrad.addColorStop(1, R.pick(p.fills));
ctx.globalAlpha = 0.3;
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, w, horizonY);
ctx.beginPath();
ctx.moveTo(-2, h + 2);
const tFreq = R.r(0.02, 0.07);
const tAmp = R.r(2, 8);
const tPhase = R.r(0, 20);
for (let x = -2; x <= w + 2; x += 2) {
ctx.lineTo(x, horizonY + Math.sin(x * tFreq + tPhase) * tAmp + Math.sin(x * tFreq * 2.5 + tPhase * 0.7) * tAmp * 0.4);
}
ctx.lineTo(w + 2, h + 2);
ctx.closePath();
ctx.globalAlpha = 0.22;
ctx.fillStyle = R.pick(p.fills);
ctx.fill();
const hGlow = ctx.createLinearGradient(0, horizonY - 6, 0, horizonY + 6);
hGlow.addColorStop(0, 'rgba(0,0,0,0)');
hGlow.addColorStop(0.5, p.glow);
hGlow.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = 0.18;
ctx.fillStyle = hGlow;
ctx.fillRect(0, horizonY - 6, w, 12);
const bodyR = R.r(6, 14);
const bodyX = R.r(w * 0.2, w * 0.8);
const bodyY = R.r(2, horizonY * 0.5);
const bodyGrad = ctx.createRadialGradient(bodyX, bodyY, 0, bodyX, bodyY, bodyR);
bodyGrad.addColorStop(0, p.glow);
bodyGrad.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = 0.15;
ctx.fillStyle = bodyGrad;
ctx.fillRect(bodyX - bodyR, bodyY - bodyR, bodyR * 2, bodyR * 2);
ctx.restore();
}
function drawArtifact(ctx, R, p, w, h) {
ctx.save();
const gearCount = R.i(1, 4);
for (let gi = 0; gi < gearCount; gi++) {
const gcx = R.r(w * 0.15, w * 0.85);
const gcy = R.r(h * 0.15, h * 0.85);
const outerR = R.r(5, 16);
const teeth = R.i(6, 14);
const toothH = outerR * 0.28;
const innerR = outerR * 0.65;
ctx.globalAlpha = R.r(0.08, 0.22);
ctx.strokeStyle = p.glow;
ctx.lineWidth = R.r(0.4, 1.2);
ctx.beginPath();
for (let t = 0; t < teeth; t++) {
const a1 = (t / teeth) * Math.PI * 2;
const a2 = ((t + 0.3) / teeth) * Math.PI * 2;
const a3 = ((t + 0.7) / teeth) * Math.PI * 2;
const a4 = ((t + 1) / teeth) * Math.PI * 2;
ctx.lineTo(gcx + Math.cos(a1) * innerR, gcy + Math.sin(a1) * innerR);
ctx.lineTo(gcx + Math.cos(a2) * (innerR + toothH), gcy + Math.sin(a2) * (innerR + toothH));
ctx.lineTo(gcx + Math.cos(a3) * (innerR + toothH), gcy + Math.sin(a3) * (innerR + toothH));
ctx.lineTo(gcx + Math.cos(a4) * innerR, gcy + Math.sin(a4) * innerR);
}
ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.arc(gcx, gcy, outerR * 0.22, 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
}
const TYPE_DRAW = {
creature: drawCreature,
sorcery: drawSpell,
instant: drawSpell,
enchantment: drawEnchantment,
land: drawLand,
artifact: drawArtifact,
};
function drawVignette(ctx, w, h) {
ctx.save();
const vg = ctx.createRadialGradient(w * 0.5, h * 0.5, Math.min(w, h) * 0.25, w * 0.5, h * 0.5, Math.max(w, h) * 0.85);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.35)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, w, h);
ctx.restore();
}
function drawGrain(ctx, R, w, h) {
ctx.save();
const count = Math.floor(w * h * 0.04);
for (let i = 0; i < count; i++) {
const gx = Math.floor(R.f() * w);
const gy = Math.floor(R.f() * h);
const v = R.i(0, 256);
ctx.fillStyle = `rgba(${v},${v},${v},${R.r(0.01, 0.04)})`;
ctx.fillRect(gx, gy, 1, 1);
}
ctx.restore();
}
const CardArt = memo(function CardArt({ cardName, color = 'colorless', type = 'creature', width = 130, height = 70 }) {
const ref = useRef(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const R = seedRng(cardName);
const p = PAL[color] || PAL.colorless;
const colorFn = COLOR_DRAW[color] || COLOR_DRAW.colorless;
colorFn(ctx, R, p, width, height);
const typeFn = TYPE_DRAW[type];
if (typeFn) typeFn(ctx, R, p, width, height);
drawVignette(ctx, width, height);
drawGrain(ctx, R, width, height);
}, [cardName, color, type, width, height]);
return (
<canvas
ref={ref}
style={{ width: `${width}px`, height: `${height}px`, display: 'block' }}
/>
);
});
export default CardArt;

View File

@@ -0,0 +1,504 @@
import React, { useState, useEffect, useCallback } from 'react';
import socket from '../socket';
import Card from './Card';
const PHASE_LABELS = {
untap: 'Untap',
upkeep: 'Upkeep',
draw: 'Draw',
main1: 'Main 1',
combat_begin: 'Combat',
combat_attackers: 'Attackers',
combat_blockers: 'Blockers',
combat_damage: 'Damage',
combat_end: 'End Combat',
main2: 'Main 2',
end_step: 'End',
cleanup: 'Cleanup',
};
const MANA_SYMBOLS = {
radiance: '\u2600',
tide: '\u{1F30A}',
shadow: '\u{1F480}',
flame: '\u{1F525}',
growth: '\u{1F33F}',
colorless: '\u26AA',
};
export default function GameBoard({ gameState, playerId, onLeave }) {
const [selectedHandIndex, setSelectedHandIndex] = useState(null);
const [selectedAttackers, setSelectedAttackers] = useState([]);
const [selectedBlockers, setSelectedBlockers] = useState({});
const [targetMode, setTargetMode] = useState(null);
const [pendingSpell, setPendingSpell] = useState(null);
const [xAmount, setXAmount] = useState(0);
const [manaColorChoice, setManaColorChoice] = useState(null);
const [showLog, setShowLog] = useState(false);
const isMyTurn = gameState.activePlayerId === playerId;
const phase = gameState.currentPhase;
const me = gameState.players[playerId];
const opponentId = Object.keys(gameState.players).find((id) => id !== playerId);
const opponent = gameState.players[opponentId];
useEffect(() => {
const handleNeedsResponse = (data) => {
if (data.type === 'chooseManaColor') {
setManaColorChoice(data.options);
}
};
socket.on('needsResponse', handleNeedsResponse);
return () => socket.off('needsResponse', handleNeedsResponse);
}, []);
useEffect(() => {
setSelectedAttackers([]);
setSelectedBlockers({});
}, [phase]);
const sendAction = useCallback((action) => {
socket.emit('gameAction', { action });
}, []);
const handleTapLand = useCallback((instanceId) => {
sendAction({ type: 'tapLand', instanceId });
}, [sendAction]);
const handleTapAbility = useCallback((instanceId, sacrifice = false) => {
sendAction({ type: 'tapAbility', instanceId, sacrifice });
}, [sendAction]);
const handlePlayCard = useCallback((handIndex) => {
const cardData = me.hand[handIndex];
if (!cardData) return;
if (cardData.type === 'land') {
sendAction({ type: 'playLand', handIndex });
setSelectedHandIndex(null);
return;
}
const needsTarget = cardData.abilities?.some((a) =>
['destroyCreature', 'destroyCreatureIf', 'bounceCreature', 'pumpCreature',
'dealDamage', 'drainLife', 'tapCreature', 'giveKeyword', 'preventDamage'].includes(a.effect)
);
const hasX = cardData.abilities?.some((a) => a.isX);
if (hasX) {
setPendingSpell({ handIndex, needsTarget, cardData });
setTargetMode(null);
return;
}
if (needsTarget) {
setPendingSpell({ handIndex, needsTarget: true, cardData, xAmount: 0 });
setTargetMode('creature');
return;
}
const canTargetPlayer = cardData.abilities?.some((a) =>
a.target === 'any' && ['dealDamage', 'drainLife'].includes(a.effect)
);
if (canTargetPlayer) {
setPendingSpell({ handIndex, needsTarget: true, cardData, xAmount: 0, canTargetPlayer: true });
setTargetMode('any');
return;
}
sendAction({ type: 'castSpell', handIndex });
setSelectedHandIndex(null);
}, [me, sendAction]);
const handleTargetCreature = useCallback((instanceId) => {
if (!pendingSpell) return;
sendAction({
type: 'castSpell',
handIndex: pendingSpell.handIndex,
targetInstanceId: instanceId,
xAmount: pendingSpell.xAmount || xAmount,
});
setPendingSpell(null);
setTargetMode(null);
setSelectedHandIndex(null);
setXAmount(0);
}, [pendingSpell, xAmount, sendAction]);
const handleTargetPlayer = useCallback((targetPid) => {
if (!pendingSpell) return;
sendAction({
type: 'castSpell',
handIndex: pendingSpell.handIndex,
targetPlayerId: targetPid,
xAmount: pendingSpell.xAmount || xAmount,
});
setPendingSpell(null);
setTargetMode(null);
setSelectedHandIndex(null);
setXAmount(0);
}, [pendingSpell, xAmount, sendAction]);
const handleDeclareAttackers = useCallback(() => {
sendAction({ type: 'declareAttackers', attackerIds: selectedAttackers });
setSelectedAttackers([]);
}, [selectedAttackers, sendAction]);
const handleDeclareBlockers = useCallback(() => {
sendAction({ type: 'declareBlockers', blockerAssignments: selectedBlockers });
setSelectedBlockers({});
}, [selectedBlockers, sendAction]);
const handlePassPhase = useCallback(() => {
sendAction({ type: 'passPriority' });
}, [sendAction]);
const handleConcede = useCallback(() => {
if (window.confirm('Are you sure you want to concede?')) {
sendAction({ type: 'concede' });
}
}, [sendAction]);
const handleManaColorChoice = useCallback((color) => {
sendAction({ type: 'chooseManaColor', color });
setManaColorChoice(null);
}, [sendAction]);
const toggleAttacker = useCallback((instanceId) => {
setSelectedAttackers((prev) =>
prev.includes(instanceId)
? prev.filter((id) => id !== instanceId)
: [...prev, instanceId]
);
}, []);
const toggleBlocker = useCallback((blockerId, attackerId) => {
setSelectedBlockers((prev) => {
const next = { ...prev };
if (next[blockerId] === attackerId) {
delete next[blockerId];
} else {
next[blockerId] = attackerId;
}
return next;
});
}, []);
const handleBattlefieldClick = useCallback((inst, isOpponent) => {
if (targetMode && pendingSpell) {
handleTargetCreature(inst.instanceId);
return;
}
if (isOpponent) return;
if (phase === 'combat_attackers' && isMyTurn && inst.cardData.type === 'creature') {
toggleAttacker(inst.instanceId);
return;
}
if (inst.cardData.type === 'land' || inst.cardData.abilities?.some((a) => a.trigger === 'tap' && a.effect === 'addMana')) {
if (!inst.tapped) {
handleTapLand(inst.instanceId);
return;
}
}
const hasTapAbility = inst.cardData.abilities?.some((a) => a.trigger === 'tap' && a.effect !== 'addMana');
const hasSacrificeAbility = inst.cardData.abilities?.some((a) => a.trigger === 'tap_sacrifice');
if (hasTapAbility && !inst.tapped) {
handleTapAbility(inst.instanceId, false);
} else if (hasSacrificeAbility) {
handleTapAbility(inst.instanceId, true);
}
}, [targetMode, pendingSpell, phase, isMyTurn, handleTargetCreature, toggleAttacker, handleTapLand, handleTapAbility]);
const renderManaPool = (pool) => (
<div className="mana-pool">
{Object.entries(pool).map(([color, amount]) =>
amount > 0 ? (
<span key={color} className={`mana mana-${color}`}>
{MANA_SYMBOLS[color]}{amount}
</span>
) : null
)}
</div>
);
const renderBattlefield = (playerData, isOpponent) => {
const lands = playerData.battlefield.filter((i) => i.cardData.type === 'land');
const creatures = playerData.battlefield.filter((i) => i.cardData.type === 'creature');
const others = playerData.battlefield.filter((i) => i.cardData.type !== 'land' && i.cardData.type !== 'creature');
return (
<div className={`battlefield ${isOpponent ? 'opponent' : 'mine'}`}>
{others.length > 0 && (
<div className="bf-row bf-others">
{others.map((inst) => (
<Card
key={inst.instanceId}
instance={inst}
small
onClick={() => handleBattlefieldClick(inst, isOpponent)}
selected={selectedAttackers.includes(inst.instanceId)}
selectable={targetMode !== null}
/>
))}
</div>
)}
<div className="bf-row bf-creatures">
{creatures.length === 0 ? (
<div className="bf-empty">No creatures</div>
) : (
creatures.map((inst) => {
const isAttacking = gameState.attackers.includes(inst.instanceId);
const isBlocking = Object.values(selectedBlockers).includes(inst.instanceId) ||
Object.values(gameState.blockers).flat().includes(inst.instanceId);
return (
<div
key={inst.instanceId}
className={`bf-creature-slot ${isAttacking ? 'attacking' : ''} ${isBlocking ? 'blocking' : ''}`}
>
<Card
instance={inst}
small
onClick={() => {
if (phase === 'combat_blockers' && !isMyTurn && !isOpponent && gameState.attackers.length > 0) {
const nextAttacker = gameState.attackers[0];
toggleBlocker(inst.instanceId, nextAttacker);
} else {
handleBattlefieldClick(inst, isOpponent);
}
}}
selected={
selectedAttackers.includes(inst.instanceId) ||
Object.keys(selectedBlockers).includes(inst.instanceId)
}
selectable={
(phase === 'combat_attackers' && isMyTurn && !isOpponent) ||
(phase === 'combat_blockers' && !isMyTurn && !isOpponent) ||
targetMode !== null
}
/>
{isAttacking && <div className="combat-badge attack-badge">ATK</div>}
{isBlocking && <div className="combat-badge block-badge">BLK</div>}
</div>
);
})
)}
</div>
<div className="bf-row bf-lands">
{lands.map((inst) => (
<Card
key={inst.instanceId}
instance={inst}
small
onClick={() => handleBattlefieldClick(inst, isOpponent)}
/>
))}
</div>
</div>
);
};
if (gameState.gameOver) {
return (
<div className="game-over-overlay">
<div className="game-over-modal">
<h1>{gameState.winner === playerId ? 'Victory!' : 'Defeat'}</h1>
<p>{gameState.winner === playerId ? 'You have won the duel!' : 'You have been defeated.'}</p>
<button className="btn btn-primary" onClick={onLeave}>Back to Lobby</button>
</div>
</div>
);
}
return (
<div className="game-board">
<div className="game-top-bar">
<button className="btn btn-small btn-back" onClick={handleConcede}>Concede</button>
<div className="phase-tracker">
{Object.entries(PHASE_LABELS).map(([key, label]) => (
<span
key={key}
className={`phase-pip ${phase === key ? 'active' : ''} ${isMyTurn ? 'my-turn' : ''}`}
>
{label}
</span>
))}
</div>
<div className="turn-info">
Turn {gameState.turnNumber} &middot; {isMyTurn ? 'Your turn' : "Opponent's turn"}
</div>
<button className="btn btn-small" onClick={() => setShowLog(!showLog)}>Log</button>
</div>
<div className="opponent-area">
<div className="player-info opponent-info">
<span className="life-total">{'\u2764'} {opponent.life}</span>
<span className="hand-count">{'\u{1F0CF}'} {opponent.handCount}</span>
<span className="library-count">Deck: {opponent.libraryCount}</span>
{renderManaPool(opponent.manaPool)}
{targetMode === 'any' && (
<button className="btn btn-target" onClick={() => handleTargetPlayer(opponentId)}>
Target Opponent
</button>
)}
</div>
{renderBattlefield(opponent, true)}
</div>
<div className="combat-zone">
{phase === 'combat_attackers' && isMyTurn && (
<div className="combat-controls">
<span>Select attackers, then:</span>
<button className="btn btn-primary" onClick={handleDeclareAttackers}>
{selectedAttackers.length > 0
? `Attack with ${selectedAttackers.length} creature(s)`
: 'Skip Attack'}
</button>
</div>
)}
{phase === 'combat_blockers' && !isMyTurn && (
<div className="combat-controls">
<span>Select blockers, then:</span>
<button className="btn btn-primary" onClick={handleDeclareBlockers}>
{Object.keys(selectedBlockers).length > 0
? `Block with ${Object.keys(selectedBlockers).length} creature(s)`
: 'No Blocks'}
</button>
</div>
)}
</div>
<div className="my-area">
{renderBattlefield(me, false)}
<div className="player-info my-info">
<span className="life-total">{'\u2764'} {me.life}</span>
<span className="library-count">Deck: {me.libraryCount}</span>
{renderManaPool(me.manaPool)}
{targetMode === 'any' && (
<button className="btn btn-target" onClick={() => handleTargetPlayer(playerId)}>
Target Self
</button>
)}
</div>
</div>
<div className="hand-area">
<div className="hand">
{me.hand.map((cardData, idx) => (
<div key={`${cardData.cardId}-${idx}`} className="hand-slot">
<Card
card={cardData}
onClick={() => {
if (selectedHandIndex === idx) {
handlePlayCard(idx);
} else {
setSelectedHandIndex(idx);
}
}}
selected={selectedHandIndex === idx}
selectable={isMyTurn || cardData.type === 'instant'}
/>
</div>
))}
</div>
<div className="hand-actions">
{(isMyTurn || phase === 'combat_blockers') && (
<button className="btn btn-pass" onClick={handlePassPhase}>
{phase === 'main1' ? 'Go to Combat' :
phase === 'main2' ? 'End Turn' :
phase === 'combat_blockers' && !isMyTurn ? 'No Blocks' : 'Pass'}
</button>
)}
</div>
</div>
{pendingSpell && pendingSpell.cardData.abilities?.some((a) => a.isX) && !targetMode && (
<div className="modal-overlay">
<div className="modal">
<h3>Choose X value for {pendingSpell.cardData.name}</h3>
<div className="x-picker">
<button onClick={() => setXAmount(Math.max(0, xAmount - 1))}>-</button>
<span className="x-value">{xAmount}</span>
<button onClick={() => setXAmount(xAmount + 1)}>+</button>
</div>
<div className="modal-actions">
<button
className="btn btn-primary"
onClick={() => {
if (pendingSpell.needsTarget) {
setPendingSpell({ ...pendingSpell, xAmount });
setTargetMode('any');
} else {
sendAction({
type: 'castSpell',
handIndex: pendingSpell.handIndex,
xAmount,
targetPlayerId: pendingSpell.canTargetPlayer ? opponentId : undefined,
});
setPendingSpell(null);
setSelectedHandIndex(null);
setXAmount(0);
}
}}
>
{pendingSpell.needsTarget ? 'Choose Target' : 'Cast'}
</button>
<button className="btn" onClick={() => { setPendingSpell(null); setXAmount(0); }}>Cancel</button>
</div>
</div>
</div>
)}
{manaColorChoice && (
<div className="modal-overlay">
<div className="modal">
<h3>Choose mana color</h3>
<div className="mana-choice-grid">
{manaColorChoice.map((color) => (
<button
key={color}
className={`btn mana-choice-btn color-${color}`}
onClick={() => handleManaColorChoice(color)}
>
{MANA_SYMBOLS[color]} {color.charAt(0).toUpperCase() + color.slice(1)}
</button>
))}
</div>
</div>
</div>
)}
{targetMode && (
<div className="target-banner">
Choose a target (click a creature{targetMode === 'any' ? ' or player' : ''})
<button className="btn btn-small" onClick={() => { setTargetMode(null); setPendingSpell(null); }}>
Cancel
</button>
</div>
)}
{showLog && (
<div className="game-log-panel">
<div className="log-header">
<h3>Game Log</h3>
<button className="btn btn-small" onClick={() => setShowLog(false)}>Close</button>
</div>
<div className="log-entries">
{gameState.log.slice().reverse().map((entry, i) => (
<div key={i} className="log-entry">
<span className="log-turn">T{entry.turn}</span>
<span className="log-msg">{entry.message}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,148 @@
import React, { useState, useEffect } from 'react';
import socket from '../socket';
const DECK_OPTIONS = [
{ color: 'radiance', name: "Dawn's Wrath", symbol: '\u2600', desc: 'Healing, protection, order' },
{ color: 'tide', name: 'Depths of Knowledge', symbol: '\u{1F30A}', desc: 'Card draw, control, counters' },
{ color: 'shadow', name: 'Veil of Shadows', symbol: '\u{1F480}', desc: 'Removal, sacrifice, drain' },
{ color: 'flame', name: 'Infernal Fury', symbol: '\u{1F525}', desc: 'Direct damage, speed, aggression' },
{ color: 'growth', name: 'Primal Might', symbol: '\u{1F33F}', desc: 'Big creatures, ramp, buffs' },
];
export default function Lobby({ playerId, playerName, rooms, currentRoom, screen, onBackToLobby }) {
const [roomName, setRoomName] = useState('');
useEffect(() => {
socket.emit('getRooms');
}, []);
const handleCreateRoom = () => {
socket.emit('createRoom', { roomName: roomName || `${playerName}'s Game` });
setRoomName('');
};
const handleJoinRoom = (roomId) => {
socket.emit('joinRoom', { roomId });
};
const handleSelectDeck = (color) => {
socket.emit('selectDeck', { deckColor: color });
};
const handleStartGame = () => {
socket.emit('startGame');
};
if (screen === 'room' && currentRoom) {
const me = currentRoom.players.find((p) => p.id === playerId);
const isHost = currentRoom.hostId === playerId;
const allReady = currentRoom.players.length >= 2 && currentRoom.players.every((p) => p.ready);
return (
<div className="lobby">
<div className="room-view">
<div className="room-header">
<button className="btn btn-back" onClick={onBackToLobby}>&larr; Leave</button>
<h2>{currentRoom.name}</h2>
</div>
<div className="room-players">
<h3>Players</h3>
{currentRoom.players.map((p) => (
<div key={p.id} className={`room-player ${p.ready ? 'ready' : ''}`}>
<span className="player-name">
{p.name} {p.id === currentRoom.hostId ? '(Host)' : ''}
</span>
<span className="player-status">
{p.ready ? `\u2714 ${typeof p.deck === 'string' ? DECK_OPTIONS.find((d) => d.color === p.deck)?.name : 'Custom Deck'}` : 'Choosing deck...'}
</span>
</div>
))}
{currentRoom.players.length < 2 && (
<div className="room-player waiting">Waiting for opponent...</div>
)}
</div>
<div className="deck-selection">
<h3>Choose Your Deck</h3>
<div className="deck-grid">
{DECK_OPTIONS.map((deck) => (
<button
key={deck.color}
className={`deck-card ${me?.deck === deck.color ? 'selected' : ''} color-${deck.color}`}
onClick={() => handleSelectDeck(deck.color)}
>
<span className="deck-symbol">{deck.symbol}</span>
<span className="deck-name">{deck.name}</span>
<span className="deck-desc">{deck.desc}</span>
</button>
))}
</div>
</div>
{isHost && (
<button
className="btn btn-primary btn-start"
onClick={handleStartGame}
disabled={!allReady}
>
{allReady ? 'Start Game' : 'Waiting for all players to be ready...'}
</button>
)}
</div>
</div>
);
}
return (
<div className="lobby">
<div className="lobby-header">
<h1 className="game-title-small">Arcane Duels</h1>
<span className="player-badge">Playing as: {playerName}</span>
</div>
<div className="lobby-content">
<div className="create-room">
<h3>Create a Game</h3>
<div className="create-room-form">
<input
type="text"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
placeholder="Room name (optional)"
className="room-input"
/>
<button className="btn btn-primary" onClick={handleCreateRoom}>
Create Room
</button>
</div>
</div>
<div className="room-list">
<h3>Available Games {rooms.length > 0 && `(${rooms.length})`}</h3>
{rooms.length === 0 ? (
<div className="no-rooms">No games available. Create one!</div>
) : (
rooms.map((room) => (
<div key={room.id} className="room-item">
<div className="room-info">
<span className="room-name">{room.name}</span>
<span className="room-meta">
Host: {room.host} &middot; {room.playerCount}/{room.maxPlayers} players
</span>
</div>
<button
className="btn btn-join"
onClick={() => handleJoinRoom(room.id)}
disabled={room.status !== 'waiting' || room.playerCount >= room.maxPlayers}
>
{room.status !== 'waiting' ? 'In Progress' : room.playerCount >= room.maxPlayers ? 'Full' : 'Join'}
</button>
</div>
))
)}
</div>
</div>
</div>
);
}

10
client/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './App.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

14
client/src/socket.js Normal file
View File

@@ -0,0 +1,14 @@
import { io } from 'socket.io-client';
const SERVER_URL = window.location.hostname === 'localhost'
? 'http://localhost:3001'
: `http://${window.location.hostname}:3001`;
const socket = io(SERVER_URL, {
autoConnect: false,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
});
export default socket;

16
client/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true,
proxy: {
'/socket.io': {
target: 'http://localhost:3001',
ws: true,
},
},
},
});