mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 05:09:12 +03:00
more mechanics
This commit is contained in:
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>
|
||||
)
|
||||
|
||||
+99
-94
@@ -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
|
||||
|
||||
+286
-132
@@ -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>
|
||||
)
|
||||
}
|
||||
+10
-2
@@ -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 {
|
||||
|
||||
+296
-176
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user