diff --git a/src/App.css b/src/App.css index 9ab784040..60136b471 100644 --- a/src/App.css +++ b/src/App.css @@ -54,7 +54,7 @@ .game-board__background { position: absolute; inset: 0; - background-size: cover; + background-size: 100% 100%; background-position: center; filter: saturate(1.05) contrast(1.05); } @@ -78,6 +78,13 @@ justify-content: center; padding-bottom: 10px; cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.game-board__slot:hover { + border-color: rgba(56, 189, 248, 0.7); + box-shadow: 0 0 18px rgba(56, 189, 248, 0.35); + background: rgba(15, 23, 42, 0.55); } .game-board__slot span { @@ -90,9 +97,28 @@ display: flex; align-items: stretch; justify-content: stretch; + border: 2px solid rgba(15, 23, 42, 0.45); box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35); cursor: pointer; overflow: hidden; + background: rgba(15, 23, 42, 0.15); + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.game-board__card::after { + content: ''; + position: absolute; + inset: 6px; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.25); + pointer-events: none; + opacity: 0.8; +} + +.game-board__card:hover { + transform: translateY(-4px); + border-color: rgba(56, 189, 248, 0.6); + box-shadow: 0 18px 30px rgba(0, 0, 0, 0.45), 0 0 18px rgba(56, 189, 248, 0.3); } .game-board__card img { @@ -106,6 +132,24 @@ box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.3), 0 12px 24px rgba(0, 0, 0, 0.4); } +.game-board__card--spawn { + animation: card-surge 0.9s ease-out; +} + +.game-board__card--spawn::before { + content: ''; + position: absolute; + inset: -15%; + background: + radial-gradient(circle at 20% 30%, rgba(148, 197, 255, 0.8), rgba(148, 197, 255, 0) 60%), + radial-gradient(circle at 80% 20%, rgba(250, 204, 21, 0.9), rgba(250, 204, 21, 0) 55%), + radial-gradient(circle at 50% 80%, rgba(59, 130, 246, 0.8), rgba(59, 130, 246, 0) 65%); + mix-blend-mode: screen; + opacity: 0.9; + animation: card-flash 1s ease-out; + pointer-events: none; +} + .game-board__activate { position: absolute; bottom: 10px; @@ -126,6 +170,79 @@ box-shadow: 0 0 20px rgba(251, 191, 36, 0.45); } +.game-board__card--corruption-success { + animation: corruption-success 1.2s ease-out; + border-color: rgba(34, 197, 94, 0.7); + box-shadow: 0 0 26px rgba(34, 197, 94, 0.6), 0 12px 28px rgba(0, 0, 0, 0.45); +} + +.game-board__card--corruption-fail { + animation: corruption-fail 0.9s ease-out; + border-color: rgba(248, 113, 113, 0.7); + box-shadow: 0 0 24px rgba(248, 113, 113, 0.5), 0 12px 28px rgba(0, 0, 0, 0.45); +} + +@keyframes card-surge { + 0% { + transform: scale(0.96); + box-shadow: 0 0 0 rgba(59, 130, 246, 0.0); + } + 45% { + transform: scale(1.02); + box-shadow: 0 0 30px rgba(59, 130, 246, 0.45); + } + 100% { + transform: scale(1); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35); + } +} + +@keyframes card-flash { + 0% { + opacity: 0; + transform: scale(0.6); + } + 30% { + opacity: 1; + } + 100% { + opacity: 0; + transform: scale(1.1); + } +} + +@keyframes corruption-success { + 0% { + transform: scale(0.95) rotate(-1deg); + filter: brightness(1.4); + } + 40% { + transform: scale(1.05) rotate(1deg); + } + 100% { + transform: scale(1); + filter: brightness(1); + } +} + +@keyframes corruption-fail { + 0% { + transform: translateX(0); + } + 25% { + transform: translateX(-6px); + } + 50% { + transform: translateX(6px); + } + 75% { + transform: translateX(-4px); + } + 100% { + transform: translateX(0); + } +} + .game-dice { position: fixed; left: 16px; @@ -140,6 +257,32 @@ border: 2px solid rgba(251, 191, 36, 0.7); } +.game-dice--rolling { + animation: dicePulse 0.9s ease-in-out; +} + +.game-dice--success { + border: 2px solid rgba(34, 197, 94, 0.85); + box-shadow: 0 0 18px rgba(34, 197, 94, 0.65), 0 0 36px rgba(34, 197, 94, 0.35); +} + +.game-dice--fail { + border: 2px solid rgba(248, 113, 113, 0.9); + box-shadow: 0 0 18px rgba(248, 113, 113, 0.65), 0 0 36px rgba(248, 113, 113, 0.35); +} + +@keyframes dicePulse { + 0% { + transform: scale(1); + } + 45% { + transform: scale(1.08); + } + 100% { + transform: scale(1); + } +} + .action-hint { position: fixed; bottom: 24px; diff --git a/src/App.tsx b/src/App.tsx index c417491f8..c1c41e788 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,10 @@ function AppContent() { // 🎲 DICE: Toggle zwischen 3D und einfachem Würfel (Standard: einfacher Würfel für bessere Kompatibilität) const [useSimpleDice, setUseSimpleDice] = useState(true); + const [diceOutcome, setDiceOutcome] = useState<'success' | 'fail' | null>(null); + const diceOutcomeTimer = useRef(null); + const [diceRolling, setDiceRolling] = useState(false); + const diceRollingTimer = useRef(null); // UI Layout Editor Route const [currentRoute, setCurrentRoute] = useState<'game' | 'ui-editor' | 'test-suite' | 'qte' | 'sprite-demo'>('game'); @@ -60,12 +64,51 @@ function AppContent() { startMatchVsAI, playCard, activateInstantInitiative, + startNewGame, runAITurn, selectHandCard, passTurn, nextTurn, } = useGameState(); - const corruptionActive = (gameState as any).pendingAbilitySelect?.type === 'corruption_steal'; + const pendingAbility = (gameState as any).pendingAbilitySelect?.type; + const corruptionActive = pendingAbility === 'corruption_steal'; + const maulwurfActive = pendingAbility === 'maulwurf_steal'; + + useEffect(() => { + const handleCorruptionResolved = (event: Event) => { + const detail = (event as CustomEvent).detail as { success?: boolean }; + const success = Boolean(detail?.success); + setDiceOutcome(success ? 'success' : 'fail'); + if (diceOutcomeTimer.current) window.clearTimeout(diceOutcomeTimer.current); + diceOutcomeTimer.current = window.setTimeout(() => { + setDiceOutcome(null); + diceOutcomeTimer.current = null; + }, 1400); + }; + + const handleCorruptionRoll = () => { + setDiceRolling(true); + if (diceRollingTimer.current) window.clearTimeout(diceRollingTimer.current); + diceRollingTimer.current = window.setTimeout(() => { + setDiceRolling(false); + diceRollingTimer.current = null; + }, 1100); + }; + + window.addEventListener('pc:corruption_resolved', handleCorruptionResolved as EventListener); + window.addEventListener('pc:corruption_roll_started', handleCorruptionRoll as EventListener); + return () => { + window.removeEventListener('pc:corruption_resolved', handleCorruptionResolved as EventListener); + window.removeEventListener('pc:corruption_roll_started', handleCorruptionRoll as EventListener); + }; + }, []); + + useEffect(() => ( + () => { + if (diceOutcomeTimer.current) window.clearTimeout(diceOutcomeTimer.current); + if (diceRollingTimer.current) window.clearTimeout(diceRollingTimer.current); + } + ), []); const actionHint = useMemo(() => { if (deckBuilderOpen) return null; @@ -75,6 +118,12 @@ function AppContent() { body: 'Wähle eine gegnerische Regierungs-Karte (gelb markiert) und würfle danach mit dem Dice.', }; } + if (maulwurfActive) { + return { + title: 'Maulwurf aktiv', + body: 'Ziel ist markiert. Würfle, um die Übernahme zu prüfen.', + }; + } if (selectedHandIndex !== null) { return { title: 'Slot wählen', @@ -608,6 +657,7 @@ function AppContent() { onPassTurn={passTurn} onToggleLog={() => setGameLogModalOpen(!gameLogModalOpen)} onCardClick={handleCardClick} + onRestartGame={startNewGame} devMode={devMode} /> )} @@ -650,7 +700,7 @@ function AppContent() { {/* Dice - Dev utility with fallback */} -
+
{useSimpleDice ? ( = ({ }, []); const normalizePresetDeck = useCallback((entries: BuilderEntry[]) => { - const MAX_CARDS = 10; - const MIN_CARDS = 5; - const MIN_GOV = 5; - const MIN_BUDGET = 50; - const MAX_BUDGET = 69; + const MAX_CARDS = 15; + const MIN_CARDS = 10; + const MIN_GOV = 6; + const MIN_BUDGET = 75; + const MAX_BUDGET = 105; const availablePols = Pols.filter(p => !disabledCards.has(p.name)); const availableSpecs = Specials.filter(s => !disabledCards.has(s.name)); @@ -302,6 +302,13 @@ export const DeckBuilder: React.FC = ({ const preset = PRESETS[idx]; if (!preset) return; const newDeck: BuilderEntry[] = []; + const extraNames = [ + 'Bestechungsskandal 2.0', + 'Maulwurf', + 'Greta Thunberg', + 'Mark Zuckerberg', + 'Symbolpolitik' + ]; preset.cards.forEach(name => { if (disabledCards.has(name)) return; const pol = Pols.find((p: BasePolitician) => p.name === name); @@ -316,6 +323,27 @@ export const DeckBuilder: React.FC = ({ } // name not found -> ignore }); + extraNames.forEach(name => { + if (disabledCards.has(name)) return; + const exists = newDeck.some(entry => { + if (entry.kind === 'pol') { + const pol = Pols.find((p: BasePolitician) => p.id === entry.baseId); + return pol?.name === name; + } + const spec = Specials.find((s: BaseSpecial) => s.id === entry.baseId); + return spec?.name === name; + }); + if (exists) return; + const pol = Pols.find((p: BasePolitician) => p.name === name); + if (pol) { + newDeck.push({ kind: 'pol', baseId: pol.id, count: 1 }); + return; + } + const spec = Specials.find((s: BaseSpecial) => s.name === name); + if (spec) { + newDeck.push({ kind: 'spec', baseId: spec.id, count: 1 }); + } + }); setDeck(normalizePresetDeck(newDeck)); }, [disabledCards, normalizePresetDeck]); @@ -330,8 +358,8 @@ export const DeckBuilder: React.FC = ({ return sum; }, 0); - // Deck validation: minimum 5 government cards, maximum 10 total cards, budget 50-69 BP - const isDeckValid = count >= 5 && governmentCount >= 5 && count <= 10 && budget >= 50 && budget <= 69; + // Deck validation: minimum 6 government cards, maximum 15 total cards, budget 75-105 BP + const isDeckValid = count >= 10 && governmentCount >= 6 && count <= 15 && budget >= 75 && budget <= 105; // Helper function to get category color for a card const getCategoryColor = (kind: 'pol' | 'spec', base: BasePolitician | BaseSpecial) => { @@ -502,8 +530,8 @@ export const DeckBuilder: React.FC = ({ // Check if card is disabled if (disabledCards.has(base.name)) return false; - // Check deck size limit (10 cards maximum) - if (count >= 10) return false; + // Check deck size limit (15 cards maximum) + if (count >= 15) return false; const tier = kind === 'spec' ? (base as BaseSpecial).tier : (base as BasePolitician).T; const limit = tier >= 3 ? 1 : 2; @@ -511,7 +539,7 @@ export const DeckBuilder: React.FC = ({ const already = entry ? entry.count : 0; const cost = kind === 'pol' ? ((base as BasePolitician).BP ?? 0) : (base as BaseSpecial).bp; - return already < limit && (budget + cost) <= 69; + return already < limit && (budget + cost) <= 105; }, [deck, budget, count, disabledCards]); const builderAdd = useCallback((base: BasePolitician | BaseSpecial, kind: 'pol' | 'spec') => { @@ -547,7 +575,7 @@ export const DeckBuilder: React.FC = ({ const handleApplyDeck = useCallback(() => { - const isDeckValid = count >= 5 && governmentCount >= 5 && count <= 10 && budget >= 50 && budget <= 69; + const isDeckValid = count >= 10 && governmentCount >= 6 && count <= 15 && budget >= 75 && budget <= 105; if (isDeckValid) { onApplyDeck(deck); onClose(); @@ -625,7 +653,7 @@ export const DeckBuilder: React.FC = ({ }, [deck, onStartMatch, onClose]); const handleStartVsAI = useCallback(() => { - const isDeckValid = count >= 5 && governmentCount >= 5 && count <= 10 && budget >= 50 && budget <= 69; + const isDeckValid = count >= 10 && governmentCount >= 6 && count <= 15 && budget >= 75 && budget <= 105; if (!isDeckValid || !onStartVsAI) return; const p1Deck: BuilderEntry[] = deck.length ? deck : []; @@ -783,13 +811,13 @@ export const DeckBuilder: React.FC = ({ if (!isOpen) return null; - const BP_LIMIT = 69; // Budget limit - const BP_MIN = 50; // Minimum budget + const BP_LIMIT = 105; // Budget limit + const BP_MIN = 75; // Minimum budget const overBudget = budget > BP_LIMIT; const underBudget = budget < BP_MIN; - const overCount = count > 10; - const underMinGovernment = governmentCount < 5; - const underMinCards = count < 5; + const overCount = count > 15; + const underMinGovernment = governmentCount < 6; + const underMinCards = count < 10; const isValid = !overBudget && !underBudget && !overCount && !underMinGovernment && !underMinCards; return ( @@ -830,7 +858,7 @@ export const DeckBuilder: React.FC = ({ border: '1px solid #203043', fontSize: '12px', }}> - Budget (BP): {budget} / 69 (Min: 50) + Budget (BP): {budget} / 105 (Min: 75) = ({ border: '1px solid #203043', fontSize: '12px', }}> - Deck: {count}/10 + Deck: {count}/15
@@ -862,7 +890,7 @@ export const DeckBuilder: React.FC = ({ gap: '6px', }} disabled={!isValid} - title={!isValid ? `Deck muss gültig sein: 5-10 Karten, ≥5 Government, 50-69 BP (aktuell: ${budget} BP)` : 'Starte Spiel gegen KI'} + title={!isValid ? `Deck muss gültig sein: 10-15 Karten, ≥6 Government, 75-105 BP (aktuell: ${budget} BP)` : 'Starte Spiel gegen KI'} > 🤖 Start vs KI @@ -1287,29 +1315,29 @@ export const DeckBuilder: React.FC = ({
= 5 ? '#1f2937' : '#7f1d1d', - color: governmentCount >= 5 ? '#d1d5db' : '#fca5a5', - border: `1px solid ${governmentCount >= 5 ? '#374151' : '#dc2626'}`, + background: governmentCount >= 6 ? '#1f2937' : '#7f1d1d', + color: governmentCount >= 6 ? '#d1d5db' : '#fca5a5', + border: `1px solid ${governmentCount >= 6 ? '#374151' : '#dc2626'}`, }}> - Gov: {governmentCount}/5+ + Gov: {governmentCount}/6+
- Cards: {count}/10 + Cards: {count}/15
= 50 && budget <= 69) ? '#1f2937' : '#7f1d1d', - color: (budget >= 50 && budget <= 69) ? '#d1d5db' : '#fca5a5', - border: `1px solid ${(budget >= 50 && budget <= 69) ? '#374151' : '#dc2626'}`, + background: (budget >= 75 && budget <= 105) ? '#1f2937' : '#7f1d1d', + color: (budget >= 75 && budget <= 105) ? '#d1d5db' : '#fca5a5', + border: `1px solid ${(budget >= 75 && budget <= 105) ? '#374151' : '#dc2626'}`, }}> - Budget: {budget}/69 + Budget: {budget}/105
diff --git a/src/components/GameBoard.tsx b/src/components/GameBoard.tsx index 416509d99..347ea224c 100644 --- a/src/components/GameBoard.tsx +++ b/src/components/GameBoard.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Card, GameState } from '../types/game'; import { getCardImagePath } from '../data/gameData'; import { LAYOUT, UI_BASE, computeSlotRects, getGovernmentRects, getPublicRects, getSofortRect, getUiTransform, getZone } from '../ui/layout'; @@ -44,8 +44,129 @@ const GameBoard: React.FC = ({ }) => { const { ref: boardRef, size } = useBoardSize(); const transform = useMemo(() => getUiTransform(size.width, size.height), [size.height, size.width]); - const corruptionActive = (gameState as any).pendingAbilitySelect?.type === 'corruption_steal'; + const pendingAbility = (gameState as any).pendingAbilitySelect; + const corruptionActive = pendingAbility?.type === 'corruption_steal'; + const maulwurfTargetUid = pendingAbility?.type === 'maulwurf_steal' ? pendingAbility?.targetUid : null; const corruptionTargetPlayer = gameState.current === 1 ? 2 : 1; + const [recentlyPlayed, setRecentlyPlayed] = useState>(new Set()); + const previousBoardUids = useRef>(new Set()); + const removalTimers = useRef>(new Map()); + const [corruptionHold, setCorruptionHold] = useState<{ player: 1 | 2 | null }>({ player: null }); + const corruptionHoldTimer = useRef(null); + const [corruptionSuccessUids, setCorruptionSuccessUids] = useState>(new Set()); + const [corruptionFailUids, setCorruptionFailUids] = useState>(new Set()); + const corruptionResultTimers = useRef>(new Map()); + + useEffect(() => { + const currentUids = new Set(); + const addCard = (card?: Card | null) => { + if (card) currentUids.add(card.uid); + }; + + ([1, 2] as const).forEach((player) => { + gameState.board[player].innen.forEach(addCard); + gameState.board[player].aussen.forEach(addCard); + addCard(gameState.board[player].sofort[0]); + addCard((gameState.traps[player] || [])[0]); + addCard(gameState.permanentSlots[player].government); + addCard(gameState.permanentSlots[player].public); + }); + + const newUids: number[] = []; + currentUids.forEach((uid) => { + if (!previousBoardUids.current.has(uid)) { + newUids.push(uid); + } + }); + + if (newUids.length) { + setRecentlyPlayed((prev) => { + const next = new Set(prev); + newUids.forEach((uid) => next.add(uid)); + return next; + }); + + newUids.forEach((uid) => { + const existingTimer = removalTimers.current.get(uid); + if (existingTimer) window.clearTimeout(existingTimer); + const timer = window.setTimeout(() => { + setRecentlyPlayed((prev) => { + const next = new Set(prev); + next.delete(uid); + return next; + }); + removalTimers.current.delete(uid); + }, 1200); + removalTimers.current.set(uid, timer); + }); + } + + previousBoardUids.current = currentUids; + }, [gameState]); + + useEffect(() => ( + () => { + removalTimers.current.forEach((timer) => window.clearTimeout(timer)); + removalTimers.current.clear(); + } + ), []); + + useEffect(() => { + const handleCorruptionRoll = (event: Event) => { + const detail = (event as CustomEvent).detail as { victim?: 1 | 2 }; + if (!detail?.victim) return; + setCorruptionHold({ player: detail.victim }); + if (corruptionHoldTimer.current) { + window.clearTimeout(corruptionHoldTimer.current); + } + corruptionHoldTimer.current = window.setTimeout(() => { + setCorruptionHold({ player: null }); + corruptionHoldTimer.current = null; + }, 1200); + }; + + const handleCorruptionResolved = (event: Event) => { + const detail = (event as CustomEvent).detail as { targetUid?: number; success?: boolean }; + if (!detail?.targetUid) return; + const targetUid = detail.targetUid; + const isSuccess = Boolean(detail.success); + const setResult = isSuccess ? setCorruptionSuccessUids : setCorruptionFailUids; + setResult((prev) => { + const next = new Set(prev); + next.add(targetUid); + return next; + }); + + const existingTimer = corruptionResultTimers.current.get(targetUid); + if (existingTimer) window.clearTimeout(existingTimer); + const timer = window.setTimeout(() => { + setResult((prev) => { + const next = new Set(prev); + next.delete(targetUid); + return next; + }); + corruptionResultTimers.current.delete(targetUid); + }, 1400); + corruptionResultTimers.current.set(targetUid, timer); + }; + + window.addEventListener('pc:corruption_roll_started', handleCorruptionRoll as EventListener); + window.addEventListener('pc:corruption_resolved', handleCorruptionResolved as EventListener); + return () => { + window.removeEventListener('pc:corruption_roll_started', handleCorruptionRoll as EventListener); + window.removeEventListener('pc:corruption_resolved', handleCorruptionResolved as EventListener); + }; + }, []); + + useEffect(() => ( + () => { + if (corruptionHoldTimer.current) { + window.clearTimeout(corruptionHoldTimer.current); + } + corruptionResultTimers.current.forEach((timer) => window.clearTimeout(timer)); + corruptionResultTimers.current.clear(); + } + ), []); const handleHover = useCallback( (card: Card | null, event?: React.MouseEvent) => { @@ -66,12 +187,7 @@ const GameBoard: React.FC = ({ ) => (
void } - ) => ( -
onCardClick(data)} onMouseEnter={(event) => handleHover(card, event)} @@ -105,7 +221,6 @@ const GameBoard: React.FC = ({ key={key} type="button" className={`game-board__slot${highlight ? ' game-board__slot--corruption' : ''}`} - className="game-board__slot" style={style} onClick={onClick} onMouseLeave={() => onCardHover(null)} @@ -119,7 +234,13 @@ const GameBoard: React.FC = ({ lane: 'aussen' | 'innen', label: string, ) => { - const isCorruptionTarget = corruptionActive && lane === 'aussen' && player === corruptionTargetPlayer; + const shouldHighlightCorruption = ( + lane === 'aussen' + && ( + (corruptionActive && player === corruptionTargetPlayer) + || (corruptionHold.player && player === corruptionHold.player) + ) + ); const rects = lane === 'aussen' ? getGovernmentRects(player === 1 ? 'player' : 'opponent') : getPublicRects(player === 1 ? 'player' : 'opponent'); @@ -134,13 +255,15 @@ const GameBoard: React.FC = ({ style, label, () => onCardClick({ type: 'row_slot', player, lane, index }), - isCorruptionTarget, - ); - } - return renderCard(card, style, { type: 'board_card', player, lane, index, card }, { highlight: isCorruptionTarget }); + false, ); } - return renderCard(card, style, { type: 'board_card', player, lane, index, card }); + return renderCard( + card, + style, + { type: 'board_card', player, lane, index, card }, + { highlight: shouldHighlightCorruption || (maulwurfTargetUid && card.uid === maulwurfTargetUid) }, + ); }); }; diff --git a/src/components/GameInfoModal.tsx b/src/components/GameInfoModal.tsx index 16bcfe47a..c961cc7cf 100644 --- a/src/components/GameInfoModal.tsx +++ b/src/components/GameInfoModal.tsx @@ -10,6 +10,7 @@ interface GameInfoModalProps { onPassTurn: (player: 1 | 2) => void; onToggleLog: () => void; onCardClick: (data: any) => void; + onRestartGame: () => void; devMode?: boolean; } @@ -30,6 +31,7 @@ export const GameInfoModal: React.FC = ({ onPassTurn, onToggleLog, onCardClick, + onRestartGame, devMode = false }) => { const [position, setPosition] = useState({ x: 50, y: 50 }); @@ -592,6 +594,26 @@ export const GameInfoModal: React.FC = ({ }}> Round Standings: {gameState.roundsWon[1]} - {gameState.roundsWon[2]}
+
)} {/* Game Phase with Matchball */} diff --git a/src/hooks/useGameEffects.ts b/src/hooks/useGameEffects.ts index f9d0e45c6..f1cc3ecb8 100644 --- a/src/hooks/useGameEffects.ts +++ b/src/hooks/useGameEffects.ts @@ -1,5 +1,21 @@ import { useCallback } from 'react'; -import { GameState, Card, Player, PoliticianCard, SpecialCard } from '../types/game'; +import { Card, GameState, Player, PoliticianCard, SpecialCard } from '../types/game'; +import { ActiveAbilitiesManager, EffectQueueManager, adjustInfluence, drawCards, hasDiplomatCard, sumRow } from '../utils/gameUtils'; +import { getCardDetails } from '../data/cardDetails'; + +type GameEffectLoggers = { + logFunctionCall?: (functionName: string, params: any, context: string) => void; + logCardEffect?: (cardName: string, message: string) => void; + logDataFlow?: (from: string, to: string, data: any, action: string) => void; + logWarning?: (warning: string, context: string) => void; +}; + +export function useGameEffects( + gameState: GameState, + setGameState: React.Dispatch>, + log: (msg: string) => void, + loggers: GameEffectLoggers = {}, +) { const { logFunctionCall = () => {}, logCardEffect = () => {}, @@ -10,8 +26,7 @@ import { GameState, Card, Player, PoliticianCard, SpecialCard } from '../types/g const executeCardEffect = useCallback(( card: Card, player: Player, - state: GameState, - logFunc: (msg: string) => void + state: GameState ): GameState => { let newState = { ...state }; @@ -272,32 +287,14 @@ import { GameState, Card, Player, PoliticianCard, SpecialCard } from '../types/g if (!flags || flags.diplomatInfluenceTransferUsed || flags.influenceTransferBlocked) return prev; if (!hasDiplomatCard(player, prev)) return prev; - const governmentCards = prev.board[player].aussen; - const fromGovCard = governmentCards.find(c => c.uid === fromCardUid && c.kind === 'pol') as PoliticianCard; - const toGovCard = governmentCards.find(c => c.uid === toCardUid && c.kind === 'pol') as PoliticianCard; - // Finde beide Karten in der Regierungsreihe - const govCards = prev.board[player].aussen; - const fromCard = govCards.find(c => c.uid === fromCardUid && c.kind === 'pol') as PoliticianCard; - const toCard = govCards.find(c => c.uid === toCardUid && c.kind === 'pol') as PoliticianCard; - - if (!fromGovCard || !toGovCard || fromGovCard.influence < amount) return prev; - - adjustInfluence(fromGovCard, -amount, 'Diplomat-Transfer'); - adjustInfluence(toGovCard, amount, 'Diplomat-Transfer'); - // Transfer durchführen - adjustInfluence(fromCard, -amount, 'Diplomat-Transfer'); - adjustInfluence(toCard, amount, 'Diplomat-Transfer'); - - if (!hasDiplomatCard(player, prev)) return prev; - const governmentCards = prev.board[player].aussen; const fromCard = governmentCards.find(c => c.uid === fromCardUid && c.kind === 'pol') as PoliticianCard; const toCard = governmentCards.find(c => c.uid === toCardUid && c.kind === 'pol') as PoliticianCard; if (!fromCard || !toCard || fromCard.influence < amount) return prev; - adjustInfluence(fromGovCard, -amount, 'Diplomat-Transfer'); - adjustInfluence(toGovCard, amount, 'Diplomat-Transfer'); + adjustInfluence(fromCard, -amount, 'Diplomat-Transfer'); + adjustInfluence(toCard, amount, 'Diplomat-Transfer'); const newFlags = { ...flags, diplomatInfluenceTransferUsed: true }; const newEffectFlags = { ...prev.effectFlags, [player]: newFlags } as GameState['effectFlags']; @@ -305,38 +302,17 @@ import { GameState, Card, Player, PoliticianCard, SpecialCard } from '../types/g const diplomat = governmentCards.find(c => c.kind === 'pol' && (c as PoliticianCard).tag === 'Diplomat') as PoliticianCard | undefined; if (diplomat) diplomat._activeUsed = true; - log(`P${player} transferiert ${amount} Einfluss von ${fromGovCard.name} zu ${toGovCard.name} (Diplomat).`); + log(`P${player} transferiert ${amount} Einfluss von ${fromCard.name} zu ${toCard.name} (Diplomat).`); return { ...prev, effectFlags: newEffectFlags }; }); - }, [log]); - - const resetActiveAbilities = useCallback((state: GameState): GameState => { - const newState = { ...state }; - [1, 2].forEach(player => { - const allCards = [...newState.board[player as Player].innen, ...newState.board[player as Player].aussen].filter(c => c.kind === 'pol') as PoliticianCard[]; - allCards.forEach(card => { - card._activeUsed = false; - }); - }); - - return newState; - }, []); - - const executePutinDoubleIntervention = useCallback((interventionCardIds: number[]) => { - setGameState(prev => { - const player = prev.current; - return ActiveAbilitiesManager.executePutinDoubleIntervention(prev, player, interventionCardIds, log); - }); }, [log, setGameState]); const resetActiveAbilities = useCallback((state: GameState): GameState => { const newState = { ...state }; - - // Reset _activeUsed für alle Politikerkarten [1, 2].forEach(player => { const allCards = [...newState.board[player as Player].innen, ...newState.board[player as Player].aussen].filter(c => c.kind === 'pol') as PoliticianCard[]; allCards.forEach(card => { @@ -355,17 +331,6 @@ import { GameState, Card, Player, PoliticianCard, SpecialCard } from '../types/g }); }, [log, setGameState]); - const canUsePutinDoubleIntervention = useCallback((player: Player): boolean => { - const board = gameState.board[player]; - const allCards = [...board.innen, ...board.aussen].filter(c => c.kind === 'pol') as PoliticianCard[]; - const putin = allCards.find(c => c.name === 'Vladimir Putin'); - - if (!putin || putin.deactivated || putin._activeUsed) return false; - - const interventions = gameState.hands[player].filter(c => c.kind === 'spec'); - return interventions.length >= 2; - }, [gameState]); - const canUsePutinDoubleIntervention = useCallback((player: Player): boolean => { const board = gameState.board[player]; const allCards = [...board.innen, ...board.aussen].filter(c => c.kind === 'pol') as PoliticianCard[]; diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts index de2b518d3..08d25873f 100644 --- a/src/hooks/useGameState.ts +++ b/src/hooks/useGameState.ts @@ -155,9 +155,27 @@ export function useGameState() { // prefer more useful/implemented specials const implFirst = ['media', 'pledge', 'pledge2', 'sanctions', 'dnc1', 'dnc2', 'dnc3', 'reshuffle', 'mission', 'trap_fakenews', 'trap_protest', 'trap_scandal']; - const srt = specPool.slice().sort((a, b) => implFirst.indexOf(a.impl) - implFirst.indexOf(b.impl)); - srt.slice(0, 11).forEach(s => deck.push(makeSpecInstance(s))); - return shuffle(deck).slice(0, 25); + const forcedSpecials = [ + 'Bestechungsskandal 2.0', + 'Maulwurf', + 'Greta Thunberg', + 'Mark Zuckerberg', + 'Symbolpolitik', + ]; + const forcedSpecCards = forcedSpecials + .map(name => specPool.find(s => s.name === name)) + .filter((s): s is (typeof Specials)[number] => Boolean(s)); + forcedSpecCards.forEach(s => deck.push(makeSpecInstance(s))); + + const remainingSpecs = specPool + .filter(s => !forcedSpecCards.some(forced => forced.id === s.id)) + .slice() + .sort((a, b) => implFirst.indexOf(a.impl) - implFirst.indexOf(b.impl)); + + const desiredSpecCount = 16; + const remainingNeeded = Math.max(0, desiredSpecCount - forcedSpecCards.length); + remainingSpecs.slice(0, remainingNeeded).forEach(s => deck.push(makeSpecInstance(s))); + return shuffle(deck).slice(0, 30); } const deck1 = buildDeck(); diff --git a/src/ui/layout.ts b/src/ui/layout.ts index 6ce77706c..37921326e 100644 --- a/src/ui/layout.ts +++ b/src/ui/layout.ts @@ -55,7 +55,7 @@ export function getZone(id: string): UiZone { export function getUiTransform(canvasW: number, canvasH: number) { const sx = canvasW / UI_BASE.width; const sy = canvasH / UI_BASE.height; - const scale = Math.min(sx, sy); + const scale = Math.min(sx, sy, 1); const offsetX = Math.floor((canvasW - UI_BASE.width * scale) / 2); const offsetY = Math.floor((canvasH - UI_BASE.height * scale) / 2); return { scale, offsetX, offsetY }; @@ -126,4 +126,4 @@ export function getInterventionsRect(side: 'player' | 'opponent'): Rect { // capacities used by canvas/utils export function getLaneCapacity(lane: 'public' | 'government'): number { return lane === 'government' ? 5 : 3; -} \ No newline at end of file +} diff --git a/src/utils/effectUtils.ts b/src/utils/effectUtils.ts index 7042763c8..114a59543 100644 --- a/src/utils/effectUtils.ts +++ b/src/utils/effectUtils.ts @@ -208,10 +208,12 @@ export class ActiveAbilitiesManager { ...state, actionPoints: { ...state.actionPoints } }; + let applied = false; switch (ability.type) { case 'hardliner': if (select.targetCard) { + applied = true; const loc = findCardLocation(select.targetCard, state); if (loc && loc.lane === 'innen' && loc.player !== select.actorPlayer) { const updatedLane = state.board[loc.player][loc.lane].filter(c => c.uid !== select.targetCard!.uid); @@ -240,13 +242,13 @@ export class ActiveAbilitiesManager { const isGovTarget = ownGov.some(card => card.uid === select.targetCard?.uid); if (isGovTarget) { adjustInfluence(select.targetCard, 2, 'Oligarchen-Einfluss'); + applied = true; } } break; case 'diplomat_transfer': // Handled separately in useGameEffects - applied = true; break; case 'putin_double_intervention': diff --git a/src/utils/queue.ts b/src/utils/queue.ts index 1cfe6c39d..03410cc75 100644 --- a/src/utils/queue.ts +++ b/src/utils/queue.ts @@ -438,6 +438,16 @@ export function resolveQueue(state: GameState, events: EffectEvent[]) { const { player: actor, targetUid } = ev as any; const victim: Player = actor === 1 ? 2 : 1; + if (typeof window !== 'undefined') { + try { + window.dispatchEvent(new CustomEvent('pc:corruption_roll_started', { + detail: { actor, victim, targetUid, type: 'bribery' } + })); + } catch (e) { + console.error('🎲 ENGINE: Error dispatching corruption roll start', e); + } + } + // Calculate W6 roll first let roll = 1 + rng.randomInt(6); console.log('🎲 ENGINE: Calculated W6 roll:', roll); @@ -495,6 +505,9 @@ export function resolveQueue(state: GameState, events: EffectEvent[]) { const effectiveTotal = total - navalnyPenalty; + let corruptionSuccess = false; + let transferOutcome: 'stolen' | 'discarded' | 'none' = 'none'; + if (effectiveTotal >= targetInfluence) { const maxSlots = 3; if (state.board[actor as Player].aussen.length < maxSlots) { @@ -502,15 +515,35 @@ export function resolveQueue(state: GameState, events: EffectEvent[]) { state.board[victim].aussen.splice(targetIdx,1); state.board[actor as Player].aussen.push(target as any); events.unshift({ type: 'LOG', msg: `Bribery Scandal 2.0: Erfolg! ${target.name} übernommen.` }); + transferOutcome = 'stolen'; } else { state.board[victim].aussen.splice(targetIdx,1); state.discard.push(target as any); events.unshift({ type: 'LOG', msg: `Bribery Scandal 2.0: Erfolg, aber kein Slot frei – ${target.name} entfernt.` }); + transferOutcome = 'discarded'; } + corruptionSuccess = true; } else { events.unshift({ type: 'LOG', msg: 'Bribery Scandal 2.0: Wurf zu niedrig – keine Übernahme.' }); } + if (typeof window !== 'undefined') { + try { + window.dispatchEvent(new CustomEvent('pc:corruption_resolved', { + detail: { + actor, + victim, + targetUid, + success: corruptionSuccess, + outcome: transferOutcome, + type: 'bribery' + } + })); + } catch (e) { + console.error('🎲 ENGINE: Error dispatching corruption resolved', e); + } + } + // Clear pending selection (state as any).pendingAbilitySelect = undefined; break; @@ -572,6 +605,16 @@ export function resolveQueue(state: GameState, events: EffectEvent[]) { const { player: actor, targetUid } = ev as any; const victim: Player = actor === 1 ? 2 : 1; + if (typeof window !== 'undefined') { + try { + window.dispatchEvent(new CustomEvent('pc:corruption_roll_started', { + detail: { actor, victim, targetUid, type: 'mole' } + })); + } catch (e) { + console.error('🎲 ENGINE: Error dispatching maulwurf roll start', e); + } + } + // Calculate W6 roll first const roll = 1 + rng.randomInt(6); console.log('🎲 ENGINE: Calculated W6 roll for Maulwurf:', roll); @@ -602,6 +645,9 @@ export function resolveQueue(state: GameState, events: EffectEvent[]) { events.unshift({ type: 'LOG', msg: `Maulwurf: Roll ${roll} vs benötigt ${requiredRoll} (${target.name}).` }); + let corruptionSuccess = false; + let transferOutcome: 'stolen' | 'discarded' | 'none' = 'none'; + if (roll >= requiredRoll) { const maxSlots = 5; // Government slots if (state.board[actor as Player].aussen.length < maxSlots) { @@ -609,16 +655,36 @@ export function resolveQueue(state: GameState, events: EffectEvent[]) { state.board[victim].aussen.splice(targetIdx,1); state.board[actor as Player].aussen.push(target as any); events.unshift({ type: 'LOG', msg: `Maulwurf: Erfolg! ${target.name} übernommen.` }); + transferOutcome = 'stolen'; } else { // No space - remove card state.board[victim].aussen.splice(targetIdx,1); state.discard.push(target as any); events.unshift({ type: 'LOG', msg: `Maulwurf: Erfolg, aber kein Slot frei – ${target.name} entfernt.` }); + transferOutcome = 'discarded'; } + corruptionSuccess = true; } else { events.unshift({ type: 'LOG', msg: 'Maulwurf: Wurf zu niedrig – keine Übernahme.' }); } + if (typeof window !== 'undefined') { + try { + window.dispatchEvent(new CustomEvent('pc:corruption_resolved', { + detail: { + actor, + victim, + targetUid, + success: corruptionSuccess, + outcome: transferOutcome, + type: 'mole' + } + })); + } catch (e) { + console.error('🎲 ENGINE: Error dispatching maulwurf resolved', e); + } + } + // Clear pending selection (state as any).pendingAbilitySelect = undefined; break; @@ -1025,4 +1091,4 @@ export function resolveQueue(state: GameState, events: EffectEvent[]) { } catch (e) { logger.dbg('resolveQueue: failed to shallow-copy hands', e); } -} \ No newline at end of file +}