From 1c0603022a4f9d22c0ef37b101f6f5cd6087e8a2 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 25 Dec 2025 10:09:02 +0300 Subject: [PATCH] Add resource icons and registry images --- docker-compose.yml | 20 +- web/src/App.jsx | 1281 ++++++++++++++++++------ web/src/assets/resources/brick.svg | 4 + web/src/assets/resources/card_back.svg | 4 + web/src/assets/resources/devcard.svg | 4 + web/src/assets/resources/grain.svg | 6 + web/src/assets/resources/lumber.svg | 5 + web/src/assets/resources/ore.svg | 4 + web/src/assets/resources/wool.svg | 3 + web/src/styles.css | 488 ++++++++- 10 files changed, 1469 insertions(+), 350 deletions(-) create mode 100644 web/src/assets/resources/brick.svg create mode 100644 web/src/assets/resources/card_back.svg create mode 100644 web/src/assets/resources/devcard.svg create mode 100644 web/src/assets/resources/grain.svg create mode 100644 web/src/assets/resources/lumber.svg create mode 100644 web/src/assets/resources/ore.svg create mode 100644 web/src/assets/resources/wool.svg diff --git a/docker-compose.yml b/docker-compose.yml index 0e81f06..7f2eaf8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,9 +12,7 @@ services: - catan-db:/var/lib/postgresql/data api: - build: - context: . - dockerfile: docker/api.Dockerfile + image: cr.danosito.com/dan/catan-api:latest restart: unless-stopped env_file: .env depends_on: @@ -27,9 +25,7 @@ services: - caddy-network game: - build: - context: . - dockerfile: docker/game.Dockerfile + image: cr.danosito.com/dan/catan-game:latest restart: unless-stopped env_file: .env depends_on: @@ -40,9 +36,7 @@ services: - ./models:/models ai: - build: - context: . - dockerfile: docker/ai.Dockerfile + image: cr.danosito.com/dan/catan-ai:latest restart: unless-stopped env_file: .env depends_on: @@ -53,9 +47,7 @@ services: - ./models:/models analytics: - build: - context: . - dockerfile: docker/analytics.Dockerfile + image: cr.danosito.com/dan/catan-analytics:latest restart: unless-stopped env_file: .env depends_on: @@ -64,9 +56,7 @@ services: - default web: - build: - context: . - dockerfile: docker/web.Dockerfile + image: cr.danosito.com/dan/catan-web:latest restart: unless-stopped depends_on: - api diff --git a/web/src/App.jsx b/web/src/App.jsx index a2ccabc..ccfde5d 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Routes, Route, Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import { apiFetch, clearToken, getToken, getUsername, setToken, wsUrl } from './api.js'; import logo from './assets/colonist/logo.png'; @@ -22,6 +22,13 @@ import settlementBlue from './assets/colonist/settlement_blue.svg'; import settlementRed from './assets/colonist/settlement_red.svg'; import settlementGreen from './assets/colonist/settlement_green.svg'; import settlementOrange from './assets/colonist/settlement_orange.svg'; +import iconBrick from './assets/resources/brick.svg'; +import iconLumber from './assets/resources/lumber.svg'; +import iconWool from './assets/resources/wool.svg'; +import iconGrain from './assets/resources/grain.svg'; +import iconOre from './assets/resources/ore.svg'; +import iconDevCard from './assets/resources/devcard.svg'; +import iconCardBack from './assets/resources/card_back.svg'; const RESOURCE_COLORS = { brick: '#c46a44', @@ -32,6 +39,24 @@ const RESOURCE_COLORS = { desert: '#d8c18f', }; +const RESOURCE_ORDER = ['brick', 'lumber', 'wool', 'grain', 'ore']; + +const RESOURCE_ICONS = { + brick: iconBrick, + lumber: iconLumber, + wool: iconWool, + grain: iconGrain, + ore: iconOre, +}; + +const RESOURCE_LABELS = { + brick: 'Brick', + lumber: 'Lumber', + wool: 'Wool', + grain: 'Grain', + ore: 'Ore', +}; + const SIDEBAR_ITEMS = [ { key: 'leaderboards', label: 'Leaderboards', icon: iconTrophy, to: '/analytics' }, { key: 'lobby', label: 'Lobby', icon: iconFriends, to: '/' }, @@ -536,13 +561,31 @@ function hexCorners(cx, cy, size) { return points; } -function Board({ board, game }) { +function Board({ + board, + game, + highlightCorners = [], + highlightEdges = [], + highlightHexes = [], + selectedCorner, + selectedEdges = [], + selectedHex, + onCornerClick, + onEdgeClick, + onHexClick, +}) { if (!board || !game) return null; const size = 46; const cornerScale = size / 3; - const hexes = Object.values(board.hexes || {}); + const hexes = Object.entries(board.hexes || {}).map(([id, hex]) => ({ + id: Number(id), + ...hex, + })); const corners = board.corners || {}; - const edges = Object.values(board.edges || {}); + const edges = Object.entries(board.edges || {}).map(([id, edge]) => ({ + id: Number(id), + ...edge, + })); const hexPositions = hexes.map((hex) => ({ ...hex, pos: cubeToPixel(hex.coord, size) })); const cornerPositions = {}; @@ -566,209 +609,136 @@ function Board({ board, game }) { playerColors[name] = pdata.color || '#333'; }); - const roadLines = edges - .filter((edge) => edge.owner) - .map((edge) => { - const a = cornerPositions[edge.a]; - const b = cornerPositions[edge.b]; - if (!a || !b) return ''; - const color = playerColors[edge.owner] || '#222'; - return ``; - }) - .join(''); - - const cornerNodes = Object.entries(cornerPositions) - .filter(([, corner]) => corner.owner) - .map(([, corner]) => { - const radius = corner.building === 'city' ? 10 : 7; - const color = playerColors[corner.owner] || '#222'; - return ``; - }) - .join(''); - - const hexShapes = hexPositions - .map((hex) => { - const points = hexCorners(hex.pos.x, hex.pos.y, size - 4) - .map((pt) => pt.join(',')) - .join(' '); - const fill = RESOURCE_COLORS[hex.resource] || '#e0c89a'; - const number = hex.number || ''; - const robber = hex.robber ? "" : ''; - return ` - - - ${number} - ${robber} - - `; - }) - .join(''); + const cornerSet = new Set(highlightCorners.map((v) => Number(v))); + const edgeSet = new Set(highlightEdges.map((v) => Number(v))); + const hexSet = new Set(highlightHexes.map((v) => Number(v))); + const selectedEdgeSet = new Set(selectedEdges.map((v) => Number(v))); return ( - + + {hexPositions.map((hex) => { + const points = hexCorners(hex.pos.x, hex.pos.y, size - 4) + .map((pt) => pt.join(',')) + .join(' '); + const fill = RESOURCE_COLORS[hex.resource] || '#e0c89a'; + const number = hex.number || ''; + const highlight = hexSet.has(hex.id); + const selected = selectedHex === hex.id; + return ( + + highlight && onHexClick && onHexClick(hex.id)} + /> + {number ? ( + + {number} + + ) : null} + {hex.robber ? ( + + ) : null} + + ); + })} + {edges.map((edge) => { + const a = cornerPositions[edge.a]; + const b = cornerPositions[edge.b]; + if (!a || !b) return null; + const highlight = edgeSet.has(edge.id); + const selected = selectedEdgeSet.has(edge.id); + return ( + + {edge.owner ? ( + + ) : null} + highlight && onEdgeClick && onEdgeClick(edge.id)} + /> + + ); + })} + {Object.entries(cornerPositions).map(([label, corner]) => { + const cornerId = Number(label); + const radius = corner.building === 'city' ? 10 : 7; + const highlight = cornerSet.has(cornerId); + const selected = selectedCorner === cornerId; + return ( + + {corner.owner ? ( + + ) : null} + highlight && onCornerClick && onCornerClick(cornerId)} + /> + + ); + })} + ); } -function ActionForm({ action, onSubmit }) { - const [payload, setPayload] = useState(action?.payload || {}); - - useEffect(() => { - setPayload(action?.payload || {}); - }, [action]); - - if (!action) { - return null; - } - - if (action.type === 'move_robber' || action.type === 'play_knight') { - const options = action.payload.options || []; - const selected = payload.hex ?? (options[0]?.hex ?? 0); - const victims = (options.find((opt) => opt.hex === selected) || {}).victims || []; - return ( -
- - - -
- ); - } - - if (action.type === 'play_road_building') { - const edges = action.payload.edges || []; - return ( -
-
Select two edges
- - -
- ); - } - - if (action.type === 'play_year_of_plenty') { - const bank = action.payload.bank || {}; - const resources = Object.keys(bank); - return ( -
- - - -
- ); - } - - if (action.type === 'play_monopoly') { - const resources = action.payload.resources || ['brick', 'lumber', 'wool', 'grain', 'ore']; - return ( -
- - -
- ); - } - - if (action.type === 'discard') { - const resources = action.payload.resources || {}; - return ( -
- {Object.entries(resources).map(([res, count]) => ( -
- {res} ({count}) - { - setPayload({ - ...payload, - cards: { ...payload.cards, [res]: parseInt(e.target.value, 10) || 0 }, - }); - }} - /> -
- ))} - -
- ); - } - - return ( - - ); +function emptyResourceMap() { + return Object.fromEntries(RESOURCE_ORDER.map((res) => [res, 0])); } function GamePage() { const { gameId } = useParams(); const [state, setState] = useState(null); const [legalActions, setLegalActions] = useState([]); - const [selectedIdx, setSelectedIdx] = useState(0); - const [trade, setTrade] = useState({ to_player: '', offer: '', request: '' }); + const [uiMode, setUiMode] = useState(null); + const [selectedCorner, setSelectedCorner] = useState(null); + const [selectedEdges, setSelectedEdges] = useState([]); + const [selectedHex, setSelectedHex] = useState(null); + const [selectedVictim, setSelectedVictim] = useState(''); + const [tradeOpen, setTradeOpen] = useState(false); + const [tradeMode, setTradeMode] = useState('player'); + const [tradeOffer, setTradeOffer] = useState(() => ({ + offer: emptyResourceMap(), + request: emptyResourceMap(), + to_player: '', + })); + const [bankTrade, setBankTrade] = useState({ give: 'brick', receive: 'ore' }); + const [discardCards, setDiscardCards] = useState(() => emptyResourceMap()); + const [yearPick, setYearPick] = useState({ first: 'brick', second: 'brick' }); + const [monopolyPick, setMonopolyPick] = useState('brick'); useEffect(() => { let socket; @@ -782,7 +752,6 @@ function GamePage() { if (msg.type === 'state') { setState(msg.data); setLegalActions(msg.data.legal_actions || []); - setSelectedIdx(0); } }; } @@ -790,47 +759,312 @@ function GamePage() { return () => socket && socket.close(); }, [gameId]); - const currentAction = legalActions[selectedIdx]; + const username = getUsername(); + const currentPlayer = state?.game?.current_player; + const isMyTurn = currentPlayer === username; + const myPlayer = state?.game?.players?.[username]; + const myResources = myPlayer?.resources || {}; - async function submitAction(payload) { - await apiFetch(`/api/games/${gameId}/action`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ type: currentAction.type, payload }), + const actionsByType = useMemo(() => { + const grouped = {}; + (legalActions || []).forEach((action) => { + if (!grouped[action.type]) { + grouped[action.type] = []; + } + grouped[action.type].push(action); }); + return grouped; + }, [legalActions]); + + const setupActions = actionsByType.place_initial || []; + const setupCornerMap = useMemo(() => { + const map = {}; + setupActions.forEach((action) => { + const corner = Number(action.payload.corner); + const road = Number(action.payload.road); + if (!map[corner]) { + map[corner] = []; + } + map[corner].push(road); + }); + return map; + }, [setupActions]); + + const buildRoadEdges = useMemo( + () => (actionsByType.build_road || []).map((action) => Number(action.payload.edge)), + [actionsByType], + ); + const buildSettlementCorners = useMemo( + () => (actionsByType.build_settlement || []).map((action) => Number(action.payload.corner)), + [actionsByType], + ); + const buildCityCorners = useMemo( + () => (actionsByType.build_city || []).map((action) => Number(action.payload.corner)), + [actionsByType], + ); + const roadBuildingEdges = useMemo(() => { + const action = (actionsByType.play_road_building || [])[0]; + const edges = action?.payload?.edges || []; + return edges.map((edge) => Number(edge)); + }, [actionsByType]); + const robberOptions = useMemo(() => { + const action = uiMode === 'play_knight' + ? (actionsByType.play_knight || [])[0] + : (actionsByType.move_robber || [])[0]; + return action?.payload?.options || []; + }, [actionsByType, uiMode]); + + const bankTradeMap = useMemo(() => { + const map = {}; + (actionsByType.trade_bank || []).forEach((action) => { + const give = action.payload.give; + const receive = action.payload.receive; + const ratio = action.payload.ratio || 4; + if (!map[give]) { + map[give] = {}; + } + map[give][receive] = ratio; + }); + return map; + }, [actionsByType]); + + const canRoll = (actionsByType.roll || []).length > 0; + const canEndTurn = (actionsByType.end_turn || []).length > 0; + const canBuyDev = (actionsByType.buy_development || []).length > 0; + const canTradeBank = (actionsByType.trade_bank || []).length > 0; + const canPlayKnight = (actionsByType.play_knight || []).length > 0; + const canPlayRoadBuilding = (actionsByType.play_road_building || []).length > 0; + const canPlayYearOfPlenty = (actionsByType.play_year_of_plenty || []).length > 0; + const canPlayMonopoly = (actionsByType.play_monopoly || []).length > 0; + + useEffect(() => { + if ((actionsByType.discard || []).length > 0) { + setUiMode('discard'); + setDiscardCards(emptyResourceMap()); + return; + } + if ((actionsByType.move_robber || []).length > 0 && uiMode !== 'play_knight') { + setUiMode('move_robber'); + return; + } + if ((actionsByType.place_initial || []).length > 0) { + setUiMode('place_initial'); + return; + } + if (['discard', 'move_robber', 'place_initial'].includes(uiMode)) { + setUiMode(null); + } + }, [actionsByType, uiMode]); + + function resetSelection() { + setSelectedCorner(null); + setSelectedEdges([]); + setSelectedHex(null); + setSelectedVictim(''); } - async function submitTrade() { - const offer = trade.offer.split(',').reduce((acc, item) => { - const [res, amt] = item.split(':').map((v) => v.trim()); - if (res && amt) acc[res] = parseInt(amt, 10) || 0; - return acc; - }, {}); - const request = trade.request.split(',').reduce((acc, item) => { - const [res, amt] = item.split(':').map((v) => v.trim()); - if (res && amt) acc[res] = parseInt(amt, 10) || 0; - return acc; - }, {}); + function startMode(mode) { + setUiMode(mode); + resetSelection(); + } + + async function sendAction(type, payload = {}) { + try { + await apiFetch(`/api/games/${gameId}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, payload }), + }); + } catch (err) { + window.alert(err.message); + } + } + + async function submitTradeOffer() { + const offer = Object.fromEntries( + Object.entries(tradeOffer.offer).filter(([, amt]) => amt > 0), + ); + const request = Object.fromEntries( + Object.entries(tradeOffer.request).filter(([, amt]) => amt > 0), + ); + if (!Object.keys(offer).length || !Object.keys(request).length) { + return; + } await apiFetch(`/api/games/${gameId}/trade/offer`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - from_player: getUsername(), - to_player: trade.to_player || null, + from_player: username, + to_player: tradeOffer.to_player || null, offer, request, }), }); + setTradeOffer({ offer: emptyResourceMap(), request: emptyResourceMap(), to_player: '' }); + setTradeOpen(false); } async function respondTrade(tradeId, accept) { await apiFetch(`/api/games/${gameId}/trade/${tradeId}/respond`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ player: getUsername(), accept }), + body: JSON.stringify({ player: username, accept }), }); } + function handleCornerClick(label) { + if (!isMyTurn) return; + const corner = Number(label); + if (uiMode === 'place_initial') { + if (setupCornerMap[corner]) { + setSelectedCorner(corner); + } + return; + } + if (uiMode === 'build_settlement' && buildSettlementCorners.includes(corner)) { + sendAction('build_settlement', { corner }); + setUiMode(null); + resetSelection(); + return; + } + if (uiMode === 'build_city' && buildCityCorners.includes(corner)) { + sendAction('build_city', { corner }); + setUiMode(null); + resetSelection(); + } + } + + function handleEdgeClick(label) { + if (!isMyTurn) return; + const edge = Number(label); + if (uiMode === 'build_road' && buildRoadEdges.includes(edge)) { + sendAction('build_road', { edge }); + setUiMode(null); + resetSelection(); + return; + } + if (uiMode === 'place_initial' && selectedCorner) { + const options = setupCornerMap[selectedCorner] || []; + if (options.includes(edge)) { + sendAction('place_initial', { corner: selectedCorner, road: edge }); + setUiMode(null); + resetSelection(); + } + return; + } + if (uiMode === 'play_road_building' && roadBuildingEdges.includes(edge)) { + const updated = selectedEdges.includes(edge) + ? selectedEdges.filter((id) => id !== edge) + : [...selectedEdges, edge].slice(0, 2); + setSelectedEdges(updated); + if (updated.length === 2) { + sendAction('play_road_building', { edges: updated }); + setUiMode(null); + resetSelection(); + } + } + } + + function handleHexClick(hexId) { + if (!isMyTurn) return; + if (!['move_robber', 'play_knight'].includes(uiMode)) return; + const option = robberOptions.find((opt) => opt.hex === hexId); + if (!option) return; + setSelectedHex(hexId); + if (!option.victims || option.victims.length <= 1) { + const victim = option.victims?.[0]; + const actionType = uiMode === 'play_knight' ? 'play_knight' : 'move_robber'; + sendAction(actionType, { hex: hexId, victim }); + setUiMode(null); + resetSelection(); + } else { + setSelectedVictim(''); + } + } + + function confirmRobberSelection() { + if (!selectedHex) return; + const option = robberOptions.find((opt) => opt.hex === selectedHex); + if (!option) return; + if (option.victims?.length && !selectedVictim) return; + const actionType = uiMode === 'play_knight' ? 'play_knight' : 'move_robber'; + sendAction(actionType, { hex: selectedHex, victim: selectedVictim || undefined }); + setUiMode(null); + resetSelection(); + } + + function updateTradeAmount(group, resource, delta, max) { + setTradeOffer((prev) => { + const next = { ...prev, [group]: { ...prev[group] } }; + const current = next[group][resource] || 0; + const updated = Math.max(0, Math.min(max, current + delta)); + next[group][resource] = updated; + return next; + }); + } + + function updateDiscard(resource, delta, max) { + setDiscardCards((prev) => { + const next = { ...prev }; + const current = next[resource] || 0; + next[resource] = Math.max(0, Math.min(max, current + delta)); + return next; + }); + } + + const discardAction = (actionsByType.discard || [])[0]; + const discardRequired = discardAction?.payload?.required || 0; + const discardResources = discardAction?.payload?.resources || {}; + const discardTotal = Object.values(discardCards).reduce((sum, value) => sum + value, 0); + const canSubmitDiscard = discardRequired > 0 && discardTotal === discardRequired; + + const highlightCorners = useMemo(() => { + if (uiMode === 'build_settlement') return buildSettlementCorners; + if (uiMode === 'build_city') return buildCityCorners; + if (uiMode === 'place_initial') { + if (selectedCorner) return []; + return Object.keys(setupCornerMap).map((key) => Number(key)); + } + return []; + }, [uiMode, buildSettlementCorners, buildCityCorners, setupCornerMap, selectedCorner]); + + const highlightEdges = useMemo(() => { + if (uiMode === 'build_road') return buildRoadEdges; + if (uiMode === 'play_road_building') return roadBuildingEdges; + if (uiMode === 'place_initial' && selectedCorner) { + return setupCornerMap[selectedCorner] || []; + } + return []; + }, [uiMode, buildRoadEdges, roadBuildingEdges, setupCornerMap, selectedCorner]); + + const highlightHexes = useMemo(() => { + if (uiMode === 'move_robber' || uiMode === 'play_knight') { + return robberOptions.map((opt) => opt.hex); + } + return []; + }, [uiMode, robberOptions]); + + const robberVictims = useMemo(() => { + if (!selectedHex) return []; + const option = robberOptions.find((opt) => opt.hex === selectedHex); + return option?.victims || []; + }, [robberOptions, selectedHex]); + + const bankGiveOptions = Object.keys(bankTradeMap); + const bankReceiveOptions = bankTrade.give ? Object.keys(bankTradeMap[bankTrade.give] || {}) : []; + const bankRatio = bankTradeMap[bankTrade.give]?.[bankTrade.receive] || null; + + const tradeDisabled = !isMyTurn || canRoll || uiMode === 'discard' || uiMode === 'move_robber'; + const actionMessage = { + build_road: 'Select a road to build.', + build_settlement: 'Select a settlement spot.', + build_city: 'Select a settlement to upgrade.', + place_initial: selectedCorner ? 'Select a connected road.' : 'Place your initial settlement.', + play_road_building: selectedEdges.length ? 'Select the second road.' : 'Select two roads.', + move_robber: 'Move the robber.', + play_knight: 'Move the robber.', + }[uiMode]; + if (!state) { return ( @@ -841,113 +1075,534 @@ function GamePage() { return ( -
- -
- -
- -
+ + )}
); } @@ -1070,7 +1725,7 @@ function ReplayViewer() {
Step {step}
-
+
diff --git a/web/src/assets/resources/brick.svg b/web/src/assets/resources/brick.svg new file mode 100644 index 0000000..f971b08 --- /dev/null +++ b/web/src/assets/resources/brick.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/assets/resources/card_back.svg b/web/src/assets/resources/card_back.svg new file mode 100644 index 0000000..c267c49 --- /dev/null +++ b/web/src/assets/resources/card_back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/assets/resources/devcard.svg b/web/src/assets/resources/devcard.svg new file mode 100644 index 0000000..a7318bd --- /dev/null +++ b/web/src/assets/resources/devcard.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/assets/resources/grain.svg b/web/src/assets/resources/grain.svg new file mode 100644 index 0000000..0b0dbad --- /dev/null +++ b/web/src/assets/resources/grain.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/assets/resources/lumber.svg b/web/src/assets/resources/lumber.svg new file mode 100644 index 0000000..980ea5a --- /dev/null +++ b/web/src/assets/resources/lumber.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/assets/resources/ore.svg b/web/src/assets/resources/ore.svg new file mode 100644 index 0000000..fb356e5 --- /dev/null +++ b/web/src/assets/resources/ore.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/assets/resources/wool.svg b/web/src/assets/resources/wool.svg new file mode 100644 index 0000000..a7c6b0c --- /dev/null +++ b/web/src/assets/resources/wool.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/src/styles.css b/web/src/styles.css index 252d8d0..54edcda 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -855,24 +855,113 @@ textarea { background: #d95f00; } -.game-layout { - display: grid; - grid-template-columns: 260px 1fr 260px; - gap: 16px; - width: 100%; - height: 100%; - overflow: hidden; -} - -.game-panel { +.game-shell { display: flex; flex-direction: column; - gap: 16px; - overflow: auto; + gap: 12px; + width: 100%; + height: 100%; + min-height: 0; } -.game-board { +.game-topbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 12px; border-radius: 12px; + background: rgba(0, 0, 0, 0.2); +} + +.player-chip { + display: flex; + gap: 10px; + align-items: center; + padding: 8px 12px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.25); + min-width: 180px; +} + +.player-chip-body { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.player-chip--active { + border: 2px solid #48ff00; +} + +.player-color { + width: 12px; + height: 12px; + border-radius: 999px; +} + +.player-name { + font-weight: bold; +} + +.player-meta { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); +} + +.player-hand { + display: flex; + align-items: center; + gap: 8px; +} + +.card-stack { + position: relative; + width: 44px; + height: 34px; + display: flex; + align-items: center; + justify-content: center; +} + +.card-back { + width: 30px; + height: 30px; +} + +.card-back--offset { + position: absolute; + left: 6px; + top: 4px; + opacity: 0.85; +} + +.card-stack--dev { + width: 34px; +} + +.card-count { + position: absolute; + bottom: -6px; + right: -6px; + background: rgba(0, 0, 0, 0.65); + color: #fff; + border-radius: 999px; + font-size: 0.7rem; + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.game-main { + display: grid; + grid-template-columns: 1fr 300px; + gap: 12px; + flex: 1; + min-height: 0; +} + +.game-board-area { + border-radius: 16px; background: rgba(0, 0, 0, 0.15); display: flex; align-items: center; @@ -881,21 +970,122 @@ textarea { overflow: hidden; } -.board-svg { - width: 100%; - height: 100%; +.game-side { + display: flex; + flex-direction: column; + gap: 12px; + overflow: auto; } -.player-row { +.game-bottom { + display: flex; + flex-direction: column; + gap: 10px; +} + +.resource-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; + padding: 10px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.2); +} + +.resource-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 90px; + padding: 8px 10px; + border-radius: 12px; + color: #1b1b1b; + font-weight: bold; +} + +.resource-icon { + width: 30px; + height: 30px; +} + +.resource-card-label { + font-size: 0.8rem; +} + +.resource-card-count { + font-size: 1.1rem; +} + +.resource-card--dev { + background: rgba(0, 0, 0, 0.3); + color: #fff; +} + +.action-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} + +.action-button { + border: 0; + border-radius: 8px; + background: rgba(0, 0, 0, 0.25); + color: #fff; + font-weight: bold; + padding: 10px 14px; + cursor: pointer; + transition: all 0.15s ease; +} + +.action-button:hover { + background: rgba(0, 0, 0, 0.4); +} + +.action-button--primary { + background: #f06800; +} + +.action-button--primary:hover { + background: #bd5200; +} + +.action-button--active { + border: 2px solid #48ff00; +} + +.action-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.action-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.2); +} + +.robber-panel { display: flex; gap: 10px; align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.2); } -.player-dot { - width: 12px; - height: 12px; - border-radius: 999px; +.board-svg { + width: 100%; + height: 100%; } .trade-card { @@ -919,6 +1109,260 @@ textarea { overflow: auto; } +.trade-panel { + display: flex; + flex-direction: column; + gap: 12px; +} + +.trade-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.trade-offer-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.trade-offer { + border-radius: 12px; + padding: 10px; + background: rgba(0, 0, 0, 0.25); +} + +.trade-offer-meta { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 6px; +} + +.trade-offer-row { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.trade-offer-label { + font-weight: bold; + min-width: 60px; +} + +.trade-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.trade-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.3); + font-size: 0.75rem; +} + +.trade-chip-icon { + width: 16px; + height: 16px; +} + +.trade-chip--brick { + background: #c46a44; +} + +.trade-chip--lumber { + background: #4a6d4a; +} + +.trade-chip--wool { + background: #8cc071; +} + +.trade-chip--grain { + background: #e2c065; +} + +.trade-chip--ore { + background: #7a7c83; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.trade-modal { + width: min(640px, 90vw); + max-height: 90vh; + overflow: auto; + padding: 20px; + border-radius: 16px; + background: #1062b0; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + gap: 16px; +} + +.trade-tabs { + display: flex; + gap: 8px; +} + +.trade-tab { + flex: 1; + padding: 8px 12px; + border-radius: 10px; + border: 0; + background: rgba(0, 0, 0, 0.2); + color: #fff; + font-weight: bold; + cursor: pointer; +} + +.trade-tab.active { + background: #1e90ff; +} + +.trade-tab:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.trade-content { + display: flex; + flex-direction: column; + gap: 14px; +} + +.trade-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +.trade-section h4 { + margin-bottom: 8px; +} + +.trade-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; +} + +.trade-resource { + padding: 8px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.2); +} + +.trade-resource-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + margin-bottom: 6px; +} + +.trade-resource-icon { + width: 18px; + height: 18px; +} + +.trade-stepper { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} + +.stepper-btn { + border: 0; + border-radius: 8px; + width: 28px; + height: 28px; + background: rgba(0, 0, 0, 0.3); + color: #fff; + font-weight: bold; + cursor: pointer; +} + +.stepper-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.stepper-value { + min-width: 24px; + text-align: center; +} + +.trade-actions-row { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.hex { + transition: all 0.15s ease; + pointer-events: all; +} + +.hex--highlight { + stroke: #48ff00; + stroke-width: 3; + cursor: pointer; +} + +.hex--selected { + stroke: #f4ff00; + stroke-width: 4; +} + +.edge-hit { + stroke: transparent; + cursor: default; + pointer-events: stroke; +} + +.edge-hit--active { + stroke: rgba(255, 255, 255, 0.2); + cursor: pointer; +} + +.edge-hit--selected { + stroke: rgba(244, 255, 0, 0.6); +} + +.corner-hit { + fill: transparent; + stroke: transparent; + cursor: default; + pointer-events: all; +} + +.corner-hit--active { + stroke: rgba(255, 255, 255, 0.5); + cursor: pointer; +} + +.corner-hit--selected { + stroke: rgba(244, 255, 0, 0.8); +} + .replay-table { display: flex; flex-direction: column; @@ -1071,7 +1515,7 @@ textarea { display: none; } - .game-layout { + .game-main { grid-template-columns: 1fr; } }