From 1c231e2a319e3201fd4c62573a2bfb90f2c47af7 Mon Sep 17 00:00:00 2001 From: amr-abdalla Date: Thu, 5 Mar 2026 10:26:08 -0500 Subject: [PATCH 1/4] Added StatisticsObserver --- code/games/action_map.py | 5 + code/games/modules/Objects/Player.py | 58 ++++--- .../games/modules/Stats/LevelStatsObserver.py | 113 ++++++++++++ code/games/modules/Stats/StatsObserver.py | 58 +++++++ code/games/modules/System/PhysicsManager.py | 26 ++- code/games/platformer_core.py | 31 ++-- ...ut.tfevents.1771935894.gpu-server.550633.0 | Bin 0 -> 88 bytes training_events.csv | 164 +----------------- 8 files changed, 250 insertions(+), 205 deletions(-) create mode 100644 code/games/action_map.py create mode 100644 code/games/modules/Stats/LevelStatsObserver.py create mode 100644 code/games/modules/Stats/StatsObserver.py create mode 100644 mylogs/platformer_ppo_platformer_master/ppo_platformer_master_novice_1/events.out.tfevents.1771935894.gpu-server.550633.0 diff --git a/code/games/action_map.py b/code/games/action_map.py new file mode 100644 index 0000000..a284f39 --- /dev/null +++ b/code/games/action_map.py @@ -0,0 +1,5 @@ +ACTION_NAMES = { + 0: "IDLE", 1: "LEFT", 2: "RIGHT", 3: "JUMP", + 4: "RIGHT+JUMP", 5: "RUN+RIGHT", 6: "LEFT+JUMP", 7: "RUN+RIGHT+JUMP", + 8: "RUN+LEFT", 9: "RUN+LEFT+JUMP" +} diff --git a/code/games/modules/Objects/Player.py b/code/games/modules/Objects/Player.py index 97dcf0b..746be8e 100644 --- a/code/games/modules/Objects/Player.py +++ b/code/games/modules/Objects/Player.py @@ -4,6 +4,7 @@ import pygame from typing import Dict, List, Any, Optional from enum import Enum, auto +from ..Stats.StatsObserver import track # Imports from sibling packages from ..Parameters import Movement_parameters as MP @@ -127,12 +128,11 @@ def _update_animation_logic(self, dt: float): # Hand the state (int) to the handler self.anim_handler.set_state(target_state.value) - def handle_input(self, a: int): - agent_left = (a in (1,6)) - agent_right = (a in (2,4,5,7)) - agent_jump = (a in (3,4,6,7)) - agent_run = (a in (5,7)) - + @track("horizontal_velocity") + def set_horizontal_velocity(self, vx): + self.vx = vx + + def check_input(self, a: int): kb_left = kb_right = kb_jump = kb_run = False if pygame.get_init(): @@ -141,7 +141,16 @@ def handle_input(self, a: int): kb_right = keys[pygame.K_RIGHT] or keys[pygame.K_d] kb_jump = keys[pygame.K_SPACE] or keys[pygame.K_w] or keys[pygame.K_UP] kb_run = keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT] - + + self.handle_input(a, kb_left, kb_right, kb_jump, kb_run) + + @track("input") + def handle_input(self, a:int, kb_left, kb_right, kb_jump, kb_run): + agent_left = (a in (1,6)) + agent_right = (a in (2,4,5,7)) + agent_jump = (a in (3,4,6,7)) + agent_run = (a in (5,7)) + is_left = agent_left or kb_left is_right = agent_right or kb_right self.jump_pressed = agent_jump or kb_jump @@ -155,6 +164,7 @@ def handle_input(self, a: int): if self.jump_pressed: self.jump_buffer = 6 + def apply_physics(self, dt: float, ctx: PhysicsContext): if self.run_held: target_max = ctx.MAX_RUN_SPEED @@ -171,14 +181,14 @@ def apply_physics(self, dt: float, ctx: PhysicsContext): skidding = (self.vx > 0 and self.input_dir < 0) or (self.vx < 0 and self.input_dir > 0) if self.on_ground and skidding: - self.vx += (self.input_dir * ctx.SKID_DECEL * dt) + self.set_horizontal_velocity(self.vx + (self.input_dir * ctx.SKID_DECEL * dt)) else: - if self.input_dir > 0: self.vx = min(self.vx + (accel_rate * dt), target_max) - else: self.vx = max(self.vx - (accel_rate * dt), -target_max) + if self.input_dir > 0: self.set_horizontal_velocity(min(self.vx + (accel_rate * dt), target_max)) + else: self.set_horizontal_velocity(max(self.vx - (accel_rate * dt), -target_max)) else: friction = (ctx.GROUND_FRICTION if self.on_ground else ctx.AIR_FRICTION) * dt - if self.vx > 0: self.vx = max(0, self.vx - friction) - elif self.vx < 0: self.vx = min(0, self.vx + friction) + if self.vx > 0: self.set_horizontal_velocity(max(0, self.vx - friction)) + elif self.vx < 0: self.set_horizontal_velocity(min(0, self.vx + friction)) self.handle_jump(dt, ctx) @@ -190,19 +200,27 @@ def handle_jump(self, dt: float, ctx: PhysicsContext): if self.jump_buffer > 0: self.jump_buffer -= 1 if (self.coyote > 0) and (self.jump_hold == 0) and (self.jump_buffer > 0): - base = ctx.JUMP_VEL_MIN - bonus = min(2.2, abs(self.vx) * ctx.SPEED_JUMP_BONUS) - self.vy = base - bonus - self.on_ground = False - self.coyote = 0 - self.jump_hold = ctx.JUMP_HOLD_FRAMES - self.jump_buffer = 0 + self.start_jump(ctx) if self.jump_hold > 0: if self.jump_pressed: self.vy -= 0.30 * (dt * 60) self.jump_hold -= 1 - + + @track("jump") + def start_jump(self, ctx: PhysicsContext): + base = ctx.JUMP_VEL_MIN + bonus = min(2.2, abs(self.vx) * ctx.SPEED_JUMP_BONUS) + self.vy = base - bonus + self.on_ground = False + self.coyote = 0 + self.jump_hold = ctx.JUMP_HOLD_FRAMES + self.jump_buffer = 0 + +# Player config +# Tracking true or false +# What to track + def render(self, surface: pygame.Surface, sx: float, sy: float, debug: bool = True): # 1. Get Sprite from Handler sprite = None diff --git a/code/games/modules/Stats/LevelStatsObserver.py b/code/games/modules/Stats/LevelStatsObserver.py new file mode 100644 index 0000000..1593aa4 --- /dev/null +++ b/code/games/modules/Stats/LevelStatsObserver.py @@ -0,0 +1,113 @@ +from ...action_map import ACTION_NAMES + +class LevelStatsObserver: + def __init__(self): + self.deaths = {} + self.jumps = 0 + self.coins_collected = 0 + self.enemies_killed = 0 + self.actions = {i: 0 for i in ACTION_NAMES} + self.sum_vx = 0.0 + self.count_vx = 0 + + def record(self, event_type, **data): + handler_name = f"record_{event_type}" + handler = getattr(self, handler_name, None) + + if handler: + handler(**data) + + def record_jump(self, **data): + self.jumps += 1 + + def record_death(self, cause, **data): + self.deaths[cause] = self.deaths.get(cause, 0) + 1 + + def record_coins_collected(self, **data): + self.coins_collected += 1 + + def record_enemies_killed(self, **data): + self.enemies_killed += 1 + + def record_horizontal_velocity(self, vx, **data): + self.sum_vx += abs(vx) + self.count_vx += 1 + + + def record_input(self, **data): + # Extract the keyboard/agent booleans from data + kb_left = data.get("kb_left", False) + kb_right = data.get("kb_right", False) + kb_jump = data.get("kb_jump", False) + kb_run = data.get("kb_run", False) + + # Compute action index (0–9) based on the original ACTION_NAMES mapping + if kb_run: + if kb_left and kb_jump: + action_id = 9 # RUN+LEFT+JUMP + elif kb_left: + action_id = 8 # RUN+LEFT + elif kb_right and kb_jump: + action_id = 7 # RUN+RIGHT+JUMP + elif kb_right: + action_id = 5 # RUN+RIGHT + else: + action_id = 0 # IDLE / run alone not mapped + else: + if kb_left and kb_jump: + action_id = 6 # LEFT+JUMP + elif kb_left: + action_id = 1 # LEFT + elif kb_right and kb_jump: + action_id = 4 # RIGHT+JUMP + elif kb_right: + action_id = 2 # RIGHT + elif kb_jump: + action_id = 3 # JUMP + else: + action_id = 0 # IDLE + # Increment the counter + self.actions[action_id] += 1 + + def set_elapsed_time (self, elapsed_time): + self.elapsed_time = elapsed_time + + def get_average_vx(self): + if (self.count_vx == 0): + return 0 + else: + return self.sum_vx / self.count_vx + + def print(self): + print(f"Jumps : {self.jumps}") + print(f"Coins Collected : {self.coins_collected}") + print(f"Enemies Killed : {self.enemies_killed}") + print(f"Elapsed Time : {self.elapsed_time:.2f}") + print(f"Average Horizontal Velocity : {self.get_average_vx():.2f}") + print("\nActions:") + for action_id, count in sorted(self.actions.items()): + if count > 0: + print(f" {ACTION_NAMES[action_id]:<18} {count}") + + print("\nDeaths :") + if not self.deaths: + print(" None") + else: + for cause, count in sorted(self.deaths.items()): + print(f" {cause} {count}") + + print("=" * 30 + "\n") + + def reset(self): + exclude = {"deaths"} # keep elapsed time + for attr, value in self.__dict__.items(): + if attr in exclude: + continue + if isinstance(value, int) or isinstance(value, float): + setattr(self, attr, 0) + elif isinstance(value, dict): + setattr(self, attr, {}) + elif isinstance(value, type(self.actions)): + setattr(self, attr, {i: 0 for i in ACTION_NAMES}) + + self.actions = {i: 0 for i in ACTION_NAMES} \ No newline at end of file diff --git a/code/games/modules/Stats/StatsObserver.py b/code/games/modules/Stats/StatsObserver.py new file mode 100644 index 0000000..b40fcae --- /dev/null +++ b/code/games/modules/Stats/StatsObserver.py @@ -0,0 +1,58 @@ +import inspect +from functools import wraps +from.LevelStatsObserver import LevelStatsObserver +import time + +class StatsObserver: + def __init__(self): + self.last_reset_time = time.time() + + def init_level_observers(self, level_order): + self.levelObservers = {level: LevelStatsObserver() for level in level_order} + + def set_current_level(self, world): + self.currentLevel = world + + def get_current_level_observer(self): + return self.levelObservers[self.currentLevel] + + def record(self, event_type, **data): + self.get_current_level_observer().record(event_type, **data) + + def get_elapsed_time(self): + return time.time() - self.last_reset_time + + def print_all(self): + print("\n" + "=" * 30) + print(" GAME STATISTICS") + print("=" * 30) + + self.get_current_level_observer().set_elapsed_time(self.get_elapsed_time()) + self.get_current_level_observer().print() + + def reset(self): + self.get_current_level_observer().reset() + self.last_reset_time = time.time() + +statisticsObserver = StatsObserver() + +def track(event_type): + def decorator(func): + sig = inspect.signature(func) + + @wraps(func) + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + + # Remove "self" + arguments = dict(bound.arguments) + arguments.pop("self", None) + + statisticsObserver.record(event_type, **arguments) + + return result + return wrapper + return decorator \ No newline at end of file diff --git a/code/games/modules/System/PhysicsManager.py b/code/games/modules/System/PhysicsManager.py index c4441ea..904f8e0 100644 --- a/code/games/modules/System/PhysicsManager.py +++ b/code/games/modules/System/PhysicsManager.py @@ -13,6 +13,7 @@ from ..Objects.Coin import Coin from ..Objects.Powerup import Powerup from ..Objects.GameObject import GameObject +from ..Stats.StatsObserver import track @dataclass class PhysicsContext: @@ -469,39 +470,46 @@ def _handle_player_enemy(self, core, player, enemy, player_was_falling): # Check if we are above the enemy center and moving down (stomping) if player_bottom < enemy_center + 10 and moving_down: # Platformer jumped on enemy - enemy.gObj.active = False + self.player_kill_enemy(core, enemy) # Bounce player player.vy = JUMP_VEL_MIN * 0.6 self.context.score = getattr(core, 'score', 0) + 100 # Safety attr check - core.score += 100 - - if hasattr(core, 'kills_step'): core.kills_step += 1 - + elif player.invincible_timer > 0: # Star power - enemy.gObj.active = False - core.score += 100 - if hasattr(core, 'kills_step'): core.kills_step += 1 + self.player_kill_enemy(core, enemy) + else: # Lost powerup or Died if player.powered_up: player.powered_up = False player.invincible_timer = 60 else: - core._handle_death() + core._handle_death(cause= "enemy") return + @track("enemies_killed") + def player_kill_enemy(self, core, enemy): + enemy.gObj.active = False + core.score += 100 + if hasattr(core, 'kills_step'): core.kills_step += 1 + def _handle_player_coin(self, core, player, coin): """ Collects coin, increments score and coin counters. """ if not coin.collected: + self._collect_coin(core, coin) + + @track("coins_collected") + def _collect_coin(self, core, coin): coin.gObj.active = False coin.collected = True core.score += 10 core.coins_step += 1 core.coins_total += 1 + def _handle_player_powerup(self, core, player, powerup): """ Applies powerup effect (Super Mushroom or Star) and removes the item. diff --git a/code/games/platformer_core.py b/code/games/platformer_core.py index b5e456e..09f31e6 100644 --- a/code/games/platformer_core.py +++ b/code/games/platformer_core.py @@ -10,6 +10,7 @@ import gymnasium from gymnasium import spaces import psutil +from .modules.Stats.StatsObserver import statisticsObserver, track from code.games.modules.System import EntityType # --- CORRECTED IMPORTS FOR NEW FOLDER STRUCTURE --- @@ -36,6 +37,7 @@ COLOR_POWERUP_MUSH, COLOR_POWERUP_STAR, COLOR_COIN, COLOR_HITBOX, COLOR_SENSOR, COLOR_AGENT_PANEL, COLOR_STREAK, TILE_SIZE) +from .action_map import ACTION_NAMES # ============================================================================= # Screen / Tile geometry @@ -44,11 +46,6 @@ PLATFORMER_WIDTH, PLATFORMER_HEIGHT = 32, 32 # Action Map for Debug Display -ACTION_NAMES = { - 0: "IDLE", 1: "LEFT", 2: "RIGHT", 3: "JUMP", - 4: "RIGHT+JUMP", 5: "RUN+RIGHT", 6: "LEFT+JUMP", 7: "RUN+RIGHT+JUMP", - 8: "RUN+LEFT", 9: "RUN+LEFT+JUMP" -} class PlatformerCore(gymnasium.Env): WIDTH, HEIGHT = SCREEN_WIDTH, SCREEN_HEIGHT @@ -155,7 +152,10 @@ def __init__(self, render_mode: str = "none", **kwargs): # SPRITE MANAGER core_dir = os.path.dirname(os.path.abspath(__file__)) assets_dir = os.path.join(core_dir, "assets") - + + #init statistics Observer levels + statisticsObserver.init_level_observers(self.level_order) + statisticsObserver.set_current_level(self.world) # print(f"[DEBUG] Loading Assets from: {assets_dir}") # self.sprite_manager = SpriteManager(assets_dir, sprite_width=32, sprite_height=32, scale=1.5) @@ -172,6 +172,7 @@ def _load_reward_fn(self, persona_name): def get_action_space(self): return self._act_space def get_observation_space(self): return self._obs_space + track("horizontal_speed") def step(self, action: int): if not self.alive: return self._obs(), 0.0, True, False, {"episode_end": True, "won": self.reached_goal} @@ -213,9 +214,9 @@ def step(self, action: int): # 2. Player Input if not self.debug_manager.free_cam_active: - self.player.handle_input(a = int(action)) + self.player.check_input(a = int(action)) else: - self.player.vx = 0; self.player.jump_hold = 0 + self.player.set_horizontal_velocity(0); self.player.jump_hold = 0 # 3. Physics System Update self.physics_manager.update_system(self.dt, self) @@ -265,6 +266,8 @@ def reset(self, seed=None, options=None) -> np.ndarray: return self._obs(), self._info() def load_level(self): + statisticsObserver.print_all() + statisticsObserver.reset() self.alive = True self.frame = 0 self.game_over = False @@ -334,9 +337,11 @@ def complete_level(self): print("all levels done") # As requested self.current_index_world = 0 self.world = self.level_order[self.current_index_world] + statisticsObserver.set_current_level(self.world) self.load_level() - def _handle_death(self): + @track("death") + def _handle_death(self, cause = "cause"): self.lives -= 1 if self.lives > 0: self._soft_reset() @@ -430,12 +435,12 @@ def _check_termination(self) -> bool: # 1. TIME LIMIT if self.use_timer and self.timer <= 0: - self._handle_death() + self._handle_death(cause = "Time Limit") return True # 2. PIT DEATH (Y-limit) if player.gObj.y > self.level_data.height: - self._handle_death() + self._handle_death(cause= "Pit") return True # 3. GOAL & SPIKES (Hitbox Precision) @@ -456,7 +461,7 @@ def _check_termination(self) -> bool: # Check intersection if p_rect.colliderect(tile.gObj.get_rect()): if tid == EntityType.SPIKE: - self._handle_death() + self._handle_death(cause = "spike") return True elif tid == EntityType.GOAL: @@ -473,7 +478,7 @@ def _check_termination(self) -> bool: # 4. STALL DEATH if self.anti_stall and self.stall_windows_count >= self.stall_kill_windows: # print("Agent killed for stalling (camping).") - self._handle_death() + self._handle_death(cause= "stall") return True return False diff --git a/mylogs/platformer_ppo_platformer_master/ppo_platformer_master_novice_1/events.out.tfevents.1771935894.gpu-server.550633.0 b/mylogs/platformer_ppo_platformer_master/ppo_platformer_master_novice_1/events.out.tfevents.1771935894.gpu-server.550633.0 new file mode 100644 index 0000000000000000000000000000000000000000..5441d5191d6eeb52073eb9c39b5f387c04529c56 GIT binary patch literal 88 zcmeZZfPjCKJmzxVOPr^gmVV1oiZ`h!F*8rkwJbHS#L6g0k4vW{HLp0oC@DX&C`GTh hG&eV~s8X-ID6=HBNG}znDn2bUCp8`-^7Qt)2LK%MA$b4* literal 0 HcmV?d00001 diff --git a/training_events.csv b/training_events.csv index cb289e3..f5ba6bc 100644 --- a/training_events.csv +++ b/training_events.csv @@ -1,163 +1 @@ -Step,Event,Reward,Cause,Action,Level,X,Y,Vx,Vy,Goal_Dist -98,GAIN,2.0381,Move,RUN+RIGHT,0,937.4,390.9,1080.0,0.0,219.1 -99,GAIN,2.0183,Move,LEFT,0,954.8,394.1,1036.7,0.0,202.9 -100,GAIN,2.0030,Move,JUMP,0,972.1,397.9,1032.5,0.0,186.8 -101,GAIN,2.0858,Move,RUN+RIGHT,0,990.1,402.3,1097.5,0.0,170.0 -102,GAIN,2.1022,Move,JUMP,0,1008.3,407.2,1093.3,0.0,153.1 -103,GAIN,2.0816,Move,IDLE,0,1026.5,412.7,1089.2,0.0,136.4 -104,GAIN,2.0388,Move,IDLE,0,1044.6,418.6,1085.0,0.0,120.0 -123,LOSS,-129.3074,Move,LEFT+JUMP,0,50.0,500.0,0.0,0.0,1070.1 -201,GAIN,2.0764,Move,RUN+RIGHT+JUMP,0,635.2,287.8,962.5,0.0,534.1 -203,GAIN,2.0912,Move,RUN+RIGHT,0,665.7,299.3,945.0,0.0,501.7 -204,GAIN,2.2133,Move,RUN+RIGHT+JUMP,0,682.5,305.3,1010.0,0.0,483.9 -205,GAIN,2.2066,Move,IDLE,0,699.3,311.3,1005.8,0.0,466.2 -207,GAIN,2.0892,Move,RUN+RIGHT,0,729.7,323.3,945.0,0.0,433.6 -208,GAIN,2.2103,Move,RUN+RIGHT,0,746.5,329.3,1010.0,0.0,415.8 -209,GAIN,2.3000,Move,RUN+RIGHT+JUMP,0,764.2,335.3,1075.0,0.0,397.3 -210,GAIN,2.3225,Move,IDLE,0,782.0,341.3,1070.8,0.0,378.7 -212,GAIN,2.0841,Move,RUN+RIGHT+JUMP,0,812.4,353.3,945.0,0.0,346.1 -226,GAIN,2.0420,Move,RUN+RIGHT,0,1012.3,437.3,945.0,0.0,131.1 -227,GAIN,2.1344,Move,RUN+RIGHT+JUMP,0,1029.1,443.3,1010.0,0.0,114.0 -228,GAIN,2.0504,Move,LEFT,0,1045.4,449.3,966.7,0.0,97.5 -234,LOSS,-123.8648,Move,RIGHT,0,96.0,512.0,0.0,0.0,1024.0 -364,GAIN,6.2789,Coin,LEFT+JUMP,0,529.0,335.3,700.0,0.0,616.9 -392,GAIN,2.0795,Move,RUN+RIGHT+JUMP,0,885.6,385.1,945.0,0.0,266.6 -395,GAIN,2.0649,Move,RUN+RIGHT,0,930.5,403.1,940.8,0.0,218.5 -400,GAIN,2.0445,Move,RUN+RIGHT+JUMP,0,1004.7,433.1,945.0,0.0,139.7 -401,GAIN,2.0234,Move,IDLE,0,1020.4,439.1,940.8,0.0,123.4 -408,LOSS,-123.1985,Move,LEFT,0,96.0,512.0,0.0,0.0,1024.0 -523,LOSS,-122.8459,Move,RUN+RIGHT+JUMP,0,96.0,512.0,0.0,0.0,1024.0 -573,GAIN,6.8701,Coin,IDLE,0,400.2,444.2,871.7,0.0,723.0 -580,GAIN,6.8554,Coin,LEFT+JUMP,0,507.8,403.2,967.5,0.0,621.8 -586,GAIN,2.0154,Move,RUN+RIGHT+JUMP,0,608.7,367.5,1110.8,0.0,531.3 -587,GAIN,2.0273,Move,IDLE,0,627.2,362.4,1106.7,0.0,515.0 -588,GAIN,2.1068,Move,RUN+RIGHT,0,646.4,357.6,1171.7,0.0,498.1 -589,GAIN,2.1157,Move,JUMP,0,665.9,353.1,1167.5,0.0,481.1 -590,GAIN,2.0321,Move,LEFT+JUMP,0,684.8,348.9,1124.2,0.0,464.8 -596,GAIN,6.3685,Coin,LEFT+JUMP,0,779.4,329.1,828.3,0.0,386.6 -639,LOSS,-43.4210,Move,RIGHT,0,96.0,128.0,0.0,0.0,384.0 -698,STALL,-1.4578,Time/Camp,RUN+RIGHT,0,735.9,113.0,1066.7,0.0,754.1 -702,LOSS,-2.1097,Move,RIGHT,0,794.5,119.9,880.0,0.0,801.0 -703,LOSS,-2.2120,Move,RUN+RIGHT+JUMP,0,810.2,112.0,945.0,0.0,818.6 -704,LOSS,-2.3146,Move,RUN+RIGHT+JUMP,0,827.0,104.5,1010.0,0.0,837.0 -705,LOSS,-2.3917,Move,RUN+RIGHT,0,844.7,97.1,1075.0,0.0,856.0 -706,LOSS,-2.3470,Move,LEFT+JUMP,0,862.1,89.9,1031.7,0.0,874.6 -707,LOSS,-2.0351,Move,RIGHT,0,876.7,83.1,880.0,0.0,890.8 -708,LOSS,-2.1342,Move,RUN+RIGHT,0,892.5,76.5,945.0,0.0,907.8 -709,LOSS,-2.2379,Move,RUN+RIGHT,0,909.3,70.3,1010.0,0.0,925.5 -710,LOSS,-2.1674,Move,LEFT,0,925.6,64.2,966.7,0.0,942.8 -711,LOSS,-2.2222,Move,RUN+RIGHT,0,942.5,58.4,1031.7,0.0,960.4 -712,LOSS,-2.3274,Move,RUN+RIGHT,0,960.5,52.8,1096.7,0.0,978.9 -713,LOSS,-2.4335,Move,RUN+RIGHT,0,979.6,47.5,1161.7,0.0,998.3 -715,LOSS,-2.0320,Move,RUN+RIGHT+JUMP,0,1010.0,37.8,945.0,0.0,1029.7 -716,LOSS,-2.1385,Move,RUN+RIGHT+JUMP,0,1026.9,33.4,1010.0,0.0,1046.7 -717,LOSS,-2.0686,Move,LEFT,0,1043.2,29.2,966.7,0.0,1063.1 -718,LOSS,-2.1258,Move,RUN+RIGHT+JUMP,0,1060.1,25.2,1031.7,0.0,1080.0 -719,LOSS,-2.2341,Move,RUN+RIGHT+JUMP,0,1078.1,21.5,1096.7,0.0,1097.8 -758,STALL,0.3294,Time/Camp,RIGHT,0,1152.0,119.4,0.0,0.0,1126.6 -818,STALL,0.0669,Time/Camp,LEFT+JUMP,0,1151.3,158.6,-43.3,0.0,1112.9 -878,STALL,0.0102,Time/Camp,RUN+RIGHT+JUMP,0,1152.0,160.0,0.0,0.0,1113.1 -938,STALL,-0.3649,Time/Camp,RUN+RIGHT,0,1149.6,159.1,189.2,0.0,1111.1 -998,GAIN,86.0018,Move,JUMP,0,96.0,128.0,0.0,0.0,384.0 -1058,STALL,0.6283,Time/Camp,LEFT,0,223.0,125.3,117.5,0.0,407.0 -1118,STALL,-0.7230,Time/Camp,RIGHT,0,710.8,128.0,496.7,0.0,724.9 -1178,STALL,0.4098,Time/Camp,LEFT+JUMP,0,1132.3,137.4,-83.3,0.0,1101.9 -1238,STALL,-0.1061,Time/Camp,RUN+RIGHT,0,1145.1,160.0,69.2,0.0,1106.5 -1298,STALL,-0.0488,Time/Camp,RIGHT+JUMP,0,1151.6,173.5,86.7,0.0,1108.6 -1358,GAIN,85.4548,Move,RUN+RIGHT+JUMP,0,96.0,128.0,0.0,0.0,384.0 -1418,STALL,-1.2255,Time/Camp,IDLE,0,791.4,125.2,871.7,0.0,795.7 -1420,LOSS,-2.2700,Move,RUN+RIGHT,0,822.0,119.9,967.5,0.0,825.1 -1421,LOSS,-2.3477,Move,RUN+RIGHT,0,838.9,112.0,1032.5,0.0,843.8 -1422,LOSS,-2.3024,Move,LEFT,0,855.6,104.3,989.2,0.0,862.1 -1423,LOSS,-2.2617,Move,JUMP,0,872.0,97.0,985.0,0.0,880.1 -1424,LOSS,-2.0515,Move,RIGHT,0,886.7,89.8,880.0,0.0,896.4 -1425,LOSS,-2.1517,Move,RUN+RIGHT,0,902.5,83.0,945.0,0.0,913.4 -1426,LOSS,-2.2562,Move,RUN+RIGHT,0,919.3,76.5,1010.0,0.0,931.4 -1427,LOSS,-2.2397,Move,JUMP,0,936.1,70.2,1005.8,0.0,949.2 -1428,LOSS,-2.2176,Move,JUMP,0,952.8,64.1,1001.7,0.0,966.8 -1429,LOSS,-2.1411,Move,LEFT,0,968.9,58.3,958.3,0.0,983.8 -1431,LOSS,-2.0636,Move,RUN+RIGHT+JUMP,0,999.4,47.6,945.0,0.0,1015.7 -1432,LOSS,-2.0417,Move,JUMP,0,1015.0,42.7,940.8,0.0,1031.9 -1478,STALL,0.2458,Time/Camp,JUMP,0,1152.0,131.9,0.0,0.0,1122.3 -1538,STALL,0.1008,Time/Camp,RIGHT+JUMP,0,1149.1,160.5,-43.3,0.0,1110.2 -1598,STALL,0.0355,Time/Camp,JUMP,0,1152.0,160.3,0.0,0.0,1113.0 -1658,STALL,0.1424,Time/Camp,RIGHT+JUMP,0,1152.0,161.6,0.0,0.0,1112.6 -1718,GAIN,86.0173,Move,LEFT,0,96.0,128.0,0.0,0.0,384.0 -1778,STALL,-1.0649,Time/Camp,RUN+RIGHT,0,618.0,127.8,901.7,0.0,648.1 -1780,LOSS,-2.0453,Move,JUMP,0,646.5,119.9,850.0,0.0,675.8 -1782,LOSS,-2.0085,Move,RIGHT+JUMP,0,674.1,104.5,850.0,0.0,707.3 -1784,LOSS,-2.0094,Move,RUN+RIGHT,0,702.1,90.1,871.7,0.0,738.5 -1785,LOSS,-2.0060,Move,RIGHT,0,716.7,83.3,880.0,0.0,754.4 -1792,LOSS,-2.0044,Move,RUN+RIGHT,0,820.3,42.9,945.0,0.0,862.9 -1794,LOSS,-2.0126,Move,RUN+RIGHT,0,851.5,33.8,966.7,0.0,894.1 -1795,LOSS,-2.0889,Move,RUN+RIGHT+JUMP,0,868.4,29.5,1031.7,0.0,910.7 -1838,STALL,0.3311,Time/Camp,LEFT+JUMP,0,1151.3,131.2,-43.3,0.0,1121.9 -1898,STALL,0.0072,Time/Camp,RIGHT,0,1148.5,160.6,4.2,0.0,1109.6 -1958,STALL,0.2216,Time/Camp,IDLE,0,1147.0,163.0,-82.5,0.0,1107.4 -2018,STALL,0.1764,Time/Camp,RUN+RIGHT,0,1130.1,164.9,-48.3,0.0,1090.8 -2078,GAIN,85.4401,Move,RIGHT+JUMP,0,96.0,128.0,0.0,0.0,384.0 -2138,STALL,-0.7525,Time/Camp,RIGHT,0,503.0,125.9,844.2,0.0,561.0 -2142,LOSS,-2.0432,Move,RUN+RIGHT+JUMP,0,558.0,104.4,897.5,0.0,616.1 -2143,LOSS,-2.0190,Move,JUMP,0,572.9,97.1,893.3,0.0,632.1 -2144,LOSS,-2.1043,Move,RUN+RIGHT+JUMP,0,588.8,90.0,958.3,0.0,648.8 -2153,LOSS,-2.0387,Move,RUN+RIGHT+JUMP,0,721.6,38.1,1005.8,0.0,784.8 -2154,LOSS,-2.1119,Move,RUN+RIGHT+JUMP,0,739.1,33.6,1070.8,0.0,801.6 -2155,LOSS,-2.1218,Move,IDLE,0,756.9,29.3,1066.7,0.0,818.4 -2156,LOSS,-2.0534,Move,LEFT,0,774.2,25.3,1023.3,0.0,834.7 -2157,LOSS,-2.1059,Move,RUN+RIGHT,0,792.0,21.6,1088.3,0.0,851.5 -2158,LOSS,-2.0670,Move,LEFT,0,809.6,18.1,1045.0,0.0,867.9 -2198,STALL,0.3985,Time/Camp,LEFT,0,1143.3,136.0,-78.3,0.0,1112.8 -2258,STALL,-0.0069,Time/Camp,RUN+RIGHT+JUMP,0,1151.6,160.6,21.7,0.0,1112.6 -2318,STALL,-0.1673,Time/Camp,LEFT+JUMP,0,1128.9,163.2,112.5,0.0,1090.3 -2378,STALL,0.1649,Time/Camp,LEFT+JUMP,0,1151.3,165.2,-43.3,0.0,1110.8 -2438,GAIN,85.3441,Move,JUMP,0,96.0,128.0,0.0,0.0,384.0 -2498,STALL,-0.4582,Time/Camp,JUMP,0,468.8,107.2,718.3,0.0,550.3 -2558,STALL,-0.0516,Time/Camp,RUN+RIGHT+JUMP,0,1151.5,113.8,155.8,0.0,1128.1 -2618,STALL,0.0050,Time/Camp,LEFT+JUMP,0,1152.3,158.7,-17.5,0.0,1113.9 -2678,STALL,-0.0538,Time/Camp,RUN+RIGHT+JUMP,0,1152.0,160.0,0.0,0.0,1113.1 -2738,STALL,0.1514,Time/Camp,RUN+RIGHT,0,1152.0,164.4,0.0,0.0,1111.7 -2798,GAIN,83.7987,Move,RIGHT+JUMP,0,96.0,128.0,0.0,0.0,384.0 -2858,STALL,-0.7745,Time/Camp,LEFT+JUMP,0,643.3,126.1,707.5,0.0,669.6 -2918,STALL,0.1193,Time/Camp,RUN+RIGHT,0,1149.1,131.8,65.0,0.0,1119.7 -2978,STALL,0.0994,Time/Camp,LEFT+JUMP,0,1151.3,159.3,-43.3,0.0,1112.6 -3038,STALL,0.0375,Time/Camp,IDLE,0,1152.0,162.2,0.0,0.0,1112.4 -3098,STALL,-0.3845,Time/Camp,LEFT+JUMP,0,1108.7,162.1,223.3,0.0,1071.4 -3158,GAIN,85.8720,Move,JUMP,0,96.0,128.0,0.0,0.0,384.0 -3218,STALL,-0.9687,Time/Camp,RIGHT+JUMP,0,585.2,125.2,880.0,0.0,623.6 -3220,LOSS,-2.0883,Move,RIGHT+JUMP,0,614.5,119.9,880.0,0.0,650.1 -3223,LOSS,-2.0261,Move,RIGHT+JUMP,0,656.9,97.1,875.8,0.0,697.7 -3224,LOSS,-2.1172,Move,RUN+RIGHT,0,672.6,90.1,940.8,0.0,714.5 -3225,LOSS,-2.0273,Move,LEFT+JUMP,0,687.6,83.2,897.5,0.0,730.6 -3226,LOSS,-2.1200,Move,RUN+RIGHT,0,703.6,76.7,962.5,0.0,747.5 -3227,LOSS,-2.1913,Move,RUN+RIGHT,0,720.5,70.4,1027.5,0.0,764.9 -3278,STALL,0.1188,Time/Camp,RUN+RIGHT+JUMP,0,1152.4,131.5,65.0,0.0,1122.8 -3338,STALL,-0.1360,Time/Camp,JUMP,0,1149.1,159.3,73.3,0.0,1110.6 -3398,STALL,0.1130,Time/Camp,RIGHT,0,1148.6,160.4,-39.2,0.0,1109.8 -3458,STALL,0.4346,Time/Camp,LEFT+JUMP,0,1140.3,161.9,-190.8,0.0,1101.5 -3518,GAIN,61.0897,Move,LEFT+JUMP,0,96.0,128.0,0.0,0.0,384.0 -3578,STALL,-0.3861,Time/Camp,RUN+RIGHT+JUMP,0,428.9,125.5,705.8,0.0,510.2 -3584,LOSS,-2.0010,Move,RUN+RIGHT+JUMP,0,510.3,90.1,931.7,0.0,591.4 -3585,LOSS,-2.0821,Move,RUN+RIGHT+JUMP,0,526.9,83.3,996.7,0.0,607.9 -3586,LOSS,-2.0699,Move,IDLE,0,543.5,76.7,992.5,0.0,624.3 -3587,LOSS,-2.0062,Move,LEFT+JUMP,0,559.5,70.3,949.2,0.0,640.3 -3588,LOSS,-2.0692,Move,RUN+RIGHT+JUMP,0,576.4,64.3,1014.2,0.0,656.7 -3589,LOSS,-2.1372,Move,RUN+RIGHT,0,594.1,58.5,1079.2,0.0,673.7 -3590,LOSS,-2.2275,Move,RUN+RIGHT+JUMP,0,612.9,52.9,1144.2,0.0,691.4 -3591,LOSS,-2.2391,Move,JUMP,0,631.9,47.6,1140.0,0.0,709.2 -3638,STALL,0.3916,Time/Camp,LEFT,0,1148.3,132.0,-74.2,0.0,1118.8 -3698,STALL,0.0148,Time/Camp,RUN+RIGHT,0,1152.0,160.6,0.0,0.0,1112.9 -3758,STALL,0.1215,Time/Camp,LEFT+JUMP,0,1151.3,161.2,-43.3,0.0,1112.1 -3818,STALL,0.0581,Time/Camp,JUMP,0,1152.0,163.0,0.0,0.0,1112.2 -3878,GAIN,84.4631,Move,JUMP,0,96.0,128.0,0.0,0.0,384.0 -3938,STALL,-1.1912,Time/Camp,LEFT+JUMP,0,717.3,125.2,897.5,0.0,731.9 -3940,LOSS,-2.2110,Move,LEFT+JUMP,0,748.9,119.8,924.2,0.0,761.6 -3941,LOSS,-2.0917,Move,RIGHT,0,763.6,112.0,880.0,0.0,778.2 -3942,LOSS,-2.0749,Move,RIGHT,0,778.2,104.4,880.0,0.0,794.7 -3943,LOSS,-2.0511,Move,JUMP,0,792.8,97.1,875.8,0.0,811.0 -3944,LOSS,-2.1509,Move,RUN+RIGHT,0,808.5,90.0,940.8,0.0,828.1 -3945,LOSS,-2.2515,Move,RUN+RIGHT,0,825.3,83.2,1005.8,0.0,846.0 -3946,LOSS,-2.1818,Move,LEFT+JUMP,0,841.5,76.6,962.5,0.0,863.3 -3947,LOSS,-2.0881,Move,LEFT,0,857.0,70.3,919.2,0.0,879.9 -3950,LOSS,-2.0492,Move,RUN+RIGHT,0,901.7,53.0,936.7,0.0,927.3 -3998,STALL,0.3871,Time/Camp,JUMP,0,1130.2,132.0,-70.0,0.0,1101.8 -4058,STALL,-0.0679,Time/Camp,RUN+RIGHT+JUMP,0,1152.4,164.4,65.0,0.0,1112.1 +Step,Event,Reward,Cause,Action,Level,X,Y,Vx,Vy,Goal_Dist From 6104c57bdfb33591622a773cdd8d4b76ecb5c33d Mon Sep 17 00:00:00 2001 From: amr-abdalla Date: Thu, 12 Mar 2026 09:52:10 -0400 Subject: [PATCH 2/4] Write Data to csv file --- .../games/modules/Stats/LevelStatsObserver.py | 50 +++++++++++-------- code/games/modules/Stats/StatsObserver.py | 30 ++++------- code/games/platformer_core.py | 20 ++++---- 3 files changed, 48 insertions(+), 52 deletions(-) diff --git a/code/games/modules/Stats/LevelStatsObserver.py b/code/games/modules/Stats/LevelStatsObserver.py index 1593aa4..f874e9a 100644 --- a/code/games/modules/Stats/LevelStatsObserver.py +++ b/code/games/modules/Stats/LevelStatsObserver.py @@ -1,14 +1,17 @@ from ...action_map import ACTION_NAMES +import csv +import os class LevelStatsObserver: - def __init__(self): - self.deaths = {} + def __init__(self, levelName): + self.deathCause = "Success" self.jumps = 0 self.coins_collected = 0 self.enemies_killed = 0 self.actions = {i: 0 for i in ACTION_NAMES} self.sum_vx = 0.0 self.count_vx = 0 + self.levelName = levelName def record(self, event_type, **data): handler_name = f"record_{event_type}" @@ -21,7 +24,7 @@ def record_jump(self, **data): self.jumps += 1 def record_death(self, cause, **data): - self.deaths[cause] = self.deaths.get(cause, 0) + 1 + self.deathCause = cause def record_coins_collected(self, **data): self.coins_collected += 1 @@ -78,25 +81,30 @@ def get_average_vx(self): else: return self.sum_vx / self.count_vx - def print(self): - print(f"Jumps : {self.jumps}") - print(f"Coins Collected : {self.coins_collected}") - print(f"Enemies Killed : {self.enemies_killed}") - print(f"Elapsed Time : {self.elapsed_time:.2f}") - print(f"Average Horizontal Velocity : {self.get_average_vx():.2f}") - print("\nActions:") - for action_id, count in sorted(self.actions.items()): - if count > 0: - print(f" {ACTION_NAMES[action_id]:<18} {count}") - - print("\nDeaths :") - if not self.deaths: - print(" None") - else: - for cause, count in sorted(self.deaths.items()): - print(f" {cause} {count}") + def write_to_csv(self, filename): + file_exists = os.path.isfile(filename) + + row = { + "level": self.levelName, + "jumps": self.jumps, + "coins_collected": self.coins_collected, + "enemies_killed": self.enemies_killed, + "elapsed_time": round(self.elapsed_time, 2), + "avg_horizontal_velocity": round(self.get_average_vx(), 2), + "cause_of_death": self.deathCause + } + + for action_id, count in self.actions.items(): + row[f"action_{ACTION_NAMES[action_id]}"] = count + + with open(filename, "a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=row.keys()) + + # Write header only if file doesn't exist yet + if not file_exists: + writer.writeheader() - print("=" * 30 + "\n") + writer.writerow(row) def reset(self): exclude = {"deaths"} # keep elapsed time diff --git a/code/games/modules/Stats/StatsObserver.py b/code/games/modules/Stats/StatsObserver.py index b40fcae..c1b5e74 100644 --- a/code/games/modules/Stats/StatsObserver.py +++ b/code/games/modules/Stats/StatsObserver.py @@ -6,35 +6,23 @@ class StatsObserver: def __init__(self): self.last_reset_time = time.time() - - def init_level_observers(self, level_order): - self.levelObservers = {level: LevelStatsObserver() for level in level_order} - def set_current_level(self, world): - self.currentLevel = world - - def get_current_level_observer(self): - return self.levelObservers[self.currentLevel] - def record(self, event_type, **data): - self.get_current_level_observer().record(event_type, **data) + self.currentAttempt.record(event_type, **data) def get_elapsed_time(self): return time.time() - self.last_reset_time - def print_all(self): - print("\n" + "=" * 30) - print(" GAME STATISTICS") - print("=" * 30) - - self.get_current_level_observer().set_elapsed_time(self.get_elapsed_time()) - self.get_current_level_observer().print() + def write_to_csv(self, deathReason = "Success", filename="stats.csv"): + self.currentAttempt.deathCause = deathReason + self.currentAttempt.set_elapsed_time(self.get_elapsed_time()) + self.currentAttempt.write_to_csv(filename) - def reset(self): - self.get_current_level_observer().reset() + def reset(self, world): + self.currentAttempt = LevelStatsObserver(world) self.last_reset_time = time.time() -statisticsObserver = StatsObserver() +statsObserver = StatsObserver() def track(event_type): def decorator(func): @@ -51,7 +39,7 @@ def wrapper(*args, **kwargs): arguments = dict(bound.arguments) arguments.pop("self", None) - statisticsObserver.record(event_type, **arguments) + statsObserver.record(event_type, **arguments) return result return wrapper diff --git a/code/games/platformer_core.py b/code/games/platformer_core.py index 09f31e6..63b1490 100644 --- a/code/games/platformer_core.py +++ b/code/games/platformer_core.py @@ -10,7 +10,7 @@ import gymnasium from gymnasium import spaces import psutil -from .modules.Stats.StatsObserver import statisticsObserver, track +from .modules.Stats.StatsObserver import statsObserver, track from code.games.modules.System import EntityType # --- CORRECTED IMPORTS FOR NEW FOLDER STRUCTURE --- @@ -154,8 +154,7 @@ def __init__(self, render_mode: str = "none", **kwargs): assets_dir = os.path.join(core_dir, "assets") #init statistics Observer levels - statisticsObserver.init_level_observers(self.level_order) - statisticsObserver.set_current_level(self.world) + statsObserver.reset(self.world) # print(f"[DEBUG] Loading Assets from: {assets_dir}") # self.sprite_manager = SpriteManager(assets_dir, sprite_width=32, sprite_height=32, scale=1.5) @@ -252,8 +251,8 @@ def step(self, action: int): if terminated: info["episode_end"] = True return self._obs(), 0.0, bool(terminated), bool(truncated), info + def reset(self, seed=None, options=None) -> np.ndarray: - super().reset(seed=seed) if not self.reached_goal: @@ -261,13 +260,10 @@ def reset(self, seed=None, options=None) -> np.ndarray: self.score = 0 self.coins_total = 0 self.load_level() - - + return self._obs(), self._info() def load_level(self): - statisticsObserver.print_all() - statisticsObserver.reset() self.alive = True self.frame = 0 self.game_over = False @@ -337,10 +333,10 @@ def complete_level(self): print("all levels done") # As requested self.current_index_world = 0 self.world = self.level_order[self.current_index_world] - statisticsObserver.set_current_level(self.world) self.load_level() + statsObserver.write_to_csv() + statsObserver.reset(self.world) - @track("death") def _handle_death(self, cause = "cause"): self.lives -= 1 if self.lives > 0: @@ -349,6 +345,10 @@ def _handle_death(self, cause = "cause"): self.alive = False self.game_over = True + statsObserver.write_to_csv(cause) + statsObserver.reset(self.world) + + def _soft_reset(self): current_lives = self.lives self.load_level() From 750ffde683f07c609ad0114ee3cb2d29c8b5eb4e Mon Sep 17 00:00:00 2001 From: amr-abdalla Date: Thu, 19 Mar 2026 09:41:12 -0400 Subject: [PATCH 3/4] Convert data to csv --- .../games/modules/Stats/LevelStatsObserver.py | 19 +---- code/games/modules/Stats/StatsObserver.py | 76 ++++++++++++++++++- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/code/games/modules/Stats/LevelStatsObserver.py b/code/games/modules/Stats/LevelStatsObserver.py index f874e9a..7d7fdb0 100644 --- a/code/games/modules/Stats/LevelStatsObserver.py +++ b/code/games/modules/Stats/LevelStatsObserver.py @@ -1,6 +1,4 @@ from ...action_map import ACTION_NAMES -import csv -import os class LevelStatsObserver: def __init__(self, levelName): @@ -81,9 +79,7 @@ def get_average_vx(self): else: return self.sum_vx / self.count_vx - def write_to_csv(self, filename): - file_exists = os.path.isfile(filename) - + def to_dict(self): row = { "level": self.levelName, "jumps": self.jumps, @@ -94,18 +90,11 @@ def write_to_csv(self, filename): "cause_of_death": self.deathCause } - for action_id, count in self.actions.items(): - row[f"action_{ACTION_NAMES[action_id]}"] = count - - with open(filename, "a", newline="") as f: - writer = csv.DictWriter(f, fieldnames=row.keys()) + for action_id in ACTION_NAMES: + row[f"action_{ACTION_NAMES[action_id]}"] = self.actions.get(action_id, 0) - # Write header only if file doesn't exist yet - if not file_exists: - writer.writeheader() + return row - writer.writerow(row) - def reset(self): exclude = {"deaths"} # keep elapsed time for attr, value in self.__dict__.items(): diff --git a/code/games/modules/Stats/StatsObserver.py b/code/games/modules/Stats/StatsObserver.py index c1b5e74..014080d 100644 --- a/code/games/modules/Stats/StatsObserver.py +++ b/code/games/modules/Stats/StatsObserver.py @@ -2,26 +2,100 @@ from functools import wraps from.LevelStatsObserver import LevelStatsObserver import time +import os +import csv +import atexit class StatsObserver: def __init__(self): self.last_reset_time = time.time() + self.attempts = [] + atexit.register(self.at_exit) def record(self, event_type, **data): self.currentAttempt.record(event_type, **data) def get_elapsed_time(self): return time.time() - self.last_reset_time + + def get_level_clear_rate(self): + result = {} + + for attempt in self.attempts: + level = attempt["level"] + cause = attempt.get("cause_of_death", "Unknown") + + if cause == "Success": + continue + + if level not in result: + result[level] = {"total_deaths": 0} + + result[level]["total_deaths"] += 1 + if cause not in result[level]: + result[level][cause] = 0 + result[level][cause] += 1 + + return result + + def get_idle_ratio(self): + total_actions = sum(self.currentAttempt.actions.values()) + idle_count = self.currentAttempt.actions.get(0, 0) + return idle_count / total_actions if total_actions > 0 else 0 + + def get_actions_per_second(self): + total_actions = sum(self.currentAttempt.actions.values()) + idle_count = self.currentAttempt.actions.get(0, 0) + return (total_actions - idle_count) / self.get_elapsed_time() + + def write_level_clear_rate_csv(self, filename="level_clear_rate.csv"): + level_data = self.get_level_clear_rate() + + all_causes = set() + for stats in level_data.values(): + for key in stats: + if key != "total_deaths": + all_causes.add(key) + + fieldnames = ["level", "total_deaths"] + sorted(all_causes) + + with open(filename, "a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + + for level, stats in level_data.items(): + row = {"level": level, "total_deaths": stats["total_deaths"]} + for cause in all_causes: + row[cause] = stats.get(cause, 0) + writer.writerow(row) def write_to_csv(self, deathReason = "Success", filename="stats.csv"): self.currentAttempt.deathCause = deathReason self.currentAttempt.set_elapsed_time(self.get_elapsed_time()) - self.currentAttempt.write_to_csv(filename) + row = self.currentAttempt.to_dict() + + row["Idle_Ratio"] = round(self.get_idle_ratio(),2) + row["Actions_Per_Second"] = round(self.get_actions_per_second(),2) + + self.attempts.append(row) + + file_exists = os.path.isfile(filename) + + with open(filename, "a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=row.keys()) + + if not file_exists: + writer.writeheader() + + writer.writerow(row) def reset(self, world): self.currentAttempt = LevelStatsObserver(world) self.last_reset_time = time.time() + def at_exit(self): + self.write_level_clear_rate_csv() + statsObserver = StatsObserver() def track(event_type): From 31fe2b8470b849ef8bc9caa30eaa6236d58728a4 Mon Sep 17 00:00:00 2001 From: amr-abdalla Date: Thu, 19 Mar 2026 11:35:11 -0400 Subject: [PATCH 4/4] Added Stats Dashboard --- StatsDashboard.py | 135 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 StatsDashboard.py diff --git a/StatsDashboard.py b/StatsDashboard.py new file mode 100644 index 0000000..b0af889 --- /dev/null +++ b/StatsDashboard.py @@ -0,0 +1,135 @@ +import streamlit as st +import pandas as pd + +# titles and initializations +st.set_page_config(page_title="Game Stats Dashboard", layout="wide") + +st.title("Game Stats Dashboard") +st.caption("Interactive dashboard for level outcomes, death causes, and player behavior.") + +deaths_df = pd.read_csv("level_clear_rate.csv") +attempts_df = pd.read_csv("stats.csv") + +# Filters +st.sidebar.header("Filters") + +levels = sorted(attempts_df["level"].unique(), key=lambda x: tuple(map(int, x.split("-")))) +selected_levels = st.sidebar.multiselect("Select levels", levels, default=levels) + +filtered_df = attempts_df[ + attempts_df["level"].isin(selected_levels) +].copy() + + +# metrics visualization +total_attempts = len(filtered_df) +success_count = (filtered_df["cause_of_death"] == "Success").sum() +death_count = total_attempts - success_count +success_rate = (success_count / total_attempts * 100) if total_attempts else 0 +avg_time = filtered_df["elapsed_time"].mean() if total_attempts else 0 +avg_velocity = filtered_df["avg_horizontal_velocity"].mean() if total_attempts else 0 + +col1, col2, col3, col4, col5, col6 = st.columns(6) +col1.metric("Attempts", total_attempts) +col2.metric("Successes", success_count) +col3.metric("Deaths", death_count) +col4.metric("Success Rate", f"{success_rate:.1f}%") +col5.metric("Avg Time", f"{avg_time:.2f}s") +col6.metric("Avg Horizontal Velocity", f"{avg_velocity:.2f}") + +# Level table +st.subheader("Level Table") + +level_table = filtered_df.groupby("level").agg( + attempts=("level", "count"), + successes=("cause_of_death", lambda x: (x == "Success").sum()), + avg_jumps=("jumps", "mean"), + avg_coins=("coins_collected", "mean"), + avg_enemies_killed=("enemies_killed", "mean"), + avg_time=("elapsed_time", "mean"), + avg_velocity=("avg_horizontal_velocity", "mean"), + avg_idle_ratio=("Idle_Ratio", "mean"), + avg_actions_per_second=("Actions_Per_Second", "mean"), +).reset_index() + +level_table["success_rate"] = ( + level_table["successes"] / level_table["attempts"] * 100 +).round(2) + +st.dataframe(level_table, use_container_width=True) + +# Attempts and Death Distribution Charts +col1, col2 = st.columns(2) + +with col1: + st.subheader("Attempts by Level") + attempts_per_level = filtered_df["level"].value_counts().sort_index() + st.bar_chart(attempts_per_level) + +with col2: + st.subheader("Death Distribution") + death_counts = ( + filtered_df[filtered_df["cause_of_death"] != "Success"] + ["cause_of_death"] + .value_counts()) + st.bar_chart(death_counts) + +# Average Time and Velocity Charts +col1, col2 = st.columns(2) + +with col1: + st.subheader("Average Elapsed Time by Level") + avg_time_by_level = filtered_df.groupby("level")["elapsed_time"].mean().sort_index() + st.bar_chart(avg_time_by_level) + +with col2: + st.subheader("Average Horizontal Velocity by Level") + avg_velocity_by_level = filtered_df.groupby("level")["avg_horizontal_velocity"].mean().sort_index() + st.bar_chart(avg_velocity_by_level) + +# Death Summary +st.subheader("Death Summary from Aggregated CSV") +st.dataframe(deaths_df, use_container_width=True) + +death_breakdown = deaths_df.set_index("level")[["Pit", "enemy", "stall"]] +st.bar_chart(death_breakdown) + +# Action Usage Table +st.subheader("Action Analysis") + +action_cols = [col for col in filtered_df.columns if col.startswith("action_")] + +if not filtered_df.empty: + action_totals = filtered_df[action_cols].sum().sort_values(ascending=False) + + action_by_level = filtered_df.groupby("level")[action_cols].sum() + st.subheader("Action Usage by Level") + st.dataframe(action_by_level, use_container_width=True) + + st.bar_chart(action_totals) + +# Detailed attempts Table + +st.subheader("Detailed Attempts") +st.dataframe(filtered_df, use_container_width=True) + +# Quick Insights +st.subheader("Quick Insights") + +if total_attempts > 0: + most_common_outcome = filtered_df["cause_of_death"].value_counts().idxmax() + hardest_level = ( + filtered_df[filtered_df["cause_of_death"] != "Success"]["level"] + .value_counts() + .idxmax() + if not filtered_df[filtered_df["cause_of_death"] != "Success"].empty + else "N/A" + ) + + st.write(f"**Most common outcome:** {most_common_outcome}") + st.write(f"**Level with most failures:** {hardest_level}") + + if "Success" in filtered_df["cause_of_death"].values: + success_df = filtered_df[filtered_df["cause_of_death"] == "Success"] + st.write(f"**Average time on successful runs:** {success_df['elapsed_time'].mean():.2f}s") + st.write(f"**Average idle ratio on successful runs:** {success_df['Idle_Ratio'].mean():.2f}")