-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
472 lines (402 loc) · 17.8 KB
/
main.py
File metadata and controls
472 lines (402 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
"""
Main Application for Airship Zero
Brutally simple scene management with 320x320 rendering
"""
import pygame
import sys
import os
import argparse
import tomllib
from typing import Dict, Any, Optional
# Import scenes
from scene_main_menu import MainMenuScene
from scene_bridge import BridgeScene
from scene_engine_room import EngineRoomScene
from scene_navigation import NavigationScene
from scene_fuel import FuelScene
from scene_cargo import CargoScene
from scene_library import LibraryScene
from scene_observatory import ObservatoryScene
from scene_book import BookScene
from scene_update import SceneUpdate
from core_simulator import get_simulator
from sound import AirshipSoundEngine
# Constants
LOGICAL_SIZE = 320
MIN_WINDOW_SIZE = 640
DEFAULT_WINDOW_SIZE = 960
FULLSCREEN_RESOLUTION = (1920, 1080)
DEFAULT_FONT_SIZE = 13
# Global assets directory (set by main app)
_assets_dir = None
def get_assets_dir() -> str:
"""Get the assets directory path for use by any module"""
global _assets_dir
if _assets_dir is None:
# Fallback detection if not set by main app
if os.path.exists("assets"):
_assets_dir = "assets"
else:
script_dir = os.path.dirname(os.path.abspath(__file__))
_assets_dir = os.path.join(script_dir, "assets")
return _assets_dir
def set_assets_dir(path: str):
"""Set the assets directory path (called by main app)"""
global _assets_dir
_assets_dir = path
def get_version() -> str:
"""Get version from pyproject.toml"""
try:
# Look for pyproject.toml relative to this script's location
script_dir = os.path.dirname(os.path.abspath(__file__))
toml_path = os.path.join(script_dir, "pyproject.toml")
with open(toml_path, "rb") as f:
data = tomllib.load(f)
return data["project"]["version"]
except Exception:
return "unknown"
class AirshipApp:
def __init__(self, save_file_path: Optional[str] = None):
pygame.init()
# Store custom save file path for the simulator
self.save_file_path = save_file_path
# Determine asset directory location
self.assets_dir = self._find_assets_dir()
# Set global assets directory for other modules
set_assets_dir(self.assets_dir)
# Text rendering configuration
self.is_text_antialiased = True
# Fullscreen state
self.is_fullscreen = False
self.windowed_size = (DEFAULT_WINDOW_SIZE, DEFAULT_WINDOW_SIZE)
# Initialize window
self.window_size = self.windowed_size
self.screen = pygame.display.set_mode(self.window_size, pygame.RESIZABLE)
pygame.display.set_caption("Airship Zero")
# Create logical rendering surface
self.logical_surface = pygame.Surface((LOGICAL_SIZE, LOGICAL_SIZE))
# Load font
self.font = self._load_font()
# Get the centralized simulator with custom save path if provided
self.simulator = get_simulator(self.save_file_path)
# Scene management
self.current_scene = None
self.scene_name = "scene_main_menu"
self.scenes = {}
self.running = True
# Initialize scenes
self._init_scenes()
# Initialize sound engine
self.sound_engine = AirshipSoundEngine(self.simulator)
print("🔊 Sound engine initialized")
# Check for updates if enabled
self._check_for_updates_if_needed()
# Game clock
self.clock = pygame.time.Clock()
def _find_assets_dir(self) -> str:
"""Find the assets directory relative to the package location"""
# Try relative path first (development mode)
if os.path.exists("assets"):
return "assets"
# Try relative to this script (package mode)
script_dir = os.path.dirname(os.path.abspath(__file__))
assets_path = os.path.join(script_dir, "assets")
if os.path.exists(assets_path):
return assets_path
# Last resort: try relative to the main module
try:
import __main__
if hasattr(__main__, '__file__'):
main_dir = os.path.dirname(os.path.abspath(__main__.__file__))
assets_path = os.path.join(main_dir, "assets")
if os.path.exists(assets_path):
return assets_path
except:
pass
# If all else fails, return relative path and hope for the best
return "assets"
def _load_font(self):
"""Load the font or fallback"""
try:
# Use the dynamically found assets directory
font_path = os.path.join(self.assets_dir, "fonts", "Roboto_Condensed", "RobotoCondensed-VariableFont_wght.ttf")
if os.path.exists(font_path):
print(f"✅ Loading font from: {font_path}")
return pygame.font.Font(font_path, DEFAULT_FONT_SIZE)
# Fallback - should not happen if fonts are properly installed
print(f"⚠️ Font not found at {font_path}, using fallback")
print(f" Assets directory: {self.assets_dir}")
return pygame.font.Font(None, DEFAULT_FONT_SIZE)
except Exception as e:
print(f"❌ Font loading error: {e}")
# Ultimate fallback
return pygame.font.Font(None, DEFAULT_FONT_SIZE)
def _init_scenes(self):
"""Initialize all scenes"""
self.scenes["scene_main_menu"] = MainMenuScene()
self.scenes["scene_bridge"] = BridgeScene(self.simulator)
self.scenes["scene_engine_room"] = EngineRoomScene(self.simulator)
self.scenes["scene_navigation"] = NavigationScene(self.simulator)
self.scenes["scene_fuel"] = FuelScene(self.simulator)
self.scenes["scene_cargo"] = CargoScene(self.simulator)
self.scenes["scene_library"] = LibraryScene(self.simulator)
self.scenes["scene_observatory"] = ObservatoryScene(self.simulator)
self.scenes["scene_update"] = SceneUpdate(self.font)
# Set up cross-references
self.scenes["scene_update"].set_main_menu_scene(self.scenes["scene_main_menu"])
# Set fonts for all scenes
for scene in self.scenes.values():
scene.set_font(self.font, self.is_text_antialiased)
# Set current scene
self.current_scene = self.scenes[self.scene_name]
# Check for existing saved game and enable resume button
if self.simulator.has_saved_game():
self.scenes["scene_main_menu"].set_game_exists(True)
def _check_for_updates_if_needed(self):
"""Check for updates if settings allow and enough time has passed"""
try:
if self.simulator.should_check_for_updates():
# Delegate to the update scene to perform the check
update_scene = self.scenes.get("scene_update")
if update_scene:
# Trigger automatic check (CDN-friendly, no force_fresh)
update_scene._check_latest_version(force_fresh=False)
except Exception as e:
print(f"Error during automatic update check: {e}")
def _transition_to_scene(self, scene_info):
"""Transition to a new scene. Accepts either a string or a dict for book scenes."""
# Handle dict-based transitions for book and edit scenes
if isinstance(scene_info, dict):
scene = scene_info.get("scene")
book = scene_info.get("book")
if scene == "scene_book" and book:
book_scene = BookScene(self.simulator, book)
book_scene.set_font(self.font, self.is_text_antialiased)
self.current_scene = book_scene
self.scene_name = scene
return
elif scene == "scene_edit" and book:
from scene_edit import EditBookScene
edit_scene = EditBookScene(self.simulator, book)
edit_scene.set_font(self.font, self.is_text_antialiased)
self.current_scene = edit_scene
self.scene_name = scene
return
elif scene in self.scenes:
self.scene_name = scene
self.current_scene = self.scenes[scene]
return
else:
# Fallback: treat as string
scene_info = scene
scene_name = scene_info
if scene_name == "quit":
self.running = False
return
elif scene_name == "new_game":
# Start a new game
self.simulator.start_new_game()
print("🔊 Simulation started (new game)")
scene_name = "scene_bridge"
elif scene_name == "resume_game":
# Load saved game and resume simulation
if self.simulator.load_game():
self.simulator.resume_simulation()
print("🔊 Simulation resumed (loaded game)")
scene_name = "scene_bridge"
else:
# Failed to load, stay on main menu
return
elif scene_name == "scene_main_menu":
# Save game and pause simulation when returning to main menu
if self.simulator.running:
self.simulator.save_game()
self.simulator.pause_simulation()
print("🔇 Simulation paused (main menu)")
if scene_name in self.scenes:
self.scene_name = scene_name
self.current_scene = self.scenes[scene_name]
# Define actual game scenes that should resume simulation
game_scenes = {
"scene_bridge", "scene_engine_room", "scene_navigation",
"scene_fuel", "scene_cargo", "scene_library", "scene_observatory",
"scene_communications", "scene_camera", "scene_crew", "scene_missions"
}
# Resume simulation only when transitioning to actual game scenes
is_game_scene = scene_name in game_scenes
if is_game_scene and self.simulator.running:
# Check if simulation is paused and resume it
game_state = self.simulator.get_state()
if game_state.get("gameInfo", {}).get("paused", False):
self.simulator.resume_simulation()
print("🔊 Simulation resumed (entering game scene)")
# Enable resume game button if we've started a game
if is_game_scene:
self.scenes["scene_main_menu"].set_game_exists(True)
def _screen_to_logical(self, screen_pos) -> tuple:
"""Convert screen coordinates to logical coordinates"""
screen_w, screen_h = self.window_size
scale = min(screen_w / LOGICAL_SIZE, screen_h / LOGICAL_SIZE)
# Calculate the actual rendered size and position
rendered_size = int(LOGICAL_SIZE * scale)
offset_x = (screen_w - rendered_size) // 2
offset_y = (screen_h - rendered_size) // 2
# Convert screen coordinates to logical coordinates
screen_x, screen_y = screen_pos
logical_x = (screen_x - offset_x) / scale
logical_y = (screen_y - offset_y) / scale
# Clamp to logical bounds
logical_x = max(0, min(LOGICAL_SIZE - 1, logical_x))
logical_y = max(0, min(LOGICAL_SIZE - 1, logical_y))
return (int(logical_x), int(logical_y))
def _toggle_fullscreen(self):
"""Toggle between fullscreen and windowed mode"""
if self.is_fullscreen:
# Switch to windowed mode
self.is_fullscreen = False
self.window_size = self.windowed_size
self.screen = pygame.display.set_mode(self.window_size, pygame.RESIZABLE)
print(f"Switched to windowed mode: {self.window_size[0]}x{self.window_size[1]}")
else:
# Store current windowed size
if not self.is_fullscreen:
self.windowed_size = self.window_size
# Switch to fullscreen mode
self.is_fullscreen = True
self.window_size = FULLSCREEN_RESOLUTION
self.screen = pygame.display.set_mode(self.window_size, pygame.FULLSCREEN)
print(f"Switched to fullscreen mode: {self.window_size[0]}x{self.window_size[1]}")
def handle_event(self, event):
"""Handle pygame events"""
if event.type == pygame.QUIT:
self.running = False
return
elif event.type == pygame.KEYDOWN:
# Handle global key events
if event.key == pygame.K_F11:
self._toggle_fullscreen()
return
elif event.type == pygame.VIDEORESIZE:
# Handle window resize (only in windowed mode)
if not self.is_fullscreen:
new_width = max(MIN_WINDOW_SIZE, event.w)
new_height = max(MIN_WINDOW_SIZE, event.h)
self.window_size = (new_width, new_height)
self.windowed_size = self.window_size
# Every AI seems to do this but it's not correct to do so,
# because it destroys and re-creates the window.
# self.screen = pygame.display.set_mode(self.window_size, pygame.RESIZABLE)
return
# Convert mouse events to logical coordinates
if hasattr(event, 'pos'):
event.pos = self._screen_to_logical(event.pos)
# Pass event to current scene
if self.current_scene:
result = self.current_scene.handle_event(event)
if result:
self._transition_to_scene(result)
def update(self, dt: float):
"""Update the game state"""
# Update the centralized simulator
self.simulator.update(dt)
# Update sound engine (always runs, handles pause states internally)
self.sound_engine.update_audio()
# Update current scene
if self.current_scene and hasattr(self.current_scene, 'update'):
self.current_scene.update(dt)
def render(self):
"""Render the current frame"""
# Clear the logical surface
self.logical_surface.fill((0, 0, 0))
# Render current scene to logical surface
if self.current_scene:
self.current_scene.render(self.logical_surface)
# Scale and center on screen
screen_w, screen_h = self.window_size
scale = min(screen_w / LOGICAL_SIZE, screen_h / LOGICAL_SIZE)
# Calculate rendered size and position
rendered_size = int(LOGICAL_SIZE * scale)
offset_x = (screen_w - rendered_size) // 2
offset_y = (screen_h - rendered_size) // 2
# Clear screen
self.screen.fill((0, 0, 0))
# Scale logical surface to screen
if scale != 1.0:
scaled_surface = pygame.transform.scale(
self.logical_surface,
(rendered_size, rendered_size)
)
else:
scaled_surface = self.logical_surface
# Blit to screen
self.screen.blit(scaled_surface, (offset_x, offset_y))
# Update display
pygame.display.flip()
def run(self):
"""Main game loop"""
version = get_version()
print(f"Starting Airship Zero v{version}...")
print(f"Logical resolution: {LOGICAL_SIZE}x{LOGICAL_SIZE}")
print(f"Window size: {self.window_size[0]}x{self.window_size[1]}")
while self.running:
# Calculate delta time
dt = self.clock.tick(60) / 1000.0 # Convert to seconds
# Handle events
for event in pygame.event.get():
self.handle_event(event)
# Update game state
self.update(dt)
# Render frame
self.render()
# Cleanup
# Auto-save game state on exit if game is running
if self.simulator.running:
print("Auto-saving game state...")
self.simulator.save_game()
# Clean up image cache
try:
from scene_book import cleanup_image_cache
cleanup_image_cache()
except ImportError:
pass # scene_book might not be imported
pygame.quit()
print("Airship Zero shutdown complete.")
def main():
"""Application entry point"""
# Parse command line arguments
parser = argparse.ArgumentParser(
description="Airship Zero - Retro airship simulation game",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Use default save location
%(prog)s --save-file custom_game.json # Use custom file in current directory
%(prog)s --save-file /path/to/game.json # Use absolute path
"""
)
parser.add_argument(
'--save-file', '--save', '-s',
metavar='PATH',
help='Custom path for the save file (default: OS-appropriate app data directory)'
)
args = parser.parse_args()
try:
app = AirshipApp(save_file_path=args.save_file)
app.run()
except KeyboardInterrupt:
print("\nShutdown requested by user")
except Exception as e:
print(f"Fatal error: {e}")
import traceback
traceback.print_exc()
finally:
# Clean up image cache
try:
from scene_book import cleanup_image_cache
cleanup_image_cache()
except ImportError:
pass # scene_book might not be imported
pygame.quit()
sys.exit()
if __name__ == "__main__":
main()