Skip to content

Instantly share code, notes, and snippets.

@drewhamlett
Last active January 23, 2025 03:03
Show Gist options
  • Save drewhamlett/b4c163cc6acd05749df98b8a5ef0c4d1 to your computer and use it in GitHub Desktop.
Save drewhamlett/b4c163cc6acd05749df98b8a5ef0c4d1 to your computer and use it in GitHub Desktop.
enemy.rb
class Entity
def initialize(x, y)
@default_fade_time = 50
@fade_timer = nil
@default_pulse_timer = Utils.random(100, 150)
@pulse_timer = @default_pulse_timer
end
def fade_complete?
return if @fade_timer.nil?
@fade_timer == 0
end
def fading?
return false if @fade_timer.nil?
@fade_timer > 0
end
def fade
@fade_timer = @default_fade_time
end
protected
def alpha(default = 255)
fading? ? (@fade_timer * 2.55).to_i : default
end
def update
unless @fade_timer.nil?
@fade_timer -= 1 if @fade_timer > 0
end
@pulse_direction ||= -1
@pulse_timer += @pulse_direction
@pulse_direction *= -1 if @pulse_timer <= 60 || @pulse_timer >= @default_pulse_timer
end
end
class Enemy < Entity
attr_gtk
ENEMY_MAX_SPEED = 1
ENEMY_ACCELERATION = 0.1
ENEMY_SEPARATION_RADIUS = 30
ENEMY_SEPARATION_FORCE = 0.5
attr_reader :x, :y, :w, :h, :id, :default_color, :health
attr_accessor :velocity_x, :velocity_y, :stunned_until, :destroyed, :velocity_hit_at
FRICTION = 0.94
SQUASH_DURATION = 50
SQUASH_SCALE = 0.8
STUN_DURATION = 120 # 2 seconds at 60fps
# @param x [Integer]
# @param y [Integer]
def initialize(x, y)
super
@health = 7
@hit_count = 0
@size = [10, 12, 14, 16].sample
@id = object_id
@x = x
@y = y
@w = @size
@h = @size
@target_w = @size
@target_h = @size
@velocity_x = 0
@velocity_y = 0
@flash_timer = 0
@squash_timer = 0
@squash_start_at = 0
@stunned_until = 0
@default_color = [
Utils.colors[:yellow],
Utils.colors[:purple],
Utils.colors[:green],
Utils.colors[:blue],
Utils.colors[:red],
Utils.colors[:light]
].sample
@destroyed = false
@hit_timer = 0
@default_angle = [0, 90, 180, 270].sample
@velocity_hit_at = {
x: 0,
y: 0
}
@max_health = 3
@health = @max_health
end
def self.update_all
Array.reject!(GTK.args.state.enemies) { |enemy| enemy.dead? }
i = 0
len = GTK.args.state.enemies.length
player = GTK.args.state.player
while i < len
enemy = GTK.args.state.enemies[i]
unless enemy&.stunned?
dx = player.x - enemy.x
dy = player.y - enemy.y
dist_squared = dx * dx + dy * dy
if dist_squared > 0
inv_dist = 1.0 / Math.sqrt(dist_squared)
enemy.velocity_x += dx * inv_dist * ENEMY_ACCELERATION
enemy.velocity_y += dy * inv_dist * ENEMY_ACCELERATION
end
if Kernel.tick_count % 4 == 0
# Check only a few random nearby enemies
force_x = 0
force_y = 0
check_count = 0
j = 0
while j < len && check_count < 5
other = GTK.args.state.enemies[j]
next if other.nil?
if other.id != enemy.id && !other.stunned?
dx = enemy.x - other.x
dy = enemy.y - other.y
dist_squared = dx * dx + dy * dy
if dist_squared < ENEMY_SEPARATION_RADIUS * ENEMY_SEPARATION_RADIUS && dist_squared > 0
check_count += 1
force_x += dx * 0.01
force_y += dy * 0.01
end
end
j += 1
end
enemy.velocity_x += force_x
enemy.velocity_y += force_y
end
speed_squared = enemy.velocity_x * enemy.velocity_x + enemy.velocity_y * enemy.velocity_y
if speed_squared > ENEMY_MAX_SPEED * ENEMY_MAX_SPEED
speed = Math.sqrt(speed_squared)
enemy.velocity_x = (enemy.velocity_x / speed) * ENEMY_MAX_SPEED
enemy.velocity_y = (enemy.velocity_y / speed) * ENEMY_MAX_SPEED
end
end
enemy&.update
i += 1
end
end
def stunned?
GTK.args.state.tick_count < @stunned_until
end
def hit
@hit_timer = 50
@hit_count += 1
@health -= 1
end
def hit?
@hit_timer > 0
end
def stun
@stunned_until = state.tick_count + STUN_DURATION
end
def destroy
fade
end
def dead?
@destroyed
end
def update
super
@hit_timer -= 1 if @hit_timer > 0
apply_physics
handle_effects
if fade_complete? && !dead?
@destroyed = true
end
if dead? && Utils.random(1, 2) == 1
PowerUp.spawn(@x, @y)
end
@x = Utils.clamp(@x, 0, WORLD_WIDTH)
@y = Utils.clamp(@y, 0, WORLD_HEIGHT)
end
def apply_physics
@x += @velocity_x
@y += @velocity_y
@velocity_x *= FRICTION
@velocity_y *= FRICTION
end
def handle_effects
@flash_timer -= 1
@squash_timer -= 1 if @squash_timer > 0
if @squash_timer > 0
progress = @squash_start_at.ease(SQUASH_DURATION, [:flip, :cube, :flip])
scale = 1 - (1 - SQUASH_SCALE) * (1 - progress)
@w = @size * scale
@h = @size * scale
else
@w = @size
@h = @size
end
end
def squash
@squash_timer = SQUASH_DURATION
@squash_start_at = state.tick_count
end
def flash
@flash_timer = 10
@flash_timer -= 1
if @flash_timer.zero?
@flash_timer = 10
end
end
def draw
return [] if dead?
glow = {
x: @x + @size / 2,
y: @y + @size / 2,
w: @w * @pulse_timer / 30,
h: @h * @pulse_timer / 30,
anchor_x: 0.5,
anchor_y: 0.5,
path: Sprites.path,
**Sprites.get("glow_pixel.png"),
a: alpha(200),
**color
}
health_bar = {
x: @x,
y: @y + @h + 1,
w: @w * (@health / @max_health),
h: 1.6,
path: Sprites.path,
**Sprites.get("particle.png"),
**color,
a: 100
}
image_config = if @hit_count == 0
Sprites.get("block.png")
elsif @hit_count <= 9
Sprites.get("block_hit_#{@hit_count}.png")
else
Sprites.get("block_hit_9.png")
end
[
{
x: @x,
y: @y,
w: @w,
h: @h,
**image_config,
path: Sprites.path,
**color,
angle: @default_angle,
a: alpha(255)
},
glow,
health_bar
]
end
private
def color
if @flash_timer > 0
Utils.colors[:white]
else
@default_color
end
end
end
require_relative "utils"
require_relative "core/sprites"
require_relative "player"
require_relative "particles"
require_relative "enemy"
require_relative "noise"
require_relative "screen_shake"
require_relative "core/hit_label"
require_relative "core/map"
require_relative "core/pickup"
require_relative "core/box"
require_relative "collision"
require_relative "core/bar"
require_relative "core/shockwave"
require_relative "core/sound"
require_relative "core/combo_label"
require_relative "core/base"
require_relative "screens/pause_screen"
require_relative "screens/start_screen"
require_relative "core/power_up"
class Class
def r
$gtk.reset
$game = Game.new(args)
end
def disable_fx
GTK.args.state.fx = false
end
def enable_fx
GTK.args.state.fx = true
end
def clear_enemies
state.enemies = []
end
def spawn_enemies(count = 100)
count.times do
state.enemies << Enemy.new(
Utils.random(0, WORLD_WIDTH),
Utils.random(0, WORLD_HEIGHT)
)
end
end
end
FONT = "fonts/TinyUnicode.ttf".freeze
FONT_SIZE = 2
# Physical screen dimensions
SCREEN_WIDTH = 1280
SCREEN_HEIGHT = 720
# DEBUG = !GTK.production?
DEBUG = true
SCALE = 3
# Target game resolution
TARGET_WIDTH = (SCREEN_WIDTH / SCALE).freeze
TARGET_HEIGHT = (SCREEN_HEIGHT / SCALE).freeze
GAME_ZOOM = [
(SCREEN_WIDTH / TARGET_WIDTH).floor,
(SCREEN_HEIGHT / TARGET_HEIGHT).floor
].min.freeze
GAME_X_OFFSET = (SCREEN_WIDTH - (TARGET_WIDTH * GAME_ZOOM)).idiv(2).freeze
GAME_Y_OFFSET = (SCREEN_HEIGHT - (TARGET_HEIGHT * GAME_ZOOM)).idiv(2).freeze
WORLD_WIDTH = 1000
WORLD_HEIGHT = 1000
MINIMAP_SIZE = 35
MINIMAP_PADDING = 6
MINIMAP_UPDATE_FREQUENCY = 30
TARGET_FPS = 30
class Game
attr_gtk
CAMERA_LERP = 0.04
CAMERA_OFFSET_X = (TARGET_WIDTH / 2)
CAMERA_OFFSET_Y = (TARGET_HEIGHT / 2)
def initialize(args)
@player = Player.new
@grid = {}
@noise = Noise.new
@bar = Bar.new(args)
@particles = Particles.new(args)
@boxes = 5.times.map do
Box.new(Utils.random(0, TARGET_WIDTH), Utils.random(0, TARGET_HEIGHT))
end
@camera = {
x: 0,
y: 0,
target_x: 0,
target_y: 0
}
args.render_target(:minimap).width = MINIMAP_SIZE
args.render_target(:minimap).height = MINIMAP_SIZE
@last_minimap_update = 0
end
private
def tick
state.camera ||= @camera
state.player = @player
state.map ||= Map.new(args)
state.pickups ||= []
state.enemies ||= []
state.bases ||= [Base.new(400, 400), Base.new(100, 100), Base.new(800, 100)]
state.noise_intensity ||= 15
@player.args = args
Shockwave.init
ComboLabel.init(args)
HitLabel.init(args)
if Kernel.tick_count.zero?
10.times do
state.enemies << Enemy.new(
Utils.random(0, TARGET_WIDTH),
Utils.random(0, TARGET_HEIGHT)
)
end
end
Array.each(state.enemies) do |enemy|
enemy.args = args
end
update
draw
end
def draw
if state.shake_timer > 0
shake_x = rand(2 * state.shake_intensity + 1) - state.shake_intensity
shake_y = rand(2 * state.shake_intensity + 1) - state.shake_intensity
state.shake_timer -= 1
else
shake_x = 0
shake_y = 0
end
args.render_target(:game).width = TARGET_WIDTH
args.render_target(:game).height = TARGET_HEIGHT
args.render_target(:game).background_color = Utils.colors[:black]
args.outputs.background_color = Utils.colors[:black]
args.render_target(:overlay).width = TARGET_WIDTH
args.render_target(:overlay).height = TARGET_HEIGHT
args.render_target(:overlay).background_color = Utils.colors[:black]
args.render_target(:game).sprites << [
Array.map(@particles.draw(args)) do |p|
p.merge(x: p.x - @camera.x, y: p.y - @camera.y)
end
]
enemies_to_render = if state.tick_count % 4 == 0
Array.select(state.enemies) do |e|
e.x >= @camera.x - e.w &&
e.x <= @camera.x + TARGET_WIDTH - 5 &&
e.y >= @camera.y - e.h &&
e.y <= @camera.y + TARGET_HEIGHT - 5
end
else
@last_enemies_to_render ||= []
end
@last_enemies_to_render = enemies_to_render if state.tick_count % 3 == 0
args.render_target(:game).sprites << [
Array.flat_map(state.pickups) do |pi|
Array.map(pi.draw) do |sprite|
sprite.merge(x: sprite.x - @camera.x, y: sprite.y - @camera.y)
end
end,
Array.map(@player.draw) do |sprite|
sprite.merge(x: sprite.x - @camera.x, y: sprite.y - @camera.y)
end,
Array.flat_map(enemies_to_render) do |e|
Array.map(e.draw) do |sprite|
sprite.merge(x: sprite.x - @camera.x, y: sprite.y - @camera.y)
end
end,
Array.flat_map(@boxes) do |b|
Array.map(b.draw) do |sprite|
sprite.merge(x: sprite.x - @camera.x, y: sprite.y - @camera.y)
end
end,
Array.map(Shockwave.draw) do |p|
p.merge(x: p.x - @camera.x, y: p.y - @camera.y)
end,
@noise
]
args.outputs.labels << [
Array.map(HitLabel.draw(args)) do |p|
p.merge(
x: (p.x - @camera.x) * GAME_ZOOM + GAME_X_OFFSET,
y: (p.y - @camera.y) * GAME_ZOOM + GAME_Y_OFFSET
)
end,
ComboLabel.draw,
{
x: 20,
y: 40,
text: "Score: 100",
font: FONT,
size_enum: 4,
a: 255,
**Utils.colors[:white]
}
]
args.outputs.sprites << [
{
x: shake_x,
y: shake_y,
w: SCREEN_WIDTH,
h: SCREEN_HEIGHT,
path: :game,
angle: 1
},
# {
# x: GAME_X_OFFSET,
# y: GAME_Y_OFFSET,
# w: TARGET_WIDTH * GAME_ZOOM,
# h: TARGET_HEIGHT * GAME_ZOOM,
# path: :overlay,
# blendmode_enum: 4,
# a: 0
# },
@bar.draw
]
if args.state.minimap_enabled
if state.tick_count - @last_minimap_update >= MINIMAP_UPDATE_FREQUENCY
update_minimap
@last_minimap_update = state.tick_count
end
args.render_target(:game).primitives << [
{
x: TARGET_WIDTH - MINIMAP_SIZE - MINIMAP_PADDING,
y: TARGET_HEIGHT - MINIMAP_SIZE - MINIMAP_PADDING,
w: MINIMAP_SIZE,
h: MINIMAP_SIZE,
r: 0, g: 0, b: 0, a: 40,
primitive_marker: :solid
},
{
x: TARGET_WIDTH - MINIMAP_SIZE - MINIMAP_PADDING,
y: TARGET_HEIGHT - MINIMAP_SIZE - MINIMAP_PADDING,
w: MINIMAP_SIZE,
h: MINIMAP_SIZE,
path: :minimap,
primitive_marker: :sprite
},
{
x: TARGET_WIDTH - MINIMAP_SIZE - MINIMAP_PADDING + (@player.x / WORLD_WIDTH * MINIMAP_SIZE),
y: TARGET_HEIGHT - MINIMAP_SIZE - MINIMAP_PADDING + (@player.y / WORLD_HEIGHT * MINIMAP_SIZE),
w: 2,
h: 2,
r: 255, g: 255, b: 255,
primitive_marker: :solid
},
{
x: TARGET_WIDTH - MINIMAP_SIZE - MINIMAP_PADDING,
y: TARGET_HEIGHT - MINIMAP_SIZE - MINIMAP_PADDING,
w: MINIMAP_SIZE,
h: MINIMAP_SIZE,
r: 255, g: 255, b: 255,
primitive_marker: :border
}
]
end
end
def update
handle_collisions
Enemy.update_all
@bar.update
@player.update
ScreenShake.tick(args)
@particles.tick(args)
HitLabel.tick(args)
Shockwave.update
Sound.tick
ComboLabel.tick
Array.each(@boxes) do |box|
box.update
# box.delete_if(&:dead?)
end
update_camera
update_enemies
args.state.map.tick if args.state.fx
end
def update_camera
# Set camera target to follow player
@camera.target_x = @player.x - CAMERA_OFFSET_X
@camera.target_y = @player.y - CAMERA_OFFSET_Y
# Smooth camera movement using lerp
dx = @camera.target_x - @camera.x
dy = @camera.target_y - @camera.y
@camera.x += dx * CAMERA_LERP
@camera.y += dy * CAMERA_LERP
# Clamp camera position to world bounds
@camera.x = Utils.clamp(@camera.x, 0, WORLD_WIDTH - TARGET_WIDTH)
@camera.y = Utils.clamp(@camera.y, 0, WORLD_HEIGHT - TARGET_HEIGHT)
end
def update_enemies
# Utils.every(3.seconds) do
# state.enemies << Enemy.new(
# Utils.random(@camera.x + TARGET_WIDTH, WORLD_WIDTH),
# Utils.random(@camera.y + TARGET_HEIGHT, WORLD_HEIGHT)
# )
# end
end
def handle_collisions
Collision.check(
args,
player: @player,
pickups: state.pickups,
boxes: @boxes,
enemies: state.enemies
)
end
def get_mouse_position
x = ((args.mouse.x - GAME_X_OFFSET) / GAME_ZOOM).floor + @camera.x
y = ((args.mouse.y - GAME_Y_OFFSET) / GAME_ZOOM).floor + @camera.y
[x, y]
end
def update_minimap
args.render_target(:minimap).primitives.clear
args.render_target(:minimap).primitives << Array.map(state.enemies) do |enemy|
{
x: (enemy.x / WORLD_WIDTH * MINIMAP_SIZE),
y: (enemy.y / WORLD_HEIGHT * MINIMAP_SIZE),
w: 1,
h: 1,
**enemy.color,
primitive_marker: :solid
}
end
end
end
GTK.warn_array_primitives!
def global_keyboard(args)
if args.keyboard.key_up.escape || args.keyboard.key_up.p
args.state.paused = !args.state.paused
end
if args.keyboard.key_up.f
args.state.fx = !args.state.fx
end
if args.inputs.keyboard.key_up.enter
args.state.current_screen = :game
end
if args.inputs.keyboard.key_up.m
args.state.minimap_enabled = !args.state.minimap_enabled
end
end
def tick(args)
if Kernel.tick_count.zero?
args.state.debug = DEBUG
args.state.fx = true
args.state.first_game_tick = false
args.state.minimap_enabled = true
end
$game ||= Game.new(args)
$game.args = args
args.state.current_screen ||= :game
args.state.paused ||= false
args.state.wave ||= 1
args.state.next_wave_time ||= 0
args.state.camera_x = $game.instance_variable_get(:@camera)[:x]
args.state.camera_y = $game.instance_variable_get(:@camera)[:y]
if args.state.paused
PauseScreen.draw(args)
elsif args.state.current_screen == :start
StartScreen.draw(args)
elsif args.state.current_screen == :game
$game.tick
end
if Kernel.tick_count.zero?
args.outputs.static_sprites << [
{
x: 0,
y: 0,
w: SCREEN_WIDTH,
h: SCREEN_HEIGHT,
path: "assets/v-crush.png",
a: 255
},
{
x: 0,
y: 0,
w: SCREEN_WIDTH,
h: SCREEN_HEIGHT,
path: "assets/v-crush.png",
a: 255
}
]
args.render_target(:brightness).primitives << [
{
x: 0,
y: 0,
w: SCREEN_WIDTH,
h: SCREEN_HEIGHT,
r: 130 - 50,
g: 130 - 50,
b: 160 - 0,
blendmode_enum: 4,
primitive_marker: :solid
}
]
if args.state.tick_count.zero? && args.state.fx
args.outputs.static_sprites << [
{
x: 0,
y: 0,
w: SCREEN_WIDTH,
h: SCREEN_HEIGHT,
path: "assets/scanline.png",
a: 10
},
{
x: 0,
y: 0,
w: SCREEN_WIDTH,
h: SCREEN_HEIGHT,
path: :brightness,
a: 255,
blendmode_enum: 4
}
]
end
end
Utils.render_debug(args)
global_keyboard(args)
end
$gtk.reset
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment