-
-
Save tmattio/d31fa591a889cdd0db3c1849183f61ca to your computer and use it in GitHub Desktop.
Demo of a Space Invaders game in OCaml.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(executable | |
(public_name space-invaders) | |
(name space_invaders) | |
(libraries raylib)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(lang dune 3.18) | |
(name space_invaders) | |
(package | |
(name space_invaders) | |
(depends ocaml raylib)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(** Demo of a Space Invaders game in OCaml. | |
This is a simple version of the classic Space Invaders game. It demonstrates | |
the basic game loop, state management, and rendering in OCaml using the | |
Raylib library. | |
The game features a player ship that can move left and right, and a grid of | |
enemies that move left and right and descend periodically. The player can | |
"shoot" by pressing the Space key, which instantly destroys all enemies. The | |
player loses a life if they press Enter, and the game ends when the player | |
runs out of lives. | |
The game is rendered using simple shapes and text, and the player and enemy | |
designs are custom and not exact replicas of the original game. *) | |
(** Configuration parameters for the game. These values can be tweaked to change | |
window size, ship speed, etc. *) | |
module Conf = struct | |
(** Width of the game window. *) | |
let screen_width = 800 | |
(** Height of the game window. *) | |
let screen_height = 600 | |
(** Width of the player's ship. *) | |
let ship_width = 50 | |
(** Height of the player's ship. *) | |
let ship_height = 20 | |
(** Speed at which the player's ship moves left or right. *) | |
let ship_speed = 5 | |
(** Frames per second. The original Space Invaders ran at 60 FPS. *) | |
let fps = 60 | |
(** Number of lives the player starts with. In the original game, you usually | |
start with 3 lives. *) | |
let initial_lives = 3 | |
(** How often (in frames) enemies descend vertically. The classic game moves | |
them down periodically after shifting left-right. *) | |
let enemy_descent_interval = 120 | |
end | |
module Player = struct | |
type t = { | |
x : int; (** Horizontal position *) | |
y : int; (** Vertical position *) | |
lives : int; | |
} | |
(** Represents the player's ship. *) | |
(** Create a new player centered at the bottom of the screen. *) | |
let create () = | |
{ | |
x = (Conf.screen_width - Conf.ship_width) / 2; | |
y = Conf.screen_height - Conf.ship_height - 20; | |
lives = Conf.initial_lives; | |
} | |
(** Moves the player left or right depending on key input. In the original | |
game, the ship can only move horizontally. *) | |
let move player = | |
let new_x = | |
if | |
Raylib.is_key_down Raylib.Key.Right | |
&& player.x < Conf.screen_width - Conf.ship_width | |
then player.x + Conf.ship_speed | |
else if Raylib.is_key_down Raylib.Key.Left && player.x > 0 then | |
player.x - Conf.ship_speed | |
else player.x | |
in | |
{ player with x = new_x } | |
(** Reduces the player's lives by one. In classic Space Invaders, a life is | |
lost when the ship is hit by an enemy projectile. *) | |
let take_damage player = | |
if player.lives > 1 then { player with lives = player.lives - 1 } | |
else { player with lives = 0 } | |
(** Draw the player's ship in a slightly stylized way, adding triangles to | |
mimic a small turret shape. The design is custom, not an exact replica | |
from the original. *) | |
let draw player = | |
let x, y = (player.x, player.y) in | |
let ship_color = Raylib.Color.white in | |
Raylib.draw_rectangle x y Conf.ship_width Conf.ship_height ship_color; | |
(* Simple triangle "top" of the ship. *) | |
Raylib.draw_triangle | |
(Raylib.Vector2.create | |
(float_of_int (x + (Conf.ship_width / 2))) | |
(float_of_int (y - 10))) | |
(Raylib.Vector2.create (float_of_int x) (float_of_int y)) | |
(Raylib.Vector2.create | |
(float_of_int (x + Conf.ship_width)) | |
(float_of_int y)) | |
ship_color; | |
(* Stub "wings" extending out to the left and right. *) | |
Raylib.draw_rectangle (x - 10) (y + 5) 10 10 ship_color; | |
Raylib.draw_rectangle (x + Conf.ship_width) (y + 5) 10 10 ship_color | |
end | |
module Enemy = struct | |
(** Classic Space Invaders features three distinct enemy designs with | |
different point values. We track them here, though point calculation is | |
simplified in this demo. *) | |
type enemy_type = Crab | Squid | Octopus | |
type t = { x : int; y : int; kind : enemy_type } | |
(** Each enemy is destroyed in one hit in the original game. We store just | |
position and type. *) | |
let create x y kind = { x; y; kind } | |
(** Return a color based on enemy type. In the original, each alien type | |
usually had a unique shape and color. *) | |
let color enemy = | |
match enemy.kind with | |
| Squid -> Raylib.Color.red | |
| Crab -> Raylib.Color.yellow | |
| Octopus -> Raylib.Color.green | |
(** Draw each enemy in a simple, custom shape. This is not an exact | |
pixel-perfect replica of the original. *) | |
let draw enemy = | |
let x, y = (enemy.x, enemy.y) in | |
let enemy_color = color enemy in | |
match enemy.kind with | |
| Crab -> | |
Raylib.draw_rectangle x y 30 15 enemy_color; | |
Raylib.draw_rectangle (x - 5) (y + 15) 10 5 enemy_color; | |
Raylib.draw_rectangle (x + 25) (y + 15) 10 5 enemy_color | |
| Squid -> | |
Raylib.draw_triangle | |
(Raylib.Vector2.create (float_of_int (x + 15)) (float_of_int y)) | |
(Raylib.Vector2.create (float_of_int x) (float_of_int (y + 20))) | |
(Raylib.Vector2.create | |
(float_of_int (x + 30)) | |
(float_of_int (y + 20))) | |
enemy_color; | |
Raylib.draw_rectangle (x + 5) (y + 20) 5 5 enemy_color; | |
Raylib.draw_rectangle (x + 20) (y + 20) 5 5 enemy_color | |
| Octopus -> | |
Raylib.draw_rectangle x y 30 20 enemy_color; | |
Raylib.draw_rectangle (x + 5) (y + 20) 5 5 enemy_color; | |
Raylib.draw_rectangle (x + 20) (y + 20) 5 5 enemy_color | |
(** Enemies in the original game are always killed in one shot, so returning | |
[None] means the enemy is removed from the list. *) | |
let take_damage _enemy = None | |
(** Manage a grid (or swarm) of enemies, similar to the original row/column | |
layout. *) | |
module Grid = struct | |
type enemy = t | |
type t = { | |
enemies : enemy list; | |
base_speed : int; (** The initial horizontal speed of the wave. *) | |
initial_count : int; | |
(** How many enemies were originally in this wave. *) | |
direction : int; (** 1 = moving right, -1 = moving left. *) | |
frames : int; (** Frame counter for descent timing. *) | |
} | |
(** Create a grid of enemies for a given wave. Each wave can be faster | |
(increasing difficulty). *) | |
let create ~cols ~rows ~spacing ~wave = | |
let wave_speed_increment = 1 in | |
let base_speed = 2 + ((wave - 1) * wave_speed_increment) in | |
let total = cols * rows in | |
(* Position the enemies in a grid, offset from the top-left. *) | |
let enemies = | |
List.init total (fun i -> | |
let x = (i mod cols * spacing) + 100 in | |
let y = (i / cols * spacing) + 50 in | |
(* The original game used distinct sprites for each row: | |
top rows were often Squid, lower rows were Crab or Octopus. | |
Here, we cycle through them. *) | |
let row_index = i / cols in | |
let kind = | |
match row_index mod 3 with 0 -> Squid | 1 -> Crab | _ -> Octopus | |
in | |
create x y kind) | |
in | |
{ enemies; base_speed; initial_count = total; direction = 1; frames = 0 } | |
(** Draw every enemy in the grid. *) | |
let draw grid = List.iter draw grid.enemies | |
(** Move the entire swarm and handle their direction switching + occasional | |
descent. This replicates the signature "marching" motion from the | |
original game. *) | |
let move grid = | |
let current_count = List.length grid.enemies in | |
let destroyed = grid.initial_count - current_count in | |
(* Classic Speedup Mechanic: | |
As you destroy more enemies, their movement speed increases. | |
The factor is adjustable via [speedup_factor]. *) | |
let speedup_factor = 6 in | |
let dynamic_speed = grid.base_speed + (destroyed / speedup_factor) in | |
(* Determine if the swarm needs to reverse direction | |
(when it hits the right or left edge). *) | |
let left_most = | |
List.fold_left (fun acc e -> min acc e.x) max_int grid.enemies | |
in | |
let right_most = | |
List.fold_left (fun acc e -> max acc e.x) min_int grid.enemies | |
in | |
let new_direction = | |
if right_most >= Conf.screen_width - 30 then -1 | |
else if left_most <= 0 then 1 | |
else grid.direction | |
in | |
(* Move horizontally. *) | |
let new_enemies = | |
List.map | |
(fun e -> { e with x = e.x + (new_direction * dynamic_speed) }) | |
grid.enemies | |
in | |
(* Descend every [enemy_descent_interval] frames. *) | |
let new_frames = grid.frames + 1 in | |
let descend = new_frames mod Conf.enemy_descent_interval = 0 in | |
let enemies = | |
if descend then List.map (fun e -> { e with y = e.y + 10 }) new_enemies | |
else new_enemies | |
in | |
{ grid with enemies; direction = new_direction; frames = new_frames } | |
(** Damage all enemies (simulate hitting them), removing them from the list | |
if destroyed. TODO: Remove this when we implement bullet mechanics. *) | |
let damage_all grid = | |
let new_enemies = List.filter_map take_damage grid.enemies in | |
{ grid with enemies = new_enemies } | |
(** Check if all enemies are gone. *) | |
let all_dead grid = grid.enemies = [] | |
end | |
end | |
module Game = struct | |
(** Possible states of the game, following a simple state-machine approach. *) | |
type game_state = Menu | Playing | Game_over | |
type state = { | |
player : Player.t; | |
enemies : Enemy.Grid.t; | |
score : int; | |
wave : int; | |
game_state : game_state; | |
} | |
(** Initialize a fresh game state. *) | |
let init_state () = | |
let player = Player.create () in | |
let enemies = Enemy.Grid.create ~cols:5 ~rows:3 ~spacing:60 ~wave:1 in | |
{ player; enemies; score = 0; wave = 1; game_state = Menu } | |
(** Update logic when in the Menu state. *) | |
let update_menu state = | |
if Raylib.is_key_pressed Raylib.Key.Enter then | |
{ state with game_state = Playing } | |
else state | |
(** Update logic when in the Playing state. *) | |
let update_playing state = | |
let new_player = Player.move state.player in | |
let new_enemies = Enemy.Grid.move state.enemies in | |
(* TODO: For demonstration, pressing Space instantly damages all enemies. | |
We leave the implementation of the bullet mechanics as next step. *) | |
let new_enemies = | |
if Raylib.is_key_pressed Raylib.Key.Space then | |
Enemy.Grid.damage_all new_enemies | |
else new_enemies | |
in | |
(* For demonstration, pressing Enter => player takes damage. | |
Typically you'd be hit by enemy shots. *) | |
let new_player = | |
if Raylib.is_key_pressed Raylib.Key.Enter then | |
Player.take_damage new_player | |
else new_player | |
in | |
let game_state = if new_player.lives = 0 then Game_over else Playing in | |
(* If all enemies are destroyed, move to the next wave. | |
We grant 100 points each time you clear a wave. *) | |
if Enemy.Grid.all_dead new_enemies then | |
let next_wave = state.wave + 1 in | |
{ | |
state with | |
enemies = Enemy.Grid.create ~cols:5 ~rows:3 ~spacing:60 ~wave:next_wave; | |
wave = next_wave; | |
score = state.score + 100; | |
} | |
else { state with player = new_player; enemies = new_enemies; game_state } | |
(** Update logic when in the Game_over state. *) | |
let update_game_over state = | |
if Raylib.is_key_pressed Raylib.Key.Enter then init_state () else state | |
(** Main update function that dispatches based on the current game_state. *) | |
let update state = | |
match state.game_state with | |
| Menu -> update_menu state | |
| Playing -> update_playing state | |
| Game_over -> update_game_over state | |
let draw_menu () = | |
let title = "OCaml Game Invader" in | |
let title_size = 30 in | |
let title_width = Raylib.measure_text title title_size in | |
let subtitle = "Press Enter to Start" in | |
let subtitle_size = 20 in | |
let subtitle_width = Raylib.measure_text subtitle subtitle_size in | |
let center_x w = (Conf.screen_width - w) / 2 in | |
Raylib.draw_text title (center_x title_width) | |
((Conf.screen_height / 2) - 60) | |
title_size Raylib.Color.white; | |
Raylib.draw_text subtitle (center_x subtitle_width) (Conf.screen_height / 2) | |
subtitle_size Raylib.Color.gray | |
let draw_playing state = | |
Player.draw state.player; | |
Enemy.Grid.draw state.enemies; | |
Raylib.draw_text | |
(Printf.sprintf "Score: %d" state.score) | |
10 10 20 Raylib.Color.green; | |
Raylib.draw_text | |
(Printf.sprintf "Lives: %d" state.player.lives) | |
10 40 20 Raylib.Color.red; | |
Raylib.draw_text | |
(Printf.sprintf "Wave: %d" state.wave) | |
10 70 20 Raylib.Color.blue | |
let draw_game_over () = | |
let game_over_text = "GAME OVER" in | |
let game_over_size = 40 in | |
let game_over_width = Raylib.measure_text game_over_text game_over_size in | |
let prompt = "Press Enter to Restart" in | |
let prompt_size = 20 in | |
let prompt_width = Raylib.measure_text prompt prompt_size in | |
let center_x w = (Conf.screen_width - w) / 2 in | |
Raylib.draw_text game_over_text (center_x game_over_width) | |
((Conf.screen_height / 2) - 60) | |
game_over_size Raylib.Color.red; | |
Raylib.draw_text prompt (center_x prompt_width) (Conf.screen_height / 2) | |
prompt_size Raylib.Color.gray | |
(** Draw the entire game state to the screen. *) | |
let draw state = | |
Raylib.begin_drawing (); | |
Raylib.clear_background Raylib.Color.black; | |
let () = | |
match state.game_state with | |
| Menu -> draw_menu () | |
| Playing -> draw_playing state | |
| Game_over -> draw_game_over () | |
in | |
Raylib.end_drawing () | |
(** Game loop: initializes, runs until window is closed, then cleans up. *) | |
let run () = | |
Raylib.init_window Conf.screen_width Conf.screen_height | |
"OCaml Space Invaders"; | |
Raylib.set_target_fps Conf.fps; | |
let rec loop state = | |
if not (Raylib.window_should_close ()) then ( | |
let new_state = update state in | |
draw new_state; | |
loop new_state) | |
in | |
loop (init_state ()); | |
Raylib.close_window () | |
end | |
let () = Game.run () |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment