# this file sets up the main game loop (no need to modify it)
# Logical canvas width and height
WIDTH = 1280
HEIGHT = 720
# Nokia screen dimensions
NOKIA_WIDTH = 84
NOKIA_HEIGHT = 48
# Determine best fit zoom level
ZOOM_WIDTH = (WIDTH / NOKIA_WIDTH).floor
ZOOM_HEIGHT = (HEIGHT / NOKIA_HEIGHT).floor
ZOOM = [ZOOM_WIDTH, ZOOM_HEIGHT].min
# Compute the offset to center the Nokia screen
OFFSET_X = (WIDTH - NOKIA_WIDTH * ZOOM) / 2
OFFSET_Y = (HEIGHT - NOKIA_HEIGHT * ZOOM) / 2
# Compute the scaled dimensions of the Nokia screen
ZOOMED_WIDTH = NOKIA_WIDTH * ZOOM
ZOOMED_HEIGHT = NOKIA_HEIGHT * ZOOM
def boot args
args.state = {}
end
def tick args
# set the background color to black
args.outputs.background_color = [0, 0, 0]
# define a render target that represents the Nokia screen
args.outputs[:nokia].w = 84
args.outputs[:nokia].h = 48
args.outputs[:nokia].background_color = [199, 240, 216]
# new up the game if it hasn't been created yet
$game ||= Game.new
# pass args environment to the game
$game.args = args
# compute the mouse position in the Nokia screen
$game.nokia_mouse_position = {
x: (args.inputs.mouse.x - OFFSET_X).idiv(ZOOM),
y: (args.inputs.mouse.y - OFFSET_Y).idiv(ZOOM),
w: 1,
h: 1,
}
# update the game
$game.tick
# render the game scaled to fit the screen
args.outputs.sprites << {
x: WIDTH / 2,
y: HEIGHT / 2,
w: ZOOMED_WIDTH,
h: ZOOMED_HEIGHT,
anchor_x: 0.5,
anchor_y: 0.5,
path: :nokia,
}
end
# if GTK.reset is called
# clear out the game so that it can be re-initialized
def reset args
$game = nil
end
class Game
attr :args, :nokia_mouse_position
def tick
# create a new game on frame zero
new_game if Kernel.tick_count == 0
# calc game
calc
# render game
render
# increment the clock
state.clock += 1
end
def calc
calc_game
calc_restart
end
def calc_game
# return if the game is over
return if state.game_over
# return if the game is just starting
return if state.clock < 30
# begin capturing input after the initial countdown
if inputs.keyboard.left && snake.direction.x == 0
# if keyboard left is pressed or held, and
# if the snake is not moving left or right,
# set the next direction to left
snake.next_direction = { x: -1, y: 0 }
snake.next_angle = 180
elsif inputs.keyboard.right && snake.direction.x == 0
# if keyboard right is pressed or held, and
# if the snake is not moving left or right,
# set the next direction to right
snake.next_direction = { x: 1, y: 0 }
snake.next_angle = 0
end
if inputs.keyboard.up && snake.direction.y == 0
# if keyboard up is pressed or held, and
# if the snake is not moving up or down,
# set the next direction to up
snake.next_direction = { x: 0, y: 1 }
snake.next_angle = 90
elsif inputs.keyboard.down && snake.direction.y == 0
# if keyboard down is pressed or held, and
# if the snake is not moving up or down,
# set the next direction to down
snake.next_direction = { x: 0, y: -1 }
snake.next_angle = 270
end
# return if the game is in the initial countdown
return if state.clock < 60
# process the movement of the snake every 15 frames
return if !state.clock.zmod?(15)
# add a new segment to the end of the snake
snake.body.push_back({ x: snake.head.x, y: snake.head.y })
# update the snake's direction based on what input was captured
snake.direction = { **snake.next_direction }
# update the snake's angle based on what input was captured (for rendering)
snake.angle = snake.next_angle
# update the snake's head position based on its direction
snake.head = { x: snake.head.x + snake.direction.x,
y: snake.head.y + snake.direction.y }
# check if the snake has collided with the world boundaries
if snake.head.x < 0 || snake.head.x >= state.world_dimensions.w ||
snake.head.y < 0 || snake.head.y >= state.world_dimensions.h
state.game_over = true
state.game_over_at = state.clock
end
# check if the snake has collided with itself
if snake.body.include?(snake.head)
state.game_over = true
state.game_over_at = state.clock
end
# if the snake body is longer than the snake size
# remove the first segment of the snake body
if snake.body.length > snake.sz
snake.body.pop_front
end
# check if the snake has eaten the apple
if snake.head.x == state.apple.x && snake.head.y == state.apple.y
# increase the snake size
snake.sz += 1
# increase the score
state.score += 1
# check if the score is higher than the high score
# and update the high score if necessary
state.high_score = state.score if state.score > state.high_score
# generate a new apple
state.apple = new_apple
end
end
def calc_restart
# check keyboard input to see if game should be restarted
# wait 60 frames after game over before accepting input
return if !state.game_over
return if state.game_over_at.elapsed_time(state.clock) < 60
# if any key is pressed, start a new game
if inputs.keyboard.key_down.truthy_keys.any?
new_game
end
end
def render
# render the main game
render_game
# render the game over screen if needed
render_game_over
end
def render_game
# render the snake's head
nokia.sprites << {
x: snake.head.x * 3,
y: snake.head.y * 3,
w: 3,
h: 3,
path: "sprites/head.png",
angle: snake.angle
}
# render the snake's body
nokia.sprites << snake.body.map do |segment|
{
x: segment.x * 3,
y: segment.y * 3,
w: 3,
h: 3,
path: "sprites/body.png"
}
end
# render the apple
nokia.sprites << {
x: state.apple.x * 3,
y: state.apple.y * 3,
w: 3,
h: 3,
path: "sprites/apple.png"
}
end
def render_game_over
# return if the game is not over
return if !state.game_over
# wait 60 frames after game over before rendering the game over screen/overlay
return if state.game_over_at.elapsed_time(state.clock) < 60
# render background
nokia.sprites << {
x: 84 / 2, y: 48 / 2, w: 84, h: 18, path: :solid, r: 67, g: 82, b: 61,
anchor_x: 0.5, anchor_y: 0.5
}
# render game over text
nokia.labels << sm_label.merge(x: 84 / 2,
y: 48 / 2,
r: 199, g: 240, b: 216,
text: "GAME OVER",
anchor_x: 0.5,
anchor_y: -0.5)
# render score text
nokia.labels << sm_label.merge(x: 84 / 2,
y: 48 / 2,
r: 199, g: 240, b: 216,
text: "SCORE: #{state.score}",
anchor_x: 0.5,
anchor_y: 0.5)
# render high score text
nokia.labels << sm_label.merge(x: 84 / 2,
y: 48 / 2,
r: 199, g: 240, b: 216,
text: "HI SCORE: #{state.high_score}",
anchor_x: 0.5,
anchor_y: 1.75)
end
def snake
# helper function to access the snake state so we aren't writing state.snake everywhere
state.snake
end
def new_game
# initial state for a new game
state.clock = 0
state.world_dimensions = { w: 28, h: 16 }
state.snake = {
sz: 3,
head: { x: 14, y: 8 },
body: [],
direction: { x: 1, y: 0 },
next_direction: { x: 1, y: 0 },
angle: 0,
next_angle: 0
}
state.high_score ||= 0
state.score = 0
state.apple = new_apple
state.game_over = false
state.game_over_at = nil
end
def new_apple
# pick a random location for the apple
potential_apple = { x: Numeric.rand(0..state.world_dimensions.w - 1),
y: Numeric.rand(0..state.world_dimensions.h - 1) }
if snake.body.include?(potential_apple) || state.snake.head == potential_apple
# if the apple is on the snake or in the snake's head, pick a new location
new_apple
else
# otherwise, return the apple
potential_apple
end
end
def sm_label
{ x: 0, y: 0, size_px: 5, font: "fonts/lowrez.ttf", anchor_x: 0, anchor_y: 0 }
end
def md_label
{ x: 0, y: 0, size_px: 10, font: "fonts/lowrez.ttf", anchor_x: 0, anchor_y: 0 }
end
def lg_label
{ x: 0, y: 0, size_px: 15, font: "fonts/lowrez.ttf", anchor_x: 0, anchor_y: 0 }
end
def xl_label
{ x: 0, y: 0, size_px: 20, font: "fonts/lowrez.ttf", anchor_x: 0, anchor_y: 0 }
end
def nokia
outputs[:nokia]
end
def outputs
@args.outputs
end
def inputs
@args.inputs
end
def state
@args.state
end
end
# GTK.reset will reset your entire game
# it's useful for debugging and starting fresh
# comment this line out if you want to retain your
# current game state in between hot reloads
GTK.reset