initial commit

This commit is contained in:
2026-03-25 07:13:37 +00:00
commit 8d643a3842
28 changed files with 790 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"editor.tabSize": 2,
"editor.insertSpaces": true
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+113
View File
@@ -0,0 +1,113 @@
class Character
SPEED = 2.5
SIZE = [25, 30]
def initialize
@world_x = 60.0
@world_y = 60.0
# Load the full sheet as tiles
sheet = Gosu::Image.load_tiles("assets/images/player.png", 20, 40, retro: true)
rows = [
:idle_front,
:idle_back,
:idle_right,
:idle_left,
:walk_front,
:walk_back,
:walk_right,
:walk_left
]
@animations = {}
rows.each_with_index do |name, row|
start_index = row * 6
@animations[name] = sheet[start_index, 6] # slice 6 frames
end
@current_animation = :idle_front
@facing = :front
end
def rect
w, h = SIZE
[@world_x - w / 2, @world_y - h / 2, w, h]
end
def collides?(rect)
return true if intersects?(self.rect, rect)
false
end
def update
dx = 0.0
dy = 0.0
dx -= 1 if Gosu.button_down?(Gosu::KB_A)
dx += 1 if Gosu.button_down?(Gosu::KB_D)
dy -= 1 if Gosu.button_down?(Gosu::KB_W)
dy += 1 if Gosu.button_down?(Gosu::KB_S)
moving = dx != 0 || dy != 0
if moving
if dy < 0
@facing = :back
elsif dy > 0
@facing = :front
elsif dx < 0
@facing = :left
elsif dx > 0
@facing = :right
end
end
@current_animation = moving ? :"walk_#{@facing}" : :"idle_#{@facing}"
# Normalize vector if moving diagonally
if dx != 0 && dy != 0
length = Math.sqrt(dx**2 + dy**2)
dx /= length
dy /= length
end
# Apply speed
dx *= SPEED
dy *= SPEED
# Horizontal
@world_x += dx
if $bus.get(:collides?, rect)&.include?(:wall)
@world_x -= dx
end
# Vertical
@world_y += dy
if $bus.get(:collides?, rect)&.include?(:wall)
@world_y -= dy
end
# Camera follow
cam_x = @world_x - SCREEN_SIZE[0] / 2.0
cam_y = @world_y - SCREEN_SIZE[1] / 2.0
$bus.emit(:player_move, [cam_x, cam_y])
end
def draw
cx = SCREEN_SIZE[0] / 2.0 - SIZE[0] / 2.0
cy = SCREEN_SIZE[1] / 2.0 - SIZE[1] / 2.0
sprite_x = cx + SIZE[0] / 2.0 - 20 * 1.8 / 2
sprite_y = cy + SIZE[1] / 2.0 - 50 * 1.8 / 2
elapsed = Gosu.milliseconds / 1000.0
frame_index = ((elapsed * 7).floor) % 6
@animations[@current_animation][frame_index].draw(sprite_x, sprite_y, sprite_y, 1.8, 1.8)
return unless DEBUG
Gosu.draw_rect(cx, cy, SIZE[0], SIZE[1], Gosu::Color.new(0x8000FF00), cy)
end
end
View File
View File
+106
View File
@@ -0,0 +1,106 @@
require_relative "../utils/maze"
class Maze
def initialize
@maze = MazeData.new(80, 80)
@spritesheet = Gosu::Image.load_tiles("assets/images/walls.png", 20, 40, retro: true)
puts @spritesheet.size
end
def collides?(rect)
x1 = ((rect[0] - 10) / 60).floor
y1 = ((rect[1] - 10) / 60).floor
x2 = ((rect[0] + rect[2] + 10) / 60).floor
y2 = ((rect[1] + rect[3] + 10) / 60).floor
(x1..x2).each do |x|
(y1..y2).each do |y|
shapes = shapes_for(@maze.wall_type(x, y), x * 60, y * 60)
shapes.each do |shape|
return true if intersects?(shape, rect)
end
end
end
false
end
def shapes_for(mask, tx, ty)
return [] if mask == 0
half = 30
band = 10
shapes = []
case mask & (W | E)
when W | E
shapes << [tx, ty + 30 - band / 2, 60, band]
when E
if (mask & (N | S)) == 0
shapes << [tx, ty + 30 - band / 2, 60, band]
else
shapes << [tx + half, ty + 30 - band / 2, half, band]
end
when W
if (mask & (N | S)) == 0
shapes << [tx, ty + 30 - band / 2, 60, band]
else
shapes << [tx, ty + 30 - band / 2, half, band]
end
end
band = 20
case mask & (N | S)
when (N | S)
shapes << [tx + 30 - band / 2, ty, band, 60]
when S
shapes << [tx + 30 - band / 2, ty + half, band, half]
when N
shapes << [tx + 30 - band / 2, ty, band, half]
end
shapes
end
def draw(camera)
cam_x, cam_y = camera
tile_size = 60
scale = 3
screen_width, screen_height = SCREEN_SIZE
# How many tiles fit on screen (+1 for partially visible tiles)
tiles_x = (screen_width / tile_size) + 2
tiles_y = (screen_height / tile_size) + 2
# Which tile to start drawing (top-left corner of camera)
start_x = (cam_x / tile_size).floor - 1
start_y = (cam_y / tile_size).floor - 1
(0...tiles_y).each do |j|
(0...tiles_x).each do |i|
gx = start_x + i
gy = start_y + j
next if gx < 0 || gy < 0 || gx >= @maze.width || gy >= @maze.height
index = @maze.wall_type(gx, gy)
next if index == 0
# Pixel position on screen
screen_x = gx * tile_size - cam_x
screen_y = gy * tile_size - cam_y
@spritesheet[index - 1].draw(screen_x, screen_y, screen_y, scale, scale)
next unless DEBUG
shapes = shapes_for(index, gx * tile_size, gy * tile_size)
shapes.each do |shape|
Gosu.draw_rect(shape[0] - cam_x, shape[1] - cam_y, shape[2], shape[3], 0x44ff0000, shape[1] - cam_y)
end
end
end
end
end
+123
View File
@@ -0,0 +1,123 @@
require_relative 'state'
require_relative 'maze'
require_relative 'character'
require 'perlin'
class Game < Scene
def initialize
super
@font = Gosu::Font.new(24)
@noise = Perlin::Generator.new(rand(1000), 1.0, 1)
@floor_image = Gosu::Image.new("assets/images/floor.png", retro: true)
@maze = Maze.new
@character = Character.new
@camera = [0, 0]
$bus.on(:player_move) do |pos|
@camera = pos
end
$bus.on_retrievable(:collides?) do |rect|
collisions = []
collisions << :wall if @maze.collides?(rect)
collisions << :character if @character.collides?(rect)
next collisions
end
end
def draw
draw_floor
@maze.draw(@camera)
@character.draw
draw_fog(@camera)
draw_debug! if DEBUG
end
def draw_debug!
now = Gosu.milliseconds
@last_draw_time ||= now
@draw_count ||= 0
@fps ||= 0
@draw_count += 1
if now - @last_draw_time >= 1000
@fps = @draw_count
@draw_count = 0
@last_draw_time = now
end
@font.draw_text("FPS: #{@fps}", 5, 5, Float::INFINITY, 1, 1, Gosu::Color::YELLOW)
end
def draw_fog(camera)
cam_x, cam_y = camera
cell = 10
i_radius = 80.0
o_radius = 360.0
# Player screen position
px = SCREEN_SIZE[0] / 2.0
py = SCREEN_SIZE[1] / 2.0
tiles_x = (SCREEN_SIZE[0] / cell) + 2
tiles_y = (SCREEN_SIZE[1] / cell) + 2
tiles_y.times do |j|
tiles_x.times do |i|
sx = i * cell
sy = j * cell
dist = Math.sqrt((sx - px)**2 + (sy - py)**2)
if dist < i_radius
next # fully clear
elsif dist > o_radius
alpha = 255
else
t = (dist - i_radius) / (o_radius - i_radius) # 0..1
world_x = (sx + cam_x) * 0.005
world_y = (sy + cam_y) * 0.005
# Sample noise at world position so it scrolls with camera
noise = (@noise[world_x + Gosu.milliseconds / 5000.0, world_y + Gosu.milliseconds / 6000.0] + 1.0) / 2.0
alpha = (t * (1.0 + noise) * 255).to_i.clamp(0, 255)
end
Gosu.draw_rect(sx, sy, cell, cell, Gosu::Color.new(alpha, 0, 0, 0), 10000 + sy)
end
end
end
def draw_floor
cam_x, cam_y = @camera
tile_size = 60
offset_x = cam_x % tile_size
offset_y = cam_y % tile_size
tiles_x = (SCREEN_SIZE[0] / tile_size) + 2
tiles_y = (SCREEN_SIZE[1] / tile_size) + 2
(0...tiles_y).each do |j|
(0...tiles_x).each do |i|
sx = i * tile_size - offset_x
sy = j * tile_size - offset_y
@floor_image.draw(sx, sy, -1000, 3, 3)
end
end
end
def update
@character.update
end
def button_down(id, _pos)
return $bus.emit(:change_scene, Menu.new) if id == Gosu::KB_ESCAPE
end
end
+7
View File
@@ -0,0 +1,7 @@
class State
# This class holds state information
# for example the player inventory / items
# also their health, stealth, and memory bars
# it can handle mouse input for inventory management and item usage
# it can also handle keyboard input for quick item usage
end
+92
View File
@@ -0,0 +1,92 @@
require 'gosu'
require_relative 'settings/configuration'
require_relative 'utils/bus'
require_relative 'utils/utils'
require_relative 'utils/scene'
require_relative 'settings/scene'
require_relative 'menu/scene'
require_relative 'game/scene'
$bus = EventBus.new
# Main window
class Window < Gosu::Window
def initialize
super *SCREEN_SIZE, resizable: true
self.caption = "Mist"
@scene = Menu.new
$bus.on(:quit_game) { close! }
$bus.on_retrievable(:mouse_pos) do
next mouse_relative(mouse_x, mouse_y)
end
$bus.on(:change_scene) do |new_scene|
@scene.close
@scene = new_scene
end
end
def button_down(id)
@scene.button_down(id, mouse_relative(mouse_x, mouse_y))
end
def update
@scene.update
end
def compute_transform
scale_x = width.to_f / SCREEN_SIZE[0]
scale_y = height.to_f / SCREEN_SIZE[1]
scale = [scale_x, scale_y].min
if scale_x < scale_y
offset_x = 0
offset_y = (height - SCREEN_SIZE[1] * scale) / 2
else
offset_x = (width - SCREEN_SIZE[0] * scale) / 2
offset_y = 0
end
[scale, offset_x, offset_y]
end
def mouse_relative(sx, sy)
scale, offset_x, offset_y = compute_transform
virtual_x = (sx - offset_x) / scale
virtual_y = (sy - offset_y) / scale
return nil if virtual_x < 0 || virtual_x > SCREEN_SIZE[0] ||
virtual_y < 0 || virtual_y > SCREEN_SIZE[1]
return [virtual_x, virtual_y]
end
def draw
scale, offset_x, offset_y = compute_transform
Gosu.translate(offset_x, offset_y) do
Gosu.scale(scale) do
@scene.draw
end
end
padding_color = Gosu::Color::BLACK
if offset_y > 0
# Letterbox: top and bottom
Gosu.draw_rect(0, 0, width, offset_y, padding_color, Float::INFINITY)
Gosu.draw_rect(0, height - offset_y, width, offset_y, padding_color, Float::INFINITY)
else
# Pillarbox: left and right
Gosu.draw_rect(0, 0, offset_x, height, padding_color, Float::INFINITY)
Gosu.draw_rect(width - offset_x, 0, offset_x, height, padding_color, Float::INFINITY)
end
end
end
Window.new.show
+67
View File
@@ -0,0 +1,67 @@
class Menu < Scene
OPTIONS = {
"Start" => -> { $bus.emit(:change_scene, Game.new) },
"Settings" => -> { $bus.emit(:change_scene, Settings.new) },
"Quit" => -> { $bus.emit(:quit_game) }
}.freeze
def initialize
super
@font = Gosu::Font.new(32, name: "assets/fonts/tn.ttf")
@selected_index = 0
@bg_image = Gosu::Image.new("assets/images/menu_bg.png", retro: true)
@last_mouse_pos = nil
end
def update
pos = $bus.get(:mouse_pos)
return unless pos
if @last_mouse_pos.nil? || ( (pos[0] - @last_mouse_pos[0]).abs > 2 || (pos[1] - @last_mouse_pos[1]).abs > 2 )
hovered_option = item_at(pos)
@selected_index = hovered_option[0] if hovered_option
@last_mouse_pos = pos.dup
end
end
def draw
@bg_image.draw(0, 0, 1)
OPTIONS.each_with_index do |(text, _), index|
y = 260 + index * 50
if index == @selected_index
@font.draw_text("[ #{text.ljust(10)} ]", 50, y, 1, 1, 1, Gosu::Color::YELLOW)
else
@font.draw_text(" #{text.ljust(10)} ", 50, y, 1, 1, 1, Gosu::Color::WHITE)
end
end
end
def button_down(id, pos)
case id
when Gosu::KB_UP
@selected_index = (@selected_index - 1) % OPTIONS.size
when Gosu::KB_DOWN
@selected_index = (@selected_index + 1) % OPTIONS.size
when Gosu::KB_RETURN
OPTIONS.values[@selected_index].call
end
return unless id == Gosu::MS_LEFT && pos
selected_option = item_at(pos)
selected_option[1].call if selected_option
end
private
def item_at(pos)
OPTIONS.each_with_index do |(_, action), index|
y = 260 + index * 50
if pos[1].between?(y, y + 32) && pos[0].between?(50, 50 + @font.text_width(" " * 14))
return [index, action]
end
end
nil
end
end
View File
View File
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env ruby
# Dependencies
# Gosu for rendering and input
# Perlin noise for fog effect
load 'main.rb'
+6
View File
@@ -0,0 +1,6 @@
# Global constants
SCREEN_SIZE = [720, 480].freeze
N, S, W, E = 1, 2, 4, 8
DEBUG = ARGV.include?("--debug")
+15
View File
@@ -0,0 +1,15 @@
class Settings < Scene
def initialize
super
@font = Gosu::Font.new(24)
end
def draw
Gosu.draw_rect(0, 0, SCREEN_SIZE[0], SCREEN_SIZE[1], Gosu::Color::BLACK)
@font.draw_text("Settings Scene - Press ESC to return to Menu", 50, 50, 1, 1, 1, Gosu::Color::WHITE)
end
def button_down(id, _pos)
$bus.emit(:change_scene, Menu.new) if id == Gosu::KB_ESCAPE
end
end
+57
View File
@@ -0,0 +1,57 @@
class EventBus
def initialize
# store callbacks with owner: {event => [ {callback:, owner:} ] }
@events = Hash.new { |h, k| h[k] = [] }
@retrievable_events = {}
end
# Emit an event
def emit(event, *args)
@events[event].each do |h|
h[:callback].call(*args)
end
end
# Subscribe to an event
def on(event, owner: nil, &callback)
owner ||= callback.binding.eval("self")
@events[event] << { callback: callback, owner: owner }
end
# Subscribe to an event that should only be called once
def once(event, owner = nil, &callback)
wrapper = nil
wrapper = proc do |*args|
callback.call(*args)
off(event, &wrapper)
end
on(event, owner: owner, &wrapper)
end
def off(event, &callback)
@events[event].reject! { |h| h[:callback] == callback }
end
# Register a retrievable event (only one allowed)
def on_retrievable(event, owner = nil, &callback)
owner ||= callback.binding.eval("self")
@retrievable_events[event] = { callback: callback, owner: owner }
end
# Get data from a retrievable event
def get(event, *args)
if @retrievable_events[event]
@retrievable_events[event][:callback].call(*args)
else
nil
end
end
# Unsubscribe from an object's events
def unlisten_owner(owner)
@events.each do |event, handlers|
handlers.reject! { |h| h[:owner] == owner }
end
@retrievable_events.delete_if { |_, h| h[:owner] == owner }
end
end
+162
View File
@@ -0,0 +1,162 @@
class MazeData
def initialize(width, height)
@width = width
@height = height
@grid = Array.new(height) { Array.new(width, 0) }
@boss_rooms = []
crate_rooms!
carve_passages_from(0, 0)
fix_room_entrances!
end
def width
@width * 2 + 1
end
def height
@height * 2 + 1
end
def wall_type(gx, gy)
mask = 0
return mask unless wall_at?(gx, gy)
mask |= N if gy > 0 && wall_at?(gx, gy - 1)
mask |= S if gy < height - 1 && wall_at?(gx, gy + 1)
mask |= E if gx < width - 1 && wall_at?(gx + 1, gy )
mask |= W if gx > 0 && wall_at?(gx - 1, gy )
return mask
end
def wall_at?(gx, gy)
return true if gx == 0 || gy == 0 || gx == width - 1 || gy == height - 1
@boss_rooms.each do |rx, ry|
display_x1 = rx * 2 + 1
display_y1 = ry * 2 + 1
display_x2 = display_x1 + (BOSS_ROOM_SIZE - 1) * 2
display_y2 = display_y1 + (BOSS_ROOM_SIZE - 1) * 2
return false if gx.between?(display_x1, display_x2) && gy.between?(display_y1, display_y2)
end
return false if gx.odd? && gy.odd?
if gx.odd? && gy.even?
cx = (gx - 1) / 2
cy = (gy - 1) / 2
return (@grid[cy][cx] & S) == 0
end
if gx.even? && gy.odd?
cx = (gx - 1) / 2
cy = (gy - 1) / 2
return (@grid[cy][cx] & E) == 0
end
true
end
private
BOSS_ROOM_SIZE = 5
BOSS_ROOM = 16
N, S, W, E = 1, 2, 4, 8
DX = { E => 1, W => -1, N => 0, S => 0 }
DY = { E => 0, W => 0, N => -1, S => 1 }
OPPOSITE = { E => W, W => E, N => S, S => N }
def room_free?(x, y)
(y - 2...(y + BOSS_ROOM_SIZE + 2)).each do |j|
(x - 2...(x + BOSS_ROOM_SIZE + 2)).each do |i|
return false if @grid[j][i] & BOSS_ROOM != 0
end
end
true
end
def fix_room_entrances!
@boss_rooms.each do |x, y|
@grid[y][x + 2] |= N
@grid[y - 1][x + 2] |= S
end
end
def crate_rooms!
base = Math.sqrt(@width * @height / 200.0).round
num_rooms = rand((base - 1)..(base))
num_rooms = 1 if num_rooms < 1
attempts = 0
while @boss_rooms.size < num_rooms && attempts < 10
attempts += 1
x = rand(2..(@width - BOSS_ROOM_SIZE - 2))
y = rand(2..(@height - BOSS_ROOM_SIZE - 2))
next unless room_free?(x, y)
(y...(y + BOSS_ROOM_SIZE)).each do |j|
(x...(x + BOSS_ROOM_SIZE)).each do |i|
cell = N | S | E | W
cell |= BOSS_ROOM
cell &= ~N if j == y
cell &= ~S if j == y + BOSS_ROOM_SIZE - 1
cell &= ~W if i == x
cell &= ~E if i == x + BOSS_ROOM_SIZE - 1
@grid[j][i] = cell
end
end
@boss_rooms << [x, y]
end
end
def carve_passages_from(cx, cy)
directions = [N, S, E, W].shuffle
directions.each do |direction|
nx, ny = cx + DX[direction], cy + DY[direction]
if ny.between?(0, @height - 1) && nx.between?(0, @width - 1) && @grid[ny][nx] == 0
@grid[cy][cx] |= direction
@grid[ny][nx] |= OPPOSITE[direction]
carve_passages_from(nx, ny)
end
end
end
end
if __FILE__ == $0
WALL_CHARS = {
0 => " ", # NONE
1 => "", # N
2 => "", # S
3 => "", # NS
4 => "", # W
5 => "", # NW
6 => "", # WS
7 => "", # WNS
8 => "", # E
9 => "", # NE
10 => "", # ES
11 => "", # ENS
12 => "", # EW
13 => "", # EWN
14 => "", # EWS
15 => "" # EWNS
}
maze = MazeData.new(80, 80)
(0...maze.height).each do |y|
(0...maze.width).each do |x|
print WALL_CHARS[maze.wall_type(x, y)]
end
puts
end
end
+19
View File
@@ -0,0 +1,19 @@
class Scene
# Base class for all scenes.
def initialize
end
def update
end
def draw
end
def button_down(id, pos)
end
def close
$bus.unlisten_owner(self)
end
end
+12
View File
@@ -0,0 +1,12 @@
def intersects?(rect1, rect2)
x1, y1, w1, h1 = rect1
x2, y2, w2, h2 = rect2
# No overlap conditions
return false if x1 + w1 <= x2 # rect1 is left of rect2
return false if x1 >= x2 + w2 # rect1 is right of rect2
return false if y1 + h1 <= y2 # rect1 is above rect2
return false if y1 >= y2 + h2 # rect1 is below rect2
true # overlap exists
end