more mechanics

This commit is contained in:
Saxon Fletcher committed 2025-10-17 16:02:47 +10:00
1 parent d9a9626bb3
commit 32cdaea68c
5 files changed
+693 -409

No files matched your search

@@ -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<ActiveGame>(null)
if (activeGame === 'pixel') {
return (
<Card className="bg-transparent">
<CardContent className="relative p-0 h-[256px] overflow-hidden">
<PixelGame
availableResources={availableResources}
onExit={() => setActiveGame(null)}
/>
<PixelGame availableResources={availableResources} onExit={() => setActiveGame(null)} />
</CardContent>
</Card>
)
@@ -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<HTMLCanvasElement>) => {
const canvas = canvasRef.current
if (!canvas || !onMouseMove) return
@@ -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<Card[]>([])
const [availableCards, setAvailableCards] = useState<Card[]>([])
const weaponTypeLabels: Record<WeaponType, string> = {
[WeaponType.NORMAL]: 'Blaster',
[WeaponType.RING]: 'Ring Weapon',
}
export const SurvivalShooter = ({ availableResources = 0, onExit }: SurvivalShooterProps) => {
const maxCards = Math.max(0, availableResources)
const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([])
const [currentOptions, setCurrentOptions] = useState<Card[]>([])
const [hasStarted, setHasStarted] = useState(false)
const [currentScore, setCurrentScore] = useState(0)
const [gameStatus, setGameStatus] = useState<GameStatus>('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>([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 (
<div className="w-full h-full relative overflow-hidden bg-alternative">
<Button
onClick={onExit}
type="default"
size="tiny"
className="absolute top-4 right-4 z-10"
icon={<X strokeWidth={1} size={16} />}
/>
const renderSelectionOverlay = isSelectionActive ? (
<div className="absolute inset-0 bg-surface-100/95 backdrop-blur-md z-20 flex items-center justify-center p-6">
<Button
onClick={onExit}
type="default"
size="tiny"
className="absolute top-4 right-4"
icon={<X strokeWidth={1} size={16} />}
/>
<div className="bg-surface-100 border border-overlay rounded-lg shadow-xl w-full max-w-lg p-6 flex flex-col gap-4">
{!weaponSelectionPrompt ? (
<>
<div className="text-center space-y-1">
<h2 className="text-lg font-medium">Select an item</h2>
<p className="text-sm text-foreground-light">
{remainingSelections} selection{remainingSelections !== 1 ? 's' : ''} remaining
</p>
</div>
<div className="flex flex-wrap gap-3 justify-center">
{currentOptions.map((card) => {
const weaponOptions = getWeaponOptions(card)
const isDisabled = card.requiresWeaponSelection && weaponOptions.length === 0
<div className="w-full h-full flex flex-col items-center justify-center gap-6 p-6">
<div className="text-center">
<h2 className="text-lg font-medium mb-2">Select Your Items</h2>
<p className="text-sm text-foreground-light">
Choose up to {maxCards} item{maxCards !== 1 ? 's' : ''} to enhance your abilities
</p>
</div>
<div className="flex gap-3 flex-wrap justify-center max-w-md">
{availableCards.map((card) => {
const isSelected = selectedCards.find((c) => c.id === card.id)
return (
<button
key={card.id}
onClick={() => handleCardSelect(card)}
className={`
px-4 py-3 rounded-lg border-2 transition-all
${
isSelected
? 'border-brand bg-brand/10 scale-105'
: 'border-overlay hover:border-foreground-muted hover:scale-105'
}
`}
>
<div className="text-sm font-medium">{card.name}</div>
<div className="text-xs text-foreground-light mt-1">{card.description}</div>
</button>
)
})}
</div>
<Button onClick={handleStartGame} size="small" type="primary">
Start Game
</Button>
</div>
return (
<button
key={card.id}
type="button"
onClick={() => handleCardSelect(card)}
disabled={isDisabled}
className={`w-44 px-4 py-3 rounded-lg border-2 transition-all text-left ${
isDisabled
? 'border-overlay opacity-40 cursor-not-allowed'
: 'border-overlay hover:border-brand hover:bg-brand/10'
}`}
>
<div className="text-sm font-medium">{card.name}</div>
<div className="text-xs text-foreground-light mt-1">{card.description}</div>
{card.requiresWeaponSelection && weaponOptions.length === 0 && (
<div className="text-xs text-foreground-muted mt-2">
Unlock a compatible weapon to use this item
</div>
)}
</button>
)
})}
</div>
{currentOptions.length === 0 && (
<div className="text-center text-sm text-foreground-muted">
Generating more items...
</div>
)}
</>
) : (
<>
<div className="text-center space-y-1">
<h3 className="text-lg font-medium">Choose a weapon</h3>
<p className="text-sm text-foreground-light">
{weaponSelectionPrompt.card.name} can be applied to multiple weapons
</p>
</div>
<div className="flex flex-col gap-2">
{weaponSelectionPrompt.options.map((option) => (
<Button key={option} onClick={() => handleWeaponSelection(option)} size="tiny">
{weaponTypeLabels[option]}
</Button>
))}
</div>
<Button
onClick={() => setWeaponSelectionPrompt(null)}
type="text"
size="tiny"
className="self-center"
>
Back to items
</Button>
</>
)}
</div>
)
}
</div>
) : null
// Game over screen
if (gameStatus === 'game_over') {
@@ -255,6 +408,7 @@ export const SurvivalShooter = ({ availableResources, onExit }: SurvivalShooterP
onMouseMove={updateMousePosition}
onResize={setCanvasSize}
/>
{renderSelectionOverlay}
</div>
)
}
@@ -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 {
@@ -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<GameState | null>(null)
const keysPressed = useRef<Set<string>>(new Set())
const animationFrameId = useRef<number | null>(null)
const lastFrameTime = useRef<number>(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<GameConfig>({
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, WeaponModifierState> = {
[WeaponType.NORMAL]: createBaseModifierState(),
[WeaponType.RING]: createBaseModifierState(),
}
cards.forEach((card) => {
card.perks.forEach((perk) => {
const unlockedWeapons = new Set<WeaponType>([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<WeaponType, WeaponModifierState>,
unlockedWeapons: Set<WeaponType>
): 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(() => {