Platformer
ASCII level to TileMap + colliders, coyote time, jump buffering, enemies you can stomp, coins and a goal.
Arrows/WASD move · Space jump · R restart
Source: examples/platformer/main.cr
examples/platformer/main.cr
require "../../src/eagle"
include Eagle
# Platformer: tile map from ASCII, KinematicBody2D player with coyote time and
# jump buffering, patrolling enemies, coins (Area2D), a goal, camera with limits.
LEVEL = <<-MAP
####################################################################
# #
# c c c #
# c ####### #
# P ### c c c #
# ##### e #### ### #### #
# ##### #### ## c G #
# c c e c e #### ###
# ##### ^^^ ###### ^^^ ####### ^^^^ ^^ #
####################################################################
MAP
class Player < KinematicBody2D
SPEED = 220
JUMP = 520
GRAVITY = 1400
@coyote = 0_f32
@buffer = 0_f32
@sprite = Polygon2D.rect(22, 30, Color.hex("#ffd166"))
@face = 1
signal died
def initialize(position : Vec2)
super("Player", position)
box(22, 30)
add(@sprite)
eye = Polygon2D.rect(4, 4, Color::BLACK)
eye.position = v2(5, -8)
add(eye)
end
def physics_process(dt : Float32)
@coyote = on_floor? ? 0.1_f32 : @coyote - dt
@buffer = Input.pressed?("jump") ? 0.12_f32 : @buffer - dt
x = Input.axis("left", "right")
@face = x > 0 ? 1 : (x < 0 ? -1 : @face)
vx = Mathf.damp(velocity.x, x * SPEED, 12, dt)
vy = velocity.y + GRAVITY * dt
if @buffer > 0 && @coyote > 0
vy = -JUMP
@buffer = 0_f32; @coyote = 0_f32
Sounds.jump
end
vy = Math.max(vy, -JUMP * 0.5) if Input.released?("jump") && vy < 0
self.velocity = v2(vx, vy)
move_and_slide(dt)
self.velocity = v2(velocity.x, 0) if on_ceiling? && velocity.y < 0
@sprite.scale = v2(@face, 1)
emit_died if position.y > 800
end
end
class Enemy < KinematicBody2D
@dir = 1
def initialize(position : Vec2)
super("Enemy", position)
box(24, 20)
add(Polygon2D.rect(24, 20, Color.hex("#ef476f")))
add_to_group("enemies")
end
def physics_process(dt : Float32)
# turn at walls or ledges
ahead = global_position + v2(@dir * 16, 14)
floor_ahead = Physics2D.world.raycast(ahead, v2(0, 1), 12, exclude: body)
self.velocity = v2(@dir * 60, 300)
move_and_slide(dt)
@dir = -@dir if on_wall? || floor_ahead.nil?
end
end
module Sounds
@@jump : Sound? = nil
@@coin : Sound? = nil
@@hurt : Sound? = nil
def self.init
@@jump = Sound.tone(500, 0.1, Sound::Wave::Square, 0.2)
@@coin = Sound.tone(1200, 0.08, Sound::Wave::Sine, 0.3)
@@hurt = Sound.tone(150, 0.3, Sound::Wave::Saw, 0.3)
end
def self.jump; @@jump.try(&.play(pitch: 0.9 + rand * 0.2)); end
def self.coin; @@coin.try(&.play(pitch: 1 + rand * 0.3)); end
def self.hurt; @@hurt.try(&.play); end
end
class Platformer < App
TILE = 32
@player : Player? = nil
@cam = Camera2D.new(zoom: 1.5)
@coins = 0
@total = 0
@deaths = 0
@won = false
@world = Node2D.new("World")
@hud = Label.new("")
def load
Sounds.init
Input.map "left", Key::A, Key::Left, Input.axis(GamepadAxis::LeftX, -1)
Input.map "right", Key::D, Key::Right, Input.axis(GamepadAxis::LeftX, 1)
Input.map "jump", Key::Space, Key::W, Key::Up, GamepadButton::A
root = SceneTree.root
root.add(@world)
build
hud = CanvasLayer.new
@hud.position = v2(10, 10)
hud.add(@hud)
root.add(hud)
root.add(@cam)
end
def build
@world.clear_children
@coins = 0; @total = 0; @won = false
lines = LEVEL.lines.map(&.rstrip)
ts = Image.new(TILE * 2, TILE)
ts.fill_rect(0, 0, TILE, TILE, Color.hex("#3d5a80"))
ts.fill_rect(0, 0, TILE, 4, Color.hex("#98c1d9"))
ts.fill_rect(TILE, 0, TILE, TILE, Color::TRANSPARENT)
TILE.times { |x| (0..(x % 8 == 0 ? 6 : 2)).each { |y| ts[TILE + x, TILE - 1 - y] = Color.hex("#ee6c4d") } } # spikes
map = TileMap.new(Texture.new(ts, GPU::Filter::Nearest), TILE, TILE)
@world.add(map)
solids = StaticBody2D.new("Solids")
hazards = Area2D.new("Hazards")
lines.each_with_index do |line, y|
x = 0
while x < line.size
c = line[x]
case c
when '#'
# merge horizontal runs into one collider
x2 = x
while x2 < line.size && line[x2] == '#'
x2 += 1
end
(x...x2).each { |xx| map[xx, y] = 0 }
w = (x2 - x) * TILE
solids.add_shape(Physics2D::Polygon.box(w, TILE, v2(x * TILE + w / 2, y * TILE + TILE / 2)))
x = x2
next
when '^'
map[x, y] = 1
hazards.add_shape(Physics2D::Polygon.box(TILE - 8, 10, v2(x * TILE + TILE / 2, y * TILE + TILE - 5)))
when 'c'
coin = Area2D.new("Coin", v2(x * TILE + TILE / 2, y * TILE + TILE / 2)).circle(8)
coin.add(Polygon2D.circle(8, Color.hex("#ffd60a")))
coin.add_to_group("coins")
@world.add(coin)
@total += 1
when 'e' then @world.add(Enemy.new(v2(x * TILE + TILE / 2, y * TILE + TILE / 2)))
when 'G'
goal = Area2D.new("Goal", v2(x * TILE + TILE / 2, y * TILE + TILE / 2)).box(24, 40)
goal.add(Polygon2D.rect(24, 40, Color.hex("#06d6a0")))
@world.add(goal)
when 'P' then spawn(v2(x * TILE + TILE / 2, y * TILE + TILE / 2))
end
x += 1
end
end
@world.add(solids, hazards)
hazards.on_body_entered { |b| hurt if b.is_a?(Player) }
@cam.limits = Rect.new(0, 0, lines.map(&.size).max * TILE, lines.size * TILE)
end
def spawn(at : Vec2)
p = Player.new(at)
p.on_died { hurt }
p.on_body_entered do |o|
if o.name == "Coin" && !o.queued_free?
o.queue_free; @coins += 1; Sounds.coin
elsif o.name == "Goal"
@won = true
elsif o.is_a?(Enemy)
if p.velocity.y > 0 && p.position.y < o.position.y - 10
o.queue_free; p.velocity = v2(p.velocity.x, -300); Sounds.coin
else
hurt
end
end
end
@world.add(p)
@player = p
@cam.follow(p, v2(0, -40))
@cam.smoothing = 6
end
def hurt
return unless (p = @player) && p.in_tree?
@deaths += 1
Sounds.hurt
@cam.shake(6, 0.3)
start = p.position
p.queue_free
SceneTree.defer { spawn(v2(2.5 * TILE, 4.5 * TILE)) }
end
def update(dt : Float32)
@hud.text = "coins #{@coins}/#{@total} deaths #{@deaths} #{@won ? "YOU WIN! R to restart" : "arrows/WASD move, space jump"}"
build if Input.pressed?(Key::R)
Eagle.quit if Input.pressed?(Key::Escape)
end
end
Eagle.run(Platformer, title: "Eagle Platformer", width: 960, height: 540, clear_color: Color.hex("#1b1f2a"))