From 32cdaea68ca0fe224700ea534a5fe9550f5c3a73 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 17 Oct 2025 16:02:47 +1000 Subject: [PATCH] more mechanics --- .../HomeNew/Advisor/AdvisorEmpty.tsx | 7 +- .../SurvivalShooter/SurvivalCanvas.tsx | 193 +++---- .../SurvivalShooter/SurvivalShooter.tsx | 418 +++++++++++----- .../components/SurvivalShooter/types.ts | 12 +- .../components/SurvivalShooter/useGameLoop.ts | 472 +++++++++++------- 5 files changed, 693 insertions(+), 409 deletions(-) diff --git a/apps/studio/components/interfaces/HomeNew/Advisor/AdvisorEmpty.tsx b/apps/studio/components/interfaces/HomeNew/Advisor/AdvisorEmpty.tsx index 206edb06b0e..46b58ebb757 100644 --- a/apps/studio/components/interfaces/HomeNew/Advisor/AdvisorEmpty.tsx +++ b/apps/studio/components/interfaces/HomeNew/Advisor/AdvisorEmpty.tsx @@ -10,17 +10,14 @@ interface AdvisorEmptyProps { type ActiveGame = 'pixel' | 'survival' | null -export const AdvisorEmpty = ({ availableResources = 20 }: AdvisorEmptyProps) => { +export const AdvisorEmpty = ({ availableResources = 10 }: AdvisorEmptyProps) => { const [activeGame, setActiveGame] = useState(null) if (activeGame === 'pixel') { return ( - setActiveGame(null)} - /> + setActiveGame(null)} /> ) diff --git a/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/SurvivalCanvas.tsx b/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/SurvivalCanvas.tsx index 358e644dd3c..0baa8994a81 100644 --- a/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/SurvivalCanvas.tsx +++ b/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/SurvivalCanvas.tsx @@ -19,106 +19,117 @@ export const SurvivalCanvas = ({ gameStateRef, config, onMouseMove, onResize }: const draw = useCallback(() => { const canvas = canvasRef.current const ctx = canvas?.getContext('2d') - if (!canvas || !ctx || !gameStateRef.current) return + + if (!canvas || !ctx) { + animationFrameId.current = requestAnimationFrame(draw) + return + } + + const width = canvas.width || config.canvasWidth + const height = canvas.height || config.canvasHeight + + // Always clear the visible canvas so we can redraw with fresh state + ctx.fillStyle = isDark ? '#0a0a0a' : '#fafafa' + ctx.fillRect(0, 0, width, height) const gameState = gameStateRef.current - const { player, enemies, projectiles } = gameState + if (gameState) { + const { player, enemies, projectiles } = gameState - // Clear canvas - ctx.fillStyle = isDark ? '#0a0a0a' : '#fafafa' - ctx.fillRect(0, 0, config.canvasWidth, config.canvasHeight) + // Draw player (circle) - scale size with HP + const baseRadius = 8 + const hpMultiplier = player.stats.maxHp / 100 // starts at 1.0 for 100hp + const playerRadius = baseRadius * Math.sqrt(hpMultiplier) // use sqrt for more reasonable scaling - // Draw player (circle) - scale size with HP - const baseRadius = 8 - const hpMultiplier = player.stats.maxHp / 100 // starts at 1.0 for 100hp - const playerRadius = baseRadius * Math.sqrt(hpMultiplier) // use sqrt for more reasonable scaling + ctx.fillStyle = isDark ? '#3b82f6' : '#2563eb' + ctx.beginPath() + ctx.arc(player.position.x, player.position.y, playerRadius, 0, Math.PI * 2) + ctx.fill() - ctx.fillStyle = isDark ? '#3b82f6' : '#2563eb' - ctx.beginPath() - ctx.arc(player.position.x, player.position.y, playerRadius, 0, Math.PI * 2) - ctx.fill() + // Draw rotation indicator + ctx.strokeStyle = isDark ? '#60a5fa' : '#3b82f6' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(player.position.x, player.position.y) + const indicatorLength = playerRadius + 6 + ctx.lineTo( + player.position.x + Math.cos(player.rotation) * indicatorLength, + player.position.y + Math.sin(player.rotation) * indicatorLength + ) + ctx.stroke() - // Draw rotation indicator - ctx.strokeStyle = isDark ? '#60a5fa' : '#3b82f6' - ctx.lineWidth = 2 - ctx.beginPath() - ctx.moveTo(player.position.x, player.position.y) - const indicatorLength = playerRadius + 6 - ctx.lineTo( - player.position.x + Math.cos(player.rotation) * indicatorLength, - player.position.y + Math.sin(player.rotation) * indicatorLength - ) - ctx.stroke() - - // Draw player HP bar - const hpBarWidth = playerRadius * 2 - const hpBarHeight = 3 - const hpBarX = player.position.x - playerRadius - const hpBarY = player.position.y - playerRadius - 6 - - // Background - ctx.fillStyle = isDark ? '#333' : '#ddd' - ctx.fillRect(hpBarX, hpBarY, hpBarWidth, hpBarHeight) - - // HP - const hpPercentage = player.stats.currentHp / player.stats.maxHp - ctx.fillStyle = hpPercentage > 0.5 ? '#22c55e' : hpPercentage > 0.25 ? '#eab308' : '#ef4444' - ctx.fillRect(hpBarX, hpBarY, hpBarWidth * hpPercentage, hpBarHeight) - - // Draw enemies (squares) - enemies.forEach((enemy) => { - const size = config.enemySize - const x = enemy.position.x - size / 2 - const y = enemy.position.y - size / 2 - - // Enemy body - ctx.fillStyle = isDark ? '#ef4444' : '#dc2626' - ctx.fillRect(x, y, size, size) - - // Enemy HP bar - const enemyHpBarWidth = size - const enemyHpBarHeight = 2 - const enemyHpBarX = x - const enemyHpBarY = y - 4 + // Draw player HP bar + const hpBarWidth = playerRadius * 2 + const hpBarHeight = 3 + const hpBarX = player.position.x - playerRadius + const hpBarY = player.position.y - playerRadius - 6 + // Background ctx.fillStyle = isDark ? '#333' : '#ddd' - ctx.fillRect(enemyHpBarX, enemyHpBarY, enemyHpBarWidth, enemyHpBarHeight) + ctx.fillRect(hpBarX, hpBarY, hpBarWidth, hpBarHeight) - const enemyHpPercentage = enemy.hp / enemy.maxHp - ctx.fillStyle = '#22c55e' - ctx.fillRect(enemyHpBarX, enemyHpBarY, enemyHpBarWidth * enemyHpPercentage, enemyHpBarHeight) - }) + // HP + const hpPercentage = player.stats.currentHp / player.stats.maxHp + ctx.fillStyle = hpPercentage > 0.5 ? '#22c55e' : hpPercentage > 0.25 ? '#eab308' : '#ef4444' + ctx.fillRect(hpBarX, hpBarY, hpBarWidth * hpPercentage, hpBarHeight) - // Draw projectiles with weapon-specific colors - projectiles.forEach((projectile) => { - if (projectile.weaponType === WeaponType.RING) { - // Draw ring as expanding ring - ctx.strokeStyle = '#a78bfa' // purple - ctx.lineWidth = 3 - ctx.globalAlpha = 0.6 - ctx.beginPath() - ctx.arc(player.position.x, player.position.y, projectile.radius, 0, Math.PI * 2) - ctx.stroke() - ctx.globalAlpha = 1 - } else { - // Draw normal projectiles (yellow) - ctx.fillStyle = '#fbbf24' // yellow - ctx.beginPath() - ctx.arc( - projectile.position.x, - projectile.position.y, - projectile.radius, - 0, - Math.PI * 2 + // Draw enemies (squares) + enemies.forEach((enemy) => { + const size = config.enemySize + const x = enemy.position.x - size / 2 + const y = enemy.position.y - size / 2 + + // Enemy body + ctx.fillStyle = isDark ? '#ef4444' : '#dc2626' + ctx.fillRect(x, y, size, size) + + // Enemy HP bar + const enemyHpBarWidth = size + const enemyHpBarHeight = 2 + const enemyHpBarX = x + const enemyHpBarY = y - 4 + + ctx.fillStyle = isDark ? '#333' : '#ddd' + ctx.fillRect(enemyHpBarX, enemyHpBarY, enemyHpBarWidth, enemyHpBarHeight) + + const enemyHpPercentage = enemy.hp / enemy.maxHp + ctx.fillStyle = '#22c55e' + ctx.fillRect( + enemyHpBarX, + enemyHpBarY, + enemyHpBarWidth * enemyHpPercentage, + enemyHpBarHeight ) - ctx.fill() - } - }) + }) - // Continue animation loop if game is playing - if (gameState.status === 'playing') { - animationFrameId.current = requestAnimationFrame(draw) + // Draw projectiles with weapon-specific colors + projectiles.forEach((projectile) => { + if (projectile.weaponType === WeaponType.RING) { + // Draw ring as expanding ring + ctx.strokeStyle = '#a78bfa' // purple + ctx.lineWidth = 3 + ctx.globalAlpha = 0.6 + ctx.beginPath() + ctx.arc(player.position.x, player.position.y, projectile.radius, 0, Math.PI * 2) + ctx.stroke() + ctx.globalAlpha = 1 + } else { + // Draw normal projectiles (yellow) + ctx.fillStyle = '#fbbf24' // yellow + ctx.beginPath() + ctx.arc( + projectile.position.x, + projectile.position.y, + projectile.radius, + 0, + Math.PI * 2 + ) + ctx.fill() + } + }) } + + animationFrameId.current = requestAnimationFrame(draw) }, [gameStateRef, config, isDark]) useEffect(() => { @@ -155,17 +166,11 @@ export const SurvivalCanvas = ({ gameStateRef, config, onMouseMove, onResize }: window.removeEventListener('resize', resizeCanvas) if (animationFrameId.current) { cancelAnimationFrame(animationFrameId.current) + animationFrameId.current = null } } }, [draw, onResize]) - // Redraw when game state changes - useEffect(() => { - if (!animationFrameId.current) { - animationFrameId.current = requestAnimationFrame(draw) - } - }, [gameStateRef.current?.status, draw]) - const handleMouseMove = useCallback((e: React.MouseEvent) => { const canvas = canvasRef.current if (!canvas || !onMouseMove) return diff --git a/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/SurvivalShooter.tsx b/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/SurvivalShooter.tsx index 635f77b8341..2a73e055497 100644 --- a/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/SurvivalShooter.tsx +++ b/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/SurvivalShooter.tsx @@ -1,99 +1,131 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { Button } from 'ui' import { X, Heart, Zap } from 'lucide-react' import { SurvivalCanvas } from './SurvivalCanvas' import { useGameLoop } from './useGameLoop' -import type { Card, GameStatus } from './types' -import { PerkType } from './types' +import type { Card, GameStatus, SelectedCard } from './types' +import { PerkType, WeaponType } from './types' interface SurvivalShooterProps { availableResources: number onExit: () => void } -// Generate random cards based on available resources -const generateCards = (count: number): Card[] => { - const allItemTypes = [ - { - type: PerkType.HP_INCREASE, - name: 'Health Potion', - desc: '+30 HP', - value: 30 - }, - { - type: PerkType.ATTACK_SPEED, - name: 'Attack Speed', - desc: '+25% Speed (All Weapons)', - value: 25 - }, - { - type: PerkType.ATTACK_DAMAGE, - name: 'Damage Boost', - desc: '+25% Damage (All Weapons)', - value: 25 - }, - { - type: PerkType.PROJECTILE_COUNT, - name: 'Multishot', - desc: '+1 Projectile (All Weapons)', - value: 1 - }, - { - type: PerkType.PROJECTILE_SIZE, - name: 'Big Shot', - desc: '+50% Size (All Weapons)', - value: 0.5 - }, - { - type: PerkType.UNLOCK_RING, - name: 'Ring Weapon', - desc: 'Unlock Expanding Ring', - value: 1 - }, - ] +const allItemTypes = [ + { + type: PerkType.HP_INCREASE, + name: 'Health Potion', + desc: '+30 HP', + value: 30, + requiresWeaponSelection: false, + }, + { + type: PerkType.ATTACK_SPEED, + name: 'Attack Speed', + desc: '+25% Attack Speed', + value: 25, + requiresWeaponSelection: true, + applicableWeaponTypes: [WeaponType.NORMAL, WeaponType.RING], + }, + { + type: PerkType.ATTACK_DAMAGE, + name: 'Damage Boost', + desc: '+25% Damage', + value: 25, + requiresWeaponSelection: true, + applicableWeaponTypes: [WeaponType.NORMAL, WeaponType.RING], + }, + { + type: PerkType.PROJECTILE_COUNT, + name: 'Multishot', + desc: '+1 Projectile', + value: 1, + requiresWeaponSelection: true, + applicableWeaponTypes: [WeaponType.NORMAL, WeaponType.RING], + }, + { + type: PerkType.PROJECTILE_SIZE, + name: 'Big Shot', + desc: '+50% Projectile Size', + value: 0.5, + requiresWeaponSelection: true, + applicableWeaponTypes: [WeaponType.NORMAL, WeaponType.RING], + }, + { + type: PerkType.UNLOCK_RING, + name: 'Ring Weapon', + desc: 'Unlock Expanding Ring', + value: 1, + requiresWeaponSelection: false, + }, +] as const - return Array.from({ length: count }, (_, i) => { - const itemData = allItemTypes[Math.floor(Math.random() * allItemTypes.length)] - const isStatBoost = itemData.type === PerkType.HP_INCREASE - return { - id: `card_${i}`, - name: itemData.name, - description: itemData.desc, - cardType: isStatBoost ? ('stat' as const) : ('weapon' as const), - perks: [ - { - type: itemData.type, - name: itemData.name, - description: itemData.desc, - value: itemData.value, - }, - ], - } - }) +const createRandomCard = (): Card => { + const itemData = allItemTypes[Math.floor(Math.random() * allItemTypes.length)] + const isStatBoost = itemData.type === PerkType.HP_INCREASE + return { + id: `card_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + name: itemData.name, + description: itemData.desc, + cardType: isStatBoost ? ('stat' as const) : ('weapon' as const), + perks: [ + { + type: itemData.type, + name: itemData.name, + description: itemData.desc, + value: itemData.value, + }, + ], + requiresWeaponSelection: itemData.requiresWeaponSelection, + applicableWeaponTypes: itemData.applicableWeaponTypes, + } } -export const SurvivalShooter = ({ availableResources, onExit }: SurvivalShooterProps) => { - const maxCards = Math.min(Math.floor(availableResources / 5), 5) - const [selectedCards, setSelectedCards] = useState([]) - const [availableCards, setAvailableCards] = useState([]) +const weaponTypeLabels: Record = { + [WeaponType.NORMAL]: 'Blaster', + [WeaponType.RING]: 'Ring Weapon', +} + +export const SurvivalShooter = ({ availableResources = 0, onExit }: SurvivalShooterProps) => { + const maxCards = Math.max(0, availableResources) + const [selectedCards, setSelectedCards] = useState([]) + const [currentOptions, setCurrentOptions] = useState([]) const [hasStarted, setHasStarted] = useState(false) const [currentScore, setCurrentScore] = useState(0) const [gameStatus, setGameStatus] = useState('card_selection' as GameStatus) const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 }) + const [weaponSelectionPrompt, setWeaponSelectionPrompt] = useState<{ + card: Card + options: WeaponType[] + } | null>(null) + const [isSelectionActive, setIsSelectionActive] = useState(false) + const wasPlayingBeforeSelectionRef = useRef(false) const { gameStateRef, config, startGame, pauseGame, resumeGame, updateMousePosition } = - useGameLoop(selectedCards, canvasSize) + useGameLoop(selectedCards, canvasSize, maxCards) - // Generate cards on mount - useEffect(() => { - if (maxCards > 0) { - setAvailableCards(generateCards(maxCards)) - } else { - // If no resources for cards, start immediately - setHasStarted(true) - startGame() - } - }, [maxCards]) + const remainingSelections = Math.max(0, maxCards - selectedCards.length) + + const unlockedWeapons = useMemo(() => { + const weapons = new Set([WeaponType.NORMAL]) + selectedCards.forEach((choice) => { + if (choice.card.perks.some((perk) => perk.type === PerkType.UNLOCK_RING)) { + weapons.add(WeaponType.RING) + } + }) + return weapons + }, [selectedCards]) + + const getWeaponOptions = (card: Card): WeaponType[] => { + if (!card.requiresWeaponSelection) return [] + const allowedTypes = card.applicableWeaponTypes ?? [] + return allowedTypes.filter((type) => unlockedWeapons.has(type)) + } + + const addCardSelection = (card: Card, assignedWeaponType?: WeaponType) => { + setSelectedCards((prev) => [...prev, { card, assignedWeaponType }]) + setCurrentOptions([]) + } // Monitor game state useEffect(() => { @@ -109,24 +141,115 @@ export const SurvivalShooter = ({ availableResources, onExit }: SurvivalShooterP return () => clearInterval(interval) }, [hasStarted, gameStateRef]) - const handleCardSelect = (card: Card) => { - if (selectedCards.find((c) => c.id === card.id)) { - setSelectedCards(selectedCards.filter((c) => c.id !== card.id)) + useEffect(() => { + if (remainingSelections > 0) { + if (!isSelectionActive) { + setIsSelectionActive(true) + setCurrentOptions([]) + const status = gameStateRef.current?.status + if (status === 'playing') { + pauseGame() + wasPlayingBeforeSelectionRef.current = true + } else { + wasPlayingBeforeSelectionRef.current = false + } + } } else { - setSelectedCards([...selectedCards, card]) + if (isSelectionActive) { + setIsSelectionActive(false) + setCurrentOptions([]) + if (!hasStarted) { + setHasStarted(true) + startGame() + } else if (wasPlayingBeforeSelectionRef.current) { + resumeGame() + } + wasPlayingBeforeSelectionRef.current = false + } else if (!hasStarted && maxCards === 0) { + setHasStarted(true) + startGame() + } } + }, [ + remainingSelections, + isSelectionActive, + pauseGame, + resumeGame, + startGame, + hasStarted, + maxCards, + gameStateRef, + ]) + + useEffect(() => { + if (!isSelectionActive) { + setWeaponSelectionPrompt(null) + } + }, [isSelectionActive]) + + useEffect(() => { + const hasRingUnlocked = selectedCards.some((choice) => + choice.card.perks.some((perk) => perk.type === PerkType.UNLOCK_RING) + ) + if (hasRingUnlocked) return + + const filtered = selectedCards.filter((choice) => choice.assignedWeaponType !== WeaponType.RING) + if (filtered.length !== selectedCards.length) { + setSelectedCards(filtered) + } + }, [selectedCards]) + + useEffect(() => { + if (!isSelectionActive) { + setCurrentOptions([]) + return + } + + if (weaponSelectionPrompt) return + + if (remainingSelections <= 0) { + setCurrentOptions([]) + return + } + + if (currentOptions.length === 0) { + const optionCount = Math.min(3, remainingSelections) + setCurrentOptions(Array.from({ length: optionCount }, () => createRandomCard())) + } + }, [isSelectionActive, weaponSelectionPrompt, remainingSelections, currentOptions.length]) + + const handleCardSelect = (card: Card) => { + if (!isSelectionActive || weaponSelectionPrompt) return + if (selectedCards.some((choice) => choice.card.id === card.id)) return + if (remainingSelections <= 0) return + + if (card.requiresWeaponSelection) { + const options = getWeaponOptions(card) + if (options.length === 0) return + if (options.length === 1) { + addCardSelection(card, options[0]) + } else { + setWeaponSelectionPrompt({ card, options }) + } + return + } + + addCardSelection(card) } - const handleStartGame = () => { - setHasStarted(true) - startGame() + const handleWeaponSelection = (weapon: WeaponType) => { + if (!weaponSelectionPrompt) return + addCardSelection(weaponSelectionPrompt.card, weapon) + setWeaponSelectionPrompt(null) } const handleRestart = () => { setHasStarted(false) setSelectedCards([]) - setAvailableCards(generateCards(maxCards)) + setCurrentOptions([]) setGameStatus('card_selection' as GameStatus) + setWeaponSelectionPrompt(null) + wasPlayingBeforeSelectionRef.current = false } const formatTime = (seconds: number) => { @@ -135,56 +258,86 @@ export const SurvivalShooter = ({ availableResources, onExit }: SurvivalShooterP return `${mins}:${secs.toString().padStart(2, '0')}` } - // Card selection screen - if (!hasStarted && maxCards > 0) { - return ( -
- - ) - })} -
- - - + return ( + + ) + })} + + {currentOptions.length === 0 && ( +
+ Generating more items... +
+ )} + + ) : ( + <> +
+

Choose a weapon

+

+ {weaponSelectionPrompt.card.name} can be applied to multiple weapons +

+
+
+ {weaponSelectionPrompt.options.map((option) => ( + + ))} +
+ + + )} - ) - } + + ) : null // Game over screen if (gameStatus === 'game_over') { @@ -255,6 +408,7 @@ export const SurvivalShooter = ({ availableResources, onExit }: SurvivalShooterP onMouseMove={updateMousePosition} onResize={setCanvasSize} /> + {renderSelectionOverlay} ) } diff --git a/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/types.ts b/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/types.ts index c28a85dd0fc..b8ce8be5840 100644 --- a/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/types.ts +++ b/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/types.ts @@ -24,6 +24,7 @@ export interface Weapon { visuals: WeaponVisuals lastFireTime: number projectileAngles: number[] // for multi-projectile weapons + appliedItems?: SelectedCard[] } export interface PlayerStats { @@ -103,6 +104,13 @@ export interface Card { cardType: 'weapon' | 'stat' weapon?: Weapon perks: Perk[] + requiresWeaponSelection?: boolean + applicableWeaponTypes?: WeaponType[] +} + +export interface SelectedCard { + card: Card + assignedWeaponType?: WeaponType } export enum GameStatus { @@ -126,9 +134,9 @@ export interface GameState { projectiles: Projectile[] wave: Wave score: number // survival time in seconds - selectedCards: Card[] - availableCards: Card[] + selectedCards: SelectedCard[] mousePosition: Vector2 | null + maxSelectableItems: number } export interface GameConfig { diff --git a/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/useGameLoop.ts b/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/useGameLoop.ts index 3fd46a1c694..05a9935cd67 100644 --- a/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/useGameLoop.ts +++ b/apps/studio/components/interfaces/HomeNew/Advisor/components/SurvivalShooter/useGameLoop.ts @@ -5,10 +5,11 @@ import type { Enemy, Projectile, Vector2, - Card, PlayerStats, GameConfig, Weapon, + SelectedCard, + WeaponModifiers, } from './types' import { PerkType, WeaponType } from './types' @@ -21,29 +22,50 @@ const BASE_STATS: PlayerStats = { currentHp: 100, moveSpeed: 150, // pixels per second attackSpeed: 2, // attacks per second - attackDamage: 3, + attackDamage: 5, projectileCount: 1, projectileSize: 1, pulseEnabled: false, } -export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; height: number }) => { +export const useGameLoop = ( + selectedCards: SelectedCard[], + canvasSize: { width: number; height: number }, + maxSelectableItems: number +) => { const gameStateRef = useRef(null) - const keysPressed = useRef>(new Set()) const animationFrameId = useRef(null) const lastFrameTime = useRef(0) const enemyIdCounter = useRef(0) const projectileIdCounter = useRef(0) - - // Recalculate config whenever canvas size changes - const config: GameConfig = useMemo(() => ({ - canvasWidth: canvasSize.width || CANVAS_WIDTH, - canvasHeight: canvasSize.height || CANVAS_HEIGHT, + const configRef = useRef({ + canvasWidth: CANVAS_WIDTH, + canvasHeight: CANVAS_HEIGHT, playerRadius: 8, enemySize: 12, projectileRadius: 3, basePlayerStats: BASE_STATS, - }), [canvasSize.width, canvasSize.height]) + }) + + // Recalculate config whenever canvas size changes + const config: GameConfig = useMemo( + () => ({ + canvasWidth: canvasSize.width || CANVAS_WIDTH, + canvasHeight: canvasSize.height || CANVAS_HEIGHT, + playerRadius: 8, + enemySize: 12, + projectileRadius: 3, + basePlayerStats: BASE_STATS, + }), + [canvasSize.width, canvasSize.height] + ) + + const itemLimit = Math.max(0, Math.floor(maxSelectableItems || 0)) + const cardsForGame = useMemo(() => selectedCards.slice(0, itemLimit), [selectedCards, itemLimit]) + + useEffect(() => { + configRef.current = config + }, [config]) // Calculate dynamic player radius based on HP const getPlayerRadius = useCallback((maxHp: number): number => { @@ -56,7 +78,7 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; const BASE_WEAPONS = { NORMAL: { type: WeaponType.NORMAL, - baseDamage: 3, + baseDamage: 8, baseFireRate: 2, baseProjectileCount: 1, baseProjectileSpeed: 300, @@ -64,7 +86,7 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; }, RING: { type: WeaponType.RING, - baseDamage: 1.5, + baseDamage: 1, baseFireRate: 0.5, baseProjectileCount: 1, baseProjectileSpeed: 0, @@ -72,80 +94,123 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; }, } - // Calculate weapon stats with modifiers applied - const calculateWeaponStats = useCallback((baseWeapon: typeof BASE_WEAPONS.NORMAL, modifiers: any): Weapon => { - return { - id: `${baseWeapon.type}_weapon`, - type: baseWeapon.type, - damage: baseWeapon.baseDamage * modifiers.damageMultiplier, - fireRate: baseWeapon.baseFireRate * modifiers.fireRateMultiplier, - projectileCount: baseWeapon.baseProjectileCount + modifiers.projectileCountBonus, - projectileSpeed: baseWeapon.baseProjectileSpeed, - visuals: { - ...baseWeapon.visuals, - size: baseWeapon.visuals.size * modifiers.projectileSizeMultiplier, - }, - lastFireTime: 0, - projectileAngles: [], - } - }, []) + interface WeaponModifierState { + damageMultiplier: number + fireRateMultiplier: number + projectileCountBonus: number + projectileSizeMultiplier: number + appliedItems: SelectedCard[] + } - // Calculate modifiers from selected items - const calculateModifiers = useCallback((cards: Card[]) => { - const modifiers = { - damageMultiplier: 1.0, - fireRateMultiplier: 1.0, - projectileCountBonus: 0, - projectileSizeMultiplier: 1.0, + const createBaseModifierState = (): WeaponModifierState => ({ + damageMultiplier: 1.0, + fireRateMultiplier: 1.0, + projectileCountBonus: 0, + projectileSizeMultiplier: 1.0, + appliedItems: [], + }) + + const calculateWeaponStats = useCallback( + (baseWeapon: typeof BASE_WEAPONS.NORMAL, modifierState: WeaponModifierState): Weapon => { + return { + id: `${baseWeapon.type}_weapon`, + type: baseWeapon.type, + damage: baseWeapon.baseDamage * modifierState.damageMultiplier, + fireRate: baseWeapon.baseFireRate * modifierState.fireRateMultiplier, + projectileCount: Math.max( + 1, + baseWeapon.baseProjectileCount + modifierState.projectileCountBonus + ), + projectileSpeed: baseWeapon.baseProjectileSpeed, + visuals: { + ...baseWeapon.visuals, + size: baseWeapon.visuals.size * modifierState.projectileSizeMultiplier, + }, + lastFireTime: 0, + projectileAngles: [], + appliedItems: modifierState.appliedItems, + } + }, + [] + ) + + const buildWeaponStates = useCallback((choices: SelectedCard[]) => { + const weaponStates: Record = { + [WeaponType.NORMAL]: createBaseModifierState(), + [WeaponType.RING]: createBaseModifierState(), } - cards.forEach((card) => { - card.perks.forEach((perk) => { + const unlockedWeapons = new Set([WeaponType.NORMAL]) + + choices.forEach((choice) => { + if (choice.card.perks.some((perk) => perk.type === PerkType.UNLOCK_RING)) { + unlockedWeapons.add(WeaponType.RING) + } + + if (choice.card.requiresWeaponSelection && choice.assignedWeaponType) { + const state = weaponStates[choice.assignedWeaponType] + if (state && !state.appliedItems.some((applied) => applied.card.id === choice.card.id)) { + state.appliedItems.push(choice) + } + } + + choice.card.perks.forEach((perk) => { + const targetWeapon = + choice.card.requiresWeaponSelection && choice.assignedWeaponType + ? weaponStates[choice.assignedWeaponType] + : null + switch (perk.type) { case PerkType.ATTACK_SPEED: - modifiers.fireRateMultiplier *= 1 + perk.value / 100 + if (targetWeapon) { + targetWeapon.fireRateMultiplier *= 1 + perk.value / 100 + } break case PerkType.ATTACK_DAMAGE: - modifiers.damageMultiplier *= 1 + perk.value / 100 + if (targetWeapon) { + targetWeapon.damageMultiplier *= 1 + perk.value / 100 + } break case PerkType.PROJECTILE_COUNT: - modifiers.projectileCountBonus += perk.value + if (targetWeapon) { + targetWeapon.projectileCountBonus += perk.value + } break case PerkType.PROJECTILE_SIZE: - modifiers.projectileSizeMultiplier += perk.value + if (targetWeapon) { + targetWeapon.projectileSizeMultiplier += perk.value + } break } }) }) - return modifiers + return { weaponStates, unlockedWeapons } }, []) - // Create weapons based on selected items - const createWeapons = useCallback((cards: Card[], modifiers: any): Weapon[] => { - const weapons: Weapon[] = [] + const createWeapons = useCallback( + ( + weaponStates: Record, + unlockedWeapons: Set + ): Weapon[] => { + const weapons: Weapon[] = [] + weapons.push(calculateWeaponStats(BASE_WEAPONS.NORMAL, weaponStates[WeaponType.NORMAL])) - // Always include NORMAL weapon - weapons.push(calculateWeaponStats(BASE_WEAPONS.NORMAL, modifiers)) + if (unlockedWeapons.has(WeaponType.RING)) { + weapons.push(calculateWeaponStats(BASE_WEAPONS.RING, weaponStates[WeaponType.RING])) + } - // Check if RING weapon should be unlocked - const hasRing = cards.some((card) => - card.perks.some((perk) => perk.type === PerkType.UNLOCK_RING) - ) - - if (hasRing) { - weapons.push(calculateWeaponStats(BASE_WEAPONS.RING, modifiers)) - } - - return weapons - }, [calculateWeaponStats]) + return weapons + }, + [calculateWeaponStats] + ) // Apply card perks to player stats (only HP now, other stats use modifiers) - const getInitialPlayerStats = useCallback((): PlayerStats => { + const getInitialPlayerStats = useCallback((choices: SelectedCard[]): PlayerStats => { let stats = { ...BASE_STATS } - selectedCards.forEach((card) => { - card.perks.forEach((perk) => { + choices.forEach((choice) => { + choice.card.perks.forEach((perk) => { switch (perk.type) { case PerkType.HP_INCREASE: stats.maxHp += perk.value @@ -156,20 +221,31 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; }) return stats - }, [selectedCards]) + }, []) + + const toWeaponModifiers = useCallback( + (state: WeaponModifierState): WeaponModifiers => ({ + damageMultiplier: state.damageMultiplier, + fireRateMultiplier: state.fireRateMultiplier, + projectileCountBonus: state.projectileCountBonus, + projectileSizeMultiplier: state.projectileSizeMultiplier, + }), + [] + ) const initializeGame = useCallback(() => { - const stats = getInitialPlayerStats() - const modifiers = calculateModifiers(selectedCards) - const weapons = createWeapons(selectedCards, modifiers) + const stats = getInitialPlayerStats(cardsForGame) + const { weaponStates, unlockedWeapons } = buildWeaponStates(cardsForGame) + const weapons = createWeapons(weaponStates, unlockedWeapons) + const currentConfig = configRef.current gameStateRef.current = { status: 'playing' as GameStatus, player: { - position: { x: config.canvasWidth / 2, y: config.canvasHeight / 2 }, + position: { x: currentConfig.canvasWidth / 2, y: currentConfig.canvasHeight / 2 }, rotation: 0, stats, - modifiers, + modifiers: toWeaponModifiers(weaponStates[WeaponType.NORMAL]), weapons, lastAttackTime: 0, lastPulseTime: 0, @@ -184,17 +260,55 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; lastSpawnTime: 0, }, score: 0, - selectedCards, - availableCards: [], + selectedCards: cardsForGame, mousePosition: null, + maxSelectableItems: itemLimit, } lastFrameTime.current = performance.now() enemyIdCounter.current = 0 projectileIdCounter.current = 0 - }, [getInitialPlayerStats, calculateModifiers, createWeapons, selectedCards, config]) + }, [cardsForGame, itemLimit, getInitialPlayerStats, buildWeaponStates, createWeapons]) + + useEffect(() => { + if (!gameStateRef.current) return + + const currentState = gameStateRef.current + const { weaponStates, unlockedWeapons } = buildWeaponStates(cardsForGame) + const updatedWeapons = createWeapons(weaponStates, unlockedWeapons) + + updatedWeapons.forEach((weapon) => { + const existing = currentState.player.weapons.find((w) => w.type === weapon.type) + if (existing) { + weapon.lastFireTime = existing.lastFireTime + weapon.projectileAngles = existing.projectileAngles + } + }) + + const stats = getInitialPlayerStats(cardsForGame) + const previousMaxHp = currentState.player.stats.maxHp + const hpDiff = stats.maxHp - previousMaxHp + + currentState.player.stats.maxHp = stats.maxHp + if (hpDiff > 0) { + currentState.player.stats.currentHp = Math.min( + stats.maxHp, + currentState.player.stats.currentHp + hpDiff + ) + } else { + currentState.player.stats.currentHp = Math.min( + currentState.player.stats.currentHp, + stats.maxHp + ) + } + + currentState.player.modifiers = toWeaponModifiers(weaponStates[WeaponType.NORMAL]) + currentState.player.weapons = updatedWeapons + currentState.selectedCards = cardsForGame + }, [cardsForGame, buildWeaponStates, createWeapons, getInitialPlayerStats, toWeaponModifiers]) const spawnEnemy = useCallback((currentTime: number, waveNumber: number): Enemy => { + const currentConfig = configRef.current const id = `enemy_${enemyIdCounter.current++}` const angle = Math.random() * Math.PI * 2 const baseHp = 20 @@ -202,9 +316,9 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; const speed = baseSpeed + waveNumber * 2 // Calculate spawn position on canvas edge - const centerX = config.canvasWidth / 2 - const centerY = config.canvasHeight / 2 - const spawnDistance = Math.max(config.canvasWidth, config.canvasHeight) * 0.7 + const centerX = currentConfig.canvasWidth / 2 + const centerY = currentConfig.canvasHeight / 2 + const spawnDistance = Math.max(currentConfig.canvasWidth, currentConfig.canvasHeight) * 0.7 const spawnX = centerX + Math.cos(angle) * spawnDistance const spawnY = centerY + Math.sin(angle) * spawnDistance @@ -227,7 +341,7 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; speed, damage: 10 + waveNumber * 2, } - }, [config]) + }, []) // Find the biggest gap between angles and return the angle in the middle of that gap const findBiggestGap = useCallback((angles: number[]): number => { @@ -243,9 +357,8 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; // Check gaps between consecutive angles for (let i = 0; i < sorted.length; i++) { const nextIndex = (i + 1) % sorted.length - const gap = nextIndex === 0 - ? (Math.PI * 2 - sorted[i] + sorted[0]) - : (sorted[nextIndex] - sorted[i]) + const gap = + nextIndex === 0 ? Math.PI * 2 - sorted[i] + sorted[0] : sorted[nextIndex] - sorted[i] if (gap > maxGap) { maxGap = gap @@ -262,64 +375,68 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; } }, []) - const shootWeapons = useCallback((currentTime: number) => { - if (!gameStateRef.current) return + const shootWeapons = useCallback( + (currentTime: number) => { + if (!gameStateRef.current) return - const { player, projectiles } = gameStateRef.current + const { player, projectiles } = gameStateRef.current + const currentConfig = configRef.current - // Fire each weapon independently - player.weapons.forEach((weapon) => { - const fireInterval = 1000 / weapon.fireRate - if (currentTime - weapon.lastFireTime < fireInterval) return + // Fire each weapon independently + player.weapons.forEach((weapon) => { + const fireInterval = 1000 / weapon.fireRate + if (currentTime - weapon.lastFireTime < fireInterval) return - weapon.lastFireTime = currentTime - - // Handle different weapon types - if (weapon.type === WeaponType.RING) { - // Ring weapon: expanding ring - projectiles.push({ - id: `projectile_${projectileIdCounter.current++}`, - position: { x: player.position.x, y: player.position.y }, - velocity: { x: 0, y: 0 }, - angle: 0, - damage: weapon.damage, - radius: config.playerRadius, - weaponType: weapon.type, - isPulse: true, - createdAt: currentTime, - }) - } else { - // Normal weapon: standard projectiles - const count = Math.floor(weapon.projectileCount) - - // Ensure we have the right number of projectile angles - while (weapon.projectileAngles.length < count) { - const newAngle = findBiggestGap(weapon.projectileAngles) - weapon.projectileAngles.push(newAngle) - } - - // Shoot projectiles at each angle (relative to player rotation) - weapon.projectileAngles.forEach((relativeAngle) => { - const actualAngle = relativeAngle + player.rotation - const velocity: Vector2 = { - x: Math.cos(actualAngle) * weapon.projectileSpeed, - y: Math.sin(actualAngle) * weapon.projectileSpeed, - } + weapon.lastFireTime = currentTime + // Handle different weapon types + if (weapon.type === WeaponType.RING) { + // Ring weapon: expanding ring projectiles.push({ id: `projectile_${projectileIdCounter.current++}`, position: { x: player.position.x, y: player.position.y }, - velocity, - angle: actualAngle, + velocity: { x: 0, y: 0 }, + angle: 0, damage: weapon.damage, - radius: config.projectileRadius * weapon.visuals.size, + radius: currentConfig.playerRadius, weaponType: weapon.type, - isPulse: false, + isPulse: true, + createdAt: currentTime, }) - }) - } - }) - }, [findBiggestGap, config]) + } else { + // Normal weapon: standard projectiles + const count = Math.floor(weapon.projectileCount) + + // Ensure we have the right number of projectile angles + while (weapon.projectileAngles.length < count) { + const newAngle = findBiggestGap(weapon.projectileAngles) + weapon.projectileAngles.push(newAngle) + } + + // Shoot projectiles at each angle (relative to player rotation) + weapon.projectileAngles.forEach((relativeAngle) => { + const actualAngle = relativeAngle + player.rotation + const velocity: Vector2 = { + x: Math.cos(actualAngle) * weapon.projectileSpeed, + y: Math.sin(actualAngle) * weapon.projectileSpeed, + } + + projectiles.push({ + id: `projectile_${projectileIdCounter.current++}`, + position: { x: player.position.x, y: player.position.y }, + velocity, + angle: actualAngle, + damage: weapon.damage, + radius: currentConfig.projectileRadius * weapon.visuals.size, + weaponType: weapon.type, + isPulse: false, + }) + }) + } + }) + }, + [findBiggestGap] + ) const updatePlayer = useCallback(() => { if (!gameStateRef.current) return @@ -338,6 +455,7 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; if (!gameStateRef.current) return const { projectiles } = gameStateRef.current + const currentConfig = configRef.current // Update positions and remove off-screen projectiles for (let i = projectiles.length - 1; i >= 0; i--) { @@ -347,10 +465,13 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; // Expand pulse projectile const age = (currentTime - (proj.createdAt || 0)) / 1000 const expansionSpeed = 150 // pixels per second - proj.radius = config.playerRadius + age * expansionSpeed + proj.radius = currentConfig.playerRadius + age * expansionSpeed // Remove pulse after 2 seconds or if too large - if (age > 2 || proj.radius > Math.max(config.canvasWidth, config.canvasHeight)) { + if ( + age > 2 || + proj.radius > Math.max(currentConfig.canvasWidth, currentConfig.canvasHeight) + ) { projectiles.splice(i, 1) continue } @@ -361,63 +482,68 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; // Remove if off-screen if ( - proj.position.x > config.canvasWidth + 20 || + proj.position.x > currentConfig.canvasWidth + 20 || proj.position.x < -20 || - proj.position.y > config.canvasHeight + 20 || + proj.position.y > currentConfig.canvasHeight + 20 || proj.position.y < -20 ) { projectiles.splice(i, 1) } } } - }, [config]) + }, []) - const updateEnemies = useCallback((deltaTime: number, currentTime: number) => { - if (!gameStateRef.current) return + const updateEnemies = useCallback( + (deltaTime: number, currentTime: number) => { + if (!gameStateRef.current) return - const { enemies, wave, player } = gameStateRef.current + const { enemies, wave, player } = gameStateRef.current + const currentConfig = configRef.current - // Spawn enemies - const spawnInterval = 1000 / wave.spawnRate - if (currentTime - wave.lastSpawnTime > spawnInterval) { - enemies.push(spawnEnemy(currentTime, wave.waveNumber)) - wave.lastSpawnTime = currentTime - } - - // Update enemy positions - for (let i = enemies.length - 1; i >= 0; i--) { - const enemy = enemies[i] - - // Recalculate velocity towards player's current position each frame - const dx = player.position.x - enemy.position.x - const dy = player.position.y - enemy.position.y - const distance = Math.sqrt(dx * dx + dy * dy) - - // Update velocity to always move towards player - if (distance > 0) { - enemy.velocity.x = (dx / distance) * enemy.speed - enemy.velocity.y = (dy / distance) * enemy.speed + // Spawn enemies + const spawnInterval = 1000 / wave.spawnRate + if (currentTime - wave.lastSpawnTime > spawnInterval) { + enemies.push(spawnEnemy(currentTime, wave.waveNumber)) + wave.lastSpawnTime = currentTime } - // Move enemy using velocity vector - enemy.position.x += enemy.velocity.x * deltaTime - enemy.position.y += enemy.velocity.y * deltaTime + // Update enemy positions + for (let i = enemies.length - 1; i >= 0; i--) { + const enemy = enemies[i] - // Check collision with player (using dynamic radius based on HP) - const playerRadius = getPlayerRadius(player.stats.maxHp) + // Recalculate velocity towards player's current position each frame + const dx = player.position.x - enemy.position.x + const dy = player.position.y - enemy.position.y + const distance = Math.sqrt(dx * dx + dy * dy) - if (distance < playerRadius + config.enemySize / 2) { - player.stats.currentHp -= enemy.damage - enemies.splice(i, 1) - continue + // Update velocity to always move towards player + if (distance > 0) { + enemy.velocity.x = (dx / distance) * enemy.speed + enemy.velocity.y = (dy / distance) * enemy.speed + } + + // Move enemy using velocity vector + enemy.position.x += enemy.velocity.x * deltaTime + enemy.position.y += enemy.velocity.y * deltaTime + + // Check collision with player (using dynamic radius based on HP) + const playerRadius = getPlayerRadius(player.stats.maxHp) + + if (distance < playerRadius + currentConfig.enemySize / 2) { + player.stats.currentHp -= enemy.damage + enemies.splice(i, 1) + continue + } } - } - }, [spawnEnemy, getPlayerRadius, config]) + }, + [spawnEnemy, getPlayerRadius] + ) const checkCollisions = useCallback(() => { if (!gameStateRef.current) return const { enemies, projectiles, player } = gameStateRef.current + const currentConfig = configRef.current // Check projectile-enemy collisions for (let i = projectiles.length - 1; i >= 0; i--) { @@ -437,13 +563,14 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; // Check if enemy is touching the pulse ring (within a small threshold) const ringThickness = 20 - isColliding = Math.abs(distanceFromCenter - proj.radius) < ringThickness + config.enemySize / 2 + isColliding = + Math.abs(distanceFromCenter - proj.radius) < ringThickness + currentConfig.enemySize / 2 } else { // Regular projectile collision const dx = proj.position.x - enemy.position.x const dy = proj.position.y - enemy.position.y const distance = Math.sqrt(dx * dx + dy * dy) - isColliding = distance < proj.radius + config.enemySize / 2 + isColliding = distance < proj.radius + currentConfig.enemySize / 2 } if (isColliding) { @@ -466,7 +593,7 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; projectiles.splice(i, 1) } } - }, [config]) + }, []) const updateWave = useCallback((score: number) => { if (!gameStateRef.current) return @@ -509,14 +636,7 @@ export const useGameLoop = (selectedCards: Card[], canvasSize: { width: number; animationFrameId.current = requestAnimationFrame(gameLoop) }, - [ - updatePlayer, - shootWeapons, - updateProjectiles, - updateEnemies, - checkCollisions, - updateWave, - ] + [updatePlayer, shootWeapons, updateProjectiles, updateEnemies, checkCollisions, updateWave] ) const startGame = useCallback(() => {