Last active
January 23, 2025 03:03
-
-
Save drewhamlett/b4c163cc6acd05749df98b8a5ef0c4d1 to your computer and use it in GitHub Desktop.
enemy.rb
This file contains hidden or 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
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 |
This file contains hidden or 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
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