Merge remote-tracking branch 'origin/dev' into dev_table_basse

This commit is contained in:
MateoLT
2025-04-10 22:58:18 +02:00
10 changed files with 405 additions and 188 deletions

View File

@@ -177,7 +177,7 @@
"attack_interval": 2.0,
"attack_range": 300,
"sprite_sheet": "assets/map/enemy/turret.gif",
"size": [50,100]
"size": [80,80]
},
{
"id": "enemy3_01",
@@ -206,7 +206,7 @@
"attack_interval": 2.0,
"attack_range": 300,
"sprite_sheet": "assets/map/enemy/turret.gif",
"size": [50,100]
"size": [80,80]
}
],

View File

@@ -272,7 +272,7 @@
"attack_interval": 2.0,
"attack_range": 300,
"sprite_sheet": "assets/map/enemy/turret.gif",
"size": [50,100]
"size": [80,80]
},
{
"id": "enemy2_02",
@@ -343,7 +343,7 @@
"attack_interval": 2.0,
"attack_range": 300,
"sprite_sheet": "assets/map/enemy/turret.gif",
"size": [50,100]
"size": [80,80]
},
{
"id": "enemy1_05",
@@ -356,7 +356,7 @@
"attack_interval": 2.0,
"attack_range": 300,
"sprite_sheet": "assets/map/enemy/turret.gif",
"size": [50,100]
"size": [80,80]
},
{
"id": "enemy1_06",

View File

@@ -135,6 +135,19 @@
"attack_interval": 2.0,
"attack_range": 300,
"sprite_sheet": "assets/map/enemy/turret_enemy.png"
},
{
"id": "boss",
"type": "boss",
"x": 2000,
"y": 0,
"health": 3,
"damage": 1,
"behavior": "boss",
"attack_interval": 1.0,
"attack_range": 1000,
"sprite_sheet": "assets/map/enemy/boss.gif",
"size": [200,200]
}
],

View File

@@ -30,13 +30,13 @@ class Enemy(Entity):
sprite_path = enemy_data.get("sprite_sheet", "assets/enemy/default_enemy.png")
# Load sprite sheet or GIF depending on enemy type and file extension
if sprite_path.lower().endswith(".gif") and self.enemy_type == "turret":
self.load_gif_frames(sprite_path)
if sprite_path.lower().endswith(".gif"):
self.load_gif_frames(sprite_path, self.size)
if self.frames:
self.surf = self.frames[0]
else:
# Default sprite
self.surf = pygame.Surface((40, 40))
self.surf = pygame.Surface(self.size)
self.surf.fill((255, 0, 0))
else:
try:
@@ -44,7 +44,7 @@ class Enemy(Entity):
self.surf = pygame.transform.scale(self.surf, self.size)
except:
# Default sprite
self.surf = pygame.Surface((40, 40))
self.surf = pygame.Surface(self.size)
self.surf.fill((255, 0, 0))
# Initial rectangle
@@ -63,7 +63,7 @@ class Enemy(Entity):
self.is_attacking = False
self.detected_player = False
def load_gif_frames(self, gif_path):
def load_gif_frames(self, gif_path, size=(80, 80)):
"""Load frames from a GIF file"""
try:
gif = Image.open(gif_path)
@@ -73,7 +73,7 @@ class Enemy(Entity):
frame_surface = pygame.image.fromstring(
frame.convert("RGBA").tobytes(), frame.size, "RGBA"
)
frame_surface = pygame.transform.scale(frame_surface, (80, 80))
frame_surface = pygame.transform.scale(frame_surface, size)
self.frames.append(frame_surface)
frame_count += 1
@@ -92,9 +92,11 @@ class Enemy(Entity):
self.chase(player)
elif self.behavior == "stationary" and player:
self.stationary_attack(player)
elif self.behavior == "boss" and player:
self.boss(player)
# Animation management for turret enemies
if self.enemy_type == "turret" and self.frames:
if (self.enemy_type == "turret" or self.enemy_type == "boss") and self.frames:
self.animation_timer += dt
if self.animation_timer >= self.animation_speed:
self.animation_timer = 0
@@ -159,7 +161,7 @@ class Enemy(Entity):
self.is_attacking = True
# For turret-type enemies, create a projectile
if self.enemy_type == "turret":
if self.enemy_type == "turret" or self.enemy_type == "boss":
# Calculate direction to player
direction = vec(player.pos.x - self.pos.x, player.pos.y - self.pos.y)
@@ -209,3 +211,27 @@ class Enemy(Entity):
# KB LOL MINECRAFT
knockback_direction = 1 if player.pos.x > self.pos.x else -1
player.vel.x = knockback_direction * 8
def boss(self, player, FPS=60):
"""
Boss behavior: combine horizontal chase with turret-like attacks
"""
# Follow the player horizontally (x axis)
if abs(player.pos.x - self.pos.x) > 50:
direction = 1 if player.pos.x > self.pos.x else -1
self.pos.x += direction * self.speed
self.direction = direction
# Attack the player if within range
distance_to_player = vec(
player.pos.x - self.pos.x, player.pos.y - self.pos.y
).length()
if distance_to_player <= self.attack_range:
self.attack_timer += 1 / FPS
if self.attack_timer >= self.attack_interval:
self.attack_timer = 0
self.attack(player)
self.detected_player = distance_to_player <= self.detection_radius

View File

@@ -11,6 +11,7 @@ class Entity(pygame.sprite.Sprite):
self.pos = vec(pos)
self.vel = vec(0, 0)
self.acc = vec(0, 0)
self.alive = True
# Default surface
self.surf = pygame.Surface(size)

View File

@@ -24,9 +24,11 @@ class Exit(Entity):
sprite_path (str, optional): Path to the sprite image for the exit
"""
super().__init__(pos=(x, y), size=(width, height), color=(0, 255, 0))
self.next_level = next_level # Store the next level to load
self.next_level = next_level
self.active = True # Flag to prevent multiple triggers
self.player = None # Will store the player reference
self.player = None
self.boss = None
self.locked = False
# Load sprite if provided
if sprite_path:
@@ -46,16 +48,33 @@ class Exit(Entity):
"""
self.player = player
def set_boss(self, boss):
"""
Set the boss to this exit. The exit will be locked until the boss is defeated.
Args:
boss: The boss entity to check defeat status with
"""
self.boss = boss
self.locked = True
def update(self):
"""
Check for collision with the player and trigger level completion.
"""
# Check if the boss is defeated
if self.boss:
if hasattr(self.boss, "alive") and not self.boss.alive:
self.locked = False
elif not hasattr(self.boss, "alive"):
self.locked = False
# Skip collision check if player reference is not set
if not self.player or not self.active:
return
# Check if player is colliding with exit
if self.rect.colliderect(self.player.rect):
if self.rect.colliderect(self.player.rect) and not self.locked:
# Play the video and return to menu
self.play_video_and_return_to_menu("assets/map/exit/Zeldo Motus.mp4")
self.active = False # Prevent multiple triggers

View File

@@ -21,6 +21,8 @@ class Player(Entity):
self.jump_button = 0
self.dash_button = 1
self.attack_button = 2
self.menu_button = 3
try:
if pygame.joystick.get_count() > 0:
@@ -540,152 +542,170 @@ class Player(Entity):
self.is_attacking = False
current_time = pygame.time.get_ticks()
pressed_keys = pygame.key.get_pressed()
if pressed_keys[K_q] and pressed_keys[K_c]:
if current_time - self.last_attack_time >= self.attack_cooldown:
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
direction = vec(self.pos.x, self.pos.y)
projectile = Projectile(
pos=vec(self.pos.x, self.pos.y),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
)
# Add projectile to the sprite group (to be placed in main.py)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
)
if pressed_keys[K_d] and pressed_keys[K_c]:
if current_time - self.last_attack_time >= self.attack_cooldown:
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
direction = vec(self.pos.x, self.pos.y)
projectile = Projectile(
pos=vec(self.pos.x, self.pos.y),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
)
# Add projectile to the sprite group (to be placed in main.py)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
)
joystick_attack = False
if self.has_joystick and self.joystick:
try:
if self.joystick.get_numbuttons() > self.attack_button:
joystick_attack = self.joystick.get_button(self.attack_button)
except pygame.error:
pass
if pressed_keys[K_q] and pressed_keys[K_v]:
if current_time - self.last_attack_time >= self.attack_cooldown:
attack_sound = pygame.mixer.Sound("assets/sound/Boule de feu.mp3")
attack_sound.set_volume(0.4)
attack_sound.play()
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
direction = vec(-self.pos.x, 0)
projectile = Projectile(
pos=vec(self.pos.x - 50, self.pos.y - 50),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
texturePath="assets/player/Boule de feu.png",
size=(50, 50),
)
# Add projectile to the sprite group (to be placed in main.py)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
)
if self.projectiles > 0:
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
direction = vec(-self.pos.x, 0)
projectile = Projectile(
pos=vec(self.pos.x - 50, self.pos.y - 50),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
texturePath="assets/player/Boule de feu.png",
)
# Add projectile to the sprite group (to be placed in main.py)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
)
self.projectiles -= 1
if (
joystick_attack
and current_time - self.last_attack_time >= self.attack_cooldown
and self.projectiles > 0
):
attack_sound = pygame.mixer.Sound("assets/sound/Boule de feu.mp3")
attack_sound.set_volume(0.4)
attack_sound.play()
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
if pressed_keys[K_d] and pressed_keys[K_v]:
if current_time - self.last_attack_time >= self.attack_cooldown:
attack_sound = pygame.mixer.Sound("assets/sound/Boule de feu.mp3")
attack_sound.set_volume(0.4)
attack_sound.play()
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
# Direction en fonction de où le personnage est tourné
if self.facing_right:
direction = vec(self.pos.x, 0)
projectile = Projectile(
pos=vec(self.pos.x + 50, self.pos.y - 50),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
texturePath="assets/player/Boule de feu.png",
size=(50, 50),
position = vec(self.pos.x + 50, self.pos.y - 50)
else:
direction = vec(-self.pos.x, 0)
position = vec(self.pos.x - 50, self.pos.y - 50)
projectile = Projectile(
pos=position,
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
texturePath="assets/player/Boule de feu.png",
size=(50, 50),
)
# Ajouter le projectile au groupe de sprites
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
# Add projectile to the sprite group (to be placed in main.py)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
)
self.projectiles -= 1
if (
pressed_keys[K_q]
and pressed_keys[K_c]
and current_time - self.last_attack_time >= self.attack_cooldown
):
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
direction = vec(self.pos.x, self.pos.y)
projectile = Projectile(
pos=vec(self.pos.x, self.pos.y),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
)
# Add projectile to the sprite group (to be placed in main.py)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
if self.projectiles > 0:
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
direction = vec(self.pos.x, 0)
projectile = Projectile(
pos=vec(self.pos.x + 50, self.pos.y - 50),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
texturePath="assets/player/Boule de feu.png",
)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
)
self.projectiles -= 1
)
if (
pressed_keys[K_d]
and pressed_keys[K_c]
and current_time - self.last_attack_time >= self.attack_cooldown
):
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
direction = vec(self.pos.x, self.pos.y)
projectile = Projectile(
pos=vec(self.pos.x, self.pos.y),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
)
# Add projectile to the sprite group (to be placed in main.py)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
)
if (
pressed_keys[K_q]
and pressed_keys[K_v]
and current_time - self.last_attack_time >= self.attack_cooldown
and self.projectiles > 0
):
attack_sound = pygame.mixer.Sound("assets/sound/Boule de feu.mp3")
attack_sound.set_volume(0.4)
attack_sound.play()
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
direction = vec(-self.pos.x, 0)
projectile = Projectile(
pos=vec(self.pos.x - 50, self.pos.y - 50),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
texturePath="assets/player/Boule de feu.png",
)
# Add projectile to the sprite group (to be placed in main.py)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
)
self.projectiles -= 1
if (
pressed_keys[K_d]
and pressed_keys[K_v]
and current_time - self.last_attack_time >= self.attack_cooldown
and self.projectiles > 0
):
attack_sound = pygame.mixer.Sound("assets/sound/Boule de feu.mp3")
attack_sound.set_volume(0.4)
attack_sound.play()
self.is_attacking = True
self.attack_start_time = current_time
self.last_attack_time = current_time
# Calculate direction to player
direction = vec(self.pos.x, 0)
projectile = Projectile(
pos=vec(self.pos.x + 50, self.pos.y - 50),
direction=direction,
speed=2,
damage=1,
color=(165, 42, 42),
enemy_proj=False,
texturePath="assets/player/Boule de feu.png",
)
pygame.event.post(
pygame.event.Event(
pygame.USEREVENT,
{"action": "create_projectile", "projectile": projectile},
)
)
self.projectiles -= 1
def add_projectiles(self):
"""Set player projectiles to 3 and show floating text"""

View File

@@ -0,0 +1,97 @@
import pygame
import math
from src.Menu.BackgroundManager import BackgroundManager
class InstructionsScreen:
def __init__(self, game_resources):
self.game_resources = game_resources
self.bg_manager = BackgroundManager(game_resources.WIDTH, game_resources.HEIGHT)
self.title_font = pygame.font.SysFont("Arial", 72)
self.text_font = pygame.font.SysFont("Arial", 32)
self.blink_timer = 0
self.blink_speed = 0.5
def draw(self, surface):
self.bg_manager.draw(surface)
def render_text_with_outline(text, font, text_color, outline_color):
text_surface = font.render(text, True, text_color)
outline_surface = font.render(text, True, outline_color)
w, h = text_surface.get_size()
outline_surf = pygame.Surface((w + 2, h + 2), pygame.SRCALPHA)
# Dessiner le contour en décalant le texte
offsets = [
(1, 1),
(1, -1),
(-1, 1),
(-1, -1),
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
]
for dx, dy in offsets:
outline_surf.blit(outline_surface, (dx + 1, dy + 1))
# Dessiner le texte principal au centre
outline_surf.blit(text_surface, (1, 1))
return outline_surf
title_surf = render_text_with_outline(
"Game control", self.title_font, (255, 255, 255), (0, 0, 0)
)
title_rect = title_surf.get_rect(center=(self.game_resources.WIDTH // 2, 100))
surface.blit(title_surf, title_rect)
instructions = [
"Q : Move left",
"D : Move right",
"A : Dash",
"Espace : Jump",
"V: Attack",
"Escape : Pause / Menu",
"Controller : Use the left joystick to move",
"B: Dash",
"A: Jump",
"X: Attack",
"Y: Pause / Menu",
]
y_offset = 180
line_spacing = 40
for line in instructions:
text_surf = render_text_with_outline(
line, self.text_font, (255, 255, 255), (0, 0, 0)
)
text_rect = text_surf.get_rect(
center=(self.game_resources.WIDTH // 2, y_offset)
)
surface.blit(text_surf, text_rect)
y_offset += line_spacing
self.blink_timer += 0.01
alpha = abs(math.sin(self.blink_timer * self.blink_speed)) * 255
skip_text = render_text_with_outline(
"Press any key to continue", self.text_font, (255, 220, 0), (0, 0, 0)
)
skip_text.set_alpha(int(alpha))
skip_rect = skip_text.get_rect(
center=(self.game_resources.WIDTH // 2, self.game_resources.HEIGHT - 100)
)
surface.blit(skip_text, skip_rect)
def handle_event(self, event):
if (
event.type == pygame.KEYDOWN
or event.type == pygame.MOUSEBUTTONDOWN
or event.type == pygame.JOYBUTTONDOWN
):
return "menu"
return None

View File

@@ -61,6 +61,14 @@ def initialize_game(game_resources, map_file="map/levels/1.json"):
for exit_obj in exits:
exit_obj.set_player(map_objects["player"])
enemies = map_objects.get("enemies", None)
if exits and enemies:
for enemy in enemies:
if hasattr(enemy, "enemy_type") and enemy.enemy_type == "boss":
for exit_obj in exits:
exit_obj.set_boss(enemy)
break
background = map_objects.get("background", None)
# If no background is found, use a default black background

View File

@@ -23,6 +23,7 @@ from src.Database.CheckpointDB import CheckpointDB
from src.Map.Editor.LevelEditor import LevelEditor
from src.Menu.LevelEditorSelectionMenu import LevelEditorSelectionMenu
from src.Map.Speedrun.SpeedrunTimer import SpeedrunTimer
from src.Menu.InstructionsScreen import InstructionsScreen
def initialize_game_resources():
@@ -63,8 +64,9 @@ def initialize_game_resources():
projectiles = pygame.sprite.Group()
# Game states initialization
current_state = 0 # MENU
current_state = 5 # INSTRUCTIONS
current_menu = "main"
instructions_screen = InstructionsScreen(game_resources)
main_menu = Menu(game_resources)
level_select_menu = None
editor_select_menu = None
@@ -99,6 +101,7 @@ def initialize_game_resources():
joysticks,
editor_select_menu,
leaderboard_db,
instructions_screen,
)
@@ -134,6 +137,16 @@ def handle_system_events(
)
# Update window dimensions
ORIGINAL_WIDTH, ORIGINAL_HEIGHT = event.w, event.h
elif event.type == pygame.JOYBUTTONDOWN:
try:
if event.button == 4: # Triangle sur la plupart des manettes
if current_state in [1, 2]: # PLAYING, INFINITE
current_state = 0 # MENU
else:
pygame.quit()
sys.exit()
except Exception as e:
print(f"Error while handling joystick button: {e}")
return current_state, fullscreen, displaysurface, ORIGINAL_WIDTH, ORIGINAL_HEIGHT
@@ -505,39 +518,43 @@ def handle_exits(P1, exits, game_resources, level_file, speedrun_timer=None):
"""Handle collisions with level exits"""
exits_hit = pygame.sprite.spritecollide(P1, exits, False) if exits else []
for exit in exits_hit:
if speedrun_timer and speedrun_timer.is_running:
collected_coins = speedrun_timer.collected_items
total_coins = speedrun_timer.total_items
if not exit.locked:
if speedrun_timer and speedrun_timer.is_running:
collected_coins = speedrun_timer.collected_items
total_coins = speedrun_timer.total_items
speedrun_timer.stop()
speedrun_timer.save_time(collected_coins, total_coins)
if hasattr(game_resources, "infinite_mode") and game_resources.infinite_mode:
# Infinite mode: load the next level without going back to menu
if hasattr(game_resources, "infinite_mode_db"):
# Zeldo : add 100 points
game_resources.infinite_mode_db.add_score("player", 100)
# Add coins points also
game_resources.infinite_mode_db.add_score("player", P1.coins * 10)
result = handle_exit_collision(exit, game_resources, level_file)
return {"action": "continue_infinite", "result": result}
else:
# Normal mode: unlock the next level and return to menu
current_level_match = re.search(r"(\d+)\.json$", level_file)
if current_level_match:
current_level = int(current_level_match.group(1))
next_level = current_level + 1
speedrun_timer.stop()
speedrun_timer.save_time(collected_coins, total_coins)
if (
hasattr(game_resources, "infinite_mode")
and game_resources.infinite_mode
):
# Infinite mode: load the next level without going back to menu
if hasattr(game_resources, "infinite_mode_db"):
# Zeldo : add 100 points
game_resources.infinite_mode_db.add_score("player", 100)
# Add coins points also
game_resources.infinite_mode_db.add_score("player", P1.coins * 10)
result = handle_exit_collision(exit, game_resources, level_file)
return {"action": "continue_infinite", "result": result}
else:
# Normal mode: unlock the next level and return to menu
current_level_match = re.search(r"(\d+)\.json$", level_file)
if current_level_match:
current_level = int(current_level_match.group(1))
next_level = current_level + 1
# Unlock next level
db = LevelDB()
db.unlock_level(next_level)
db.close()
# Unlock next level
db = LevelDB()
db.unlock_level(next_level)
db.close()
# Return to level select menu
return {
"action": "return_to_level_select",
"current_state": 0, # MENU
"current_menu": "level_select",
}
# Return to level select menu
return {
"action": "return_to_level_select",
"current_state": 0, # MENU
"current_menu": "level_select",
}
return None
@@ -641,7 +658,7 @@ def handle_death_screen(
def handler():
"""Main function that handles the game flow"""
# Game state constants
MENU, PLAYING, INFINITE, LEADERBOARD, DEATH_SCREEN = 0, 1, 2, 3, 4
MENU, PLAYING, INFINITE, LEADERBOARD, DEATH_SCREEN, INSTRUCTIONS = 0, 1, 2, 3, 4, 5
previous_state = None
# Initialize game resources and states
@@ -663,6 +680,7 @@ def handler():
joysticks,
editor_select_menu,
leaderboard_db,
instructions_screen,
) = initialize_game_resources()
# Initialize editor variables
@@ -683,6 +701,7 @@ def handler():
print(f"Error while getting events: {e}")
pygame.joystick.quit()
pygame.joystick.init()
events = []
continue
# Process events
@@ -762,6 +781,13 @@ def handler():
game_resources
)
elif current_state == INSTRUCTIONS:
for event in events:
result = instructions_screen.handle_event(event)
if result == "menu":
current_state = MENU
instructions_screen.draw(displaysurface)
# Process general game events (player death, projectiles, etc.)
if event.type == USEREVENT:
current_state, death_timer, checkpoint_data, projectiles = (
@@ -925,6 +951,13 @@ def handler():
elif death_result["action"] == "return_to_menu":
current_state = death_result["current_state"]
elif current_state == INSTRUCTIONS:
for event in events:
result = instructions_screen.handle_event(event)
if result == "menu":
current_state = MENU
instructions_screen.draw(displaysurface)
# Update display
pygame.display.update()
game_resources.FramePerSec.tick(game_resources.FPS)