pacmanrb 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +46 -0
- data/app/components/board_component.rb +100 -0
- data/app/components/hud_component.rb +15 -0
- data/app/controllers/application_controller.rb +24 -0
- data/app/controllers/game_controller.rb +68 -0
- data/app/controllers/game_over_controller.rb +26 -0
- data/app/controllers/home_controller.rb +22 -0
- data/app/models/application_record.rb +7 -0
- data/app/models/high_score.rb +7 -0
- data/app/state/application_state.rb +6 -0
- data/app/state/game_over_state.rb +9 -0
- data/app/state/game_state.rb +9 -0
- data/app/state/home_state.rb +7 -0
- data/app/views/game/show_view.rb +32 -0
- data/app/views/game_over/show_view.rb +40 -0
- data/app/views/home/show_view.rb +42 -0
- data/app/views/layouts/application_layout.rb +34 -0
- data/config/database.rb +14 -0
- data/config/routes.rb +7 -0
- data/db/development.sqlite3 +0 -0
- data/db/migrate/20260719010011_create_high_scores.rb +11 -0
- data/db/schema.rb +20 -0
- data/db/seeds.rb +1 -0
- data/db/test.sqlite3 +0 -0
- data/exe/pacmanrb +7 -0
- data/lib/pacman/application.rb +13 -0
- data/lib/pacman/arcade/brains.rb +46 -0
- data/lib/pacman/arcade/direction.rb +19 -0
- data/lib/pacman/arcade/ghost.rb +72 -0
- data/lib/pacman/arcade/layouts.rb +28 -0
- data/lib/pacman/arcade/maze.rb +67 -0
- data/lib/pacman/arcade/mode_schedule.rb +52 -0
- data/lib/pacman/arcade/navigator.rb +36 -0
- data/lib/pacman/arcade/pellet_field.rb +39 -0
- data/lib/pacman/arcade/player.rb +42 -0
- data/lib/pacman/arcade/position.rb +15 -0
- data/lib/pacman/arcade/scoreboard.rb +43 -0
- data/lib/pacman/arcade/spawn.rb +8 -0
- data/lib/pacman/arcade/world.rb +169 -0
- data/lib/pacman/board_scale.rb +42 -0
- data/lib/pacman/sprites.rb +145 -0
- data/lib/pacman/version.rb +5 -0
- data/lib/pacman.rb +22 -0
- metadata +127 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
Direction = Data.define(:dy, :dx) do
|
|
6
|
+
def self.up = @up ||= new(dy: -1, dx: 0)
|
|
7
|
+
|
|
8
|
+
def self.down = @down ||= new(dy: 1, dx: 0)
|
|
9
|
+
|
|
10
|
+
def self.left = @left ||= new(dy: 0, dx: -1)
|
|
11
|
+
|
|
12
|
+
def self.right = @right ||= new(dy: 0, dx: 1)
|
|
13
|
+
|
|
14
|
+
def self.all = [up, down, left, right]
|
|
15
|
+
|
|
16
|
+
def opposite = Direction.new(dy: -dy, dx: -dx)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
class Ghost
|
|
6
|
+
attr_reader :position, :direction
|
|
7
|
+
|
|
8
|
+
def initialize(spawn:, brain:, direction: Direction.up)
|
|
9
|
+
@spawn = spawn
|
|
10
|
+
@brain = brain
|
|
11
|
+
@direction = direction
|
|
12
|
+
@position = spawn.start
|
|
13
|
+
@state = :normal
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def advance(world)
|
|
17
|
+
@direction = choose_direction(world)
|
|
18
|
+
@position = world.maze.wrap(position.step(@direction))
|
|
19
|
+
revive if eaten? && position == spawn.start
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def frighten
|
|
23
|
+
@state = :frightened unless eaten?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def calm
|
|
27
|
+
@state = :normal unless eaten?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def devour
|
|
31
|
+
@state = :eaten
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def edible?
|
|
35
|
+
@state == :frightened
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def eaten?
|
|
39
|
+
@state == :eaten
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def respawn
|
|
43
|
+
@position = spawn.start
|
|
44
|
+
@direction = Direction.up
|
|
45
|
+
@state = :normal
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :spawn, :brain
|
|
51
|
+
|
|
52
|
+
def choose_direction(world)
|
|
53
|
+
if edible?
|
|
54
|
+
world.navigator.wander(from: position, current: direction, rng: world.rng)
|
|
55
|
+
else
|
|
56
|
+
world.navigator.toward(target: target(world), from: position, current: direction)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def target(world)
|
|
61
|
+
return spawn.start if eaten?
|
|
62
|
+
return spawn.corner if world.mode == :scatter
|
|
63
|
+
|
|
64
|
+
brain.target(world)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def revive
|
|
68
|
+
@state = :normal
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
# ASCII maze layouts. Legend: `#` wall, `.` pellet, `o` power pellet,
|
|
6
|
+
# `P` player start, `G` ghost start, space = open corridor (no pellet).
|
|
7
|
+
# A row whose first and last cells are open is a wrap-around tunnel row.
|
|
8
|
+
module Layouts
|
|
9
|
+
CLASSIC = <<~MAZE
|
|
10
|
+
###################
|
|
11
|
+
#o.......#.......o#
|
|
12
|
+
#.##.###.#.###.##.#
|
|
13
|
+
#.................#
|
|
14
|
+
#.##.#.#####.#.##.#
|
|
15
|
+
#....#.GGGG..#....#
|
|
16
|
+
#.##.#.#####.#.##.#
|
|
17
|
+
.................
|
|
18
|
+
#.##.#.#####.#.##.#
|
|
19
|
+
#....#...#...#....#
|
|
20
|
+
#.##.###.#.###.##.#
|
|
21
|
+
#........P........#
|
|
22
|
+
#.##.###.#.###.##.#
|
|
23
|
+
#o...#.......#...o#
|
|
24
|
+
###################
|
|
25
|
+
MAZE
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
class Maze
|
|
6
|
+
attr_reader :width, :height
|
|
7
|
+
|
|
8
|
+
def initialize(layout)
|
|
9
|
+
@rows = pad_rows(layout.lines.map(&:chomp))
|
|
10
|
+
@height = @rows.length
|
|
11
|
+
@width = @rows.first.length
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pellet_positions
|
|
15
|
+
positions_of(".")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def power_positions
|
|
19
|
+
positions_of("o")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def open_neighbors(position)
|
|
23
|
+
Direction.all.map { |direction| wrap(position.step(direction)) }
|
|
24
|
+
.reject { |neighbor| wall?(neighbor) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def player_start
|
|
28
|
+
positions_of("P").first
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ghost_starts
|
|
32
|
+
positions_of("G")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def wrap(position)
|
|
36
|
+
Position.new(row: position.row % height, col: position.col % width)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def wall?(position)
|
|
40
|
+
cell(position) == "#"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def cell(position)
|
|
46
|
+
rows[position.row][position.col]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def positions_of(marker)
|
|
50
|
+
each_position.select { |position| cell(position) == marker }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def each_position
|
|
54
|
+
(0...height).to_a.product((0...width).to_a).map do |row, col|
|
|
55
|
+
Position.new(row: row, col: col)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
attr_reader :rows
|
|
60
|
+
|
|
61
|
+
def pad_rows(lines)
|
|
62
|
+
widest = lines.map(&:length).max
|
|
63
|
+
lines.map { |line| line.ljust(widest) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
class ModeSchedule
|
|
6
|
+
attr_reader :mode
|
|
7
|
+
|
|
8
|
+
def initialize(scatter_ticks:, chase_ticks:, frightened_ticks:)
|
|
9
|
+
@durations = {scatter: scatter_ticks, chase: chase_ticks}
|
|
10
|
+
@frightened_ticks = frightened_ticks
|
|
11
|
+
@mode = :scatter
|
|
12
|
+
@remaining = scatter_ticks
|
|
13
|
+
@frightened_remaining = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def frighten!
|
|
17
|
+
@paused_mode = mode unless frightened?
|
|
18
|
+
@mode = :frightened
|
|
19
|
+
@frightened_remaining = @frightened_ticks
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def frightened?
|
|
23
|
+
mode == :frightened
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tick
|
|
27
|
+
return tick_frightened if frightened?
|
|
28
|
+
|
|
29
|
+
advance_cycle
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def tick_frightened
|
|
36
|
+
@frightened_remaining -= 1
|
|
37
|
+
return nil if @frightened_remaining.positive?
|
|
38
|
+
|
|
39
|
+
@mode = @paused_mode
|
|
40
|
+
:frightened_ended
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def advance_cycle
|
|
44
|
+
@remaining -= 1
|
|
45
|
+
return if @remaining.positive?
|
|
46
|
+
|
|
47
|
+
@mode = (mode == :scatter) ? :chase : :scatter
|
|
48
|
+
@remaining = @durations.fetch(@mode)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
class Navigator
|
|
6
|
+
def initialize(maze)
|
|
7
|
+
@maze = maze
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def toward(target:, from:, current:)
|
|
11
|
+
candidates(from, current).min_by do |choice|
|
|
12
|
+
maze.wrap(from.step(choice)).distance_sq(target)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def wander(from:, current:, rng:)
|
|
17
|
+
options = candidates(from, current)
|
|
18
|
+
options[rng.rand(options.length)]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :maze
|
|
24
|
+
|
|
25
|
+
def candidates(from, current)
|
|
26
|
+
open = open_directions(from)
|
|
27
|
+
forward = open - [current.opposite]
|
|
28
|
+
forward.empty? ? open : forward
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def open_directions(from)
|
|
32
|
+
Direction.all.reject { |choice| maze.wall?(maze.wrap(from.step(choice))) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
class PelletField
|
|
6
|
+
def initialize(pellets:, powers:)
|
|
7
|
+
@pellets = pellets.to_set
|
|
8
|
+
@powers = powers.to_set
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def eat(position)
|
|
12
|
+
return :pellet if pellets.delete?(position)
|
|
13
|
+
return :power if powers.delete?(position)
|
|
14
|
+
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def empty?
|
|
19
|
+
remaining.zero?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def remaining
|
|
23
|
+
pellets.size + powers.size
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def include?(position)
|
|
27
|
+
pellets.include?(position) || powers.include?(position)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def power?(position)
|
|
31
|
+
powers.include?(position)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :pellets, :powers
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
class Player
|
|
6
|
+
attr_reader :position, :direction
|
|
7
|
+
|
|
8
|
+
def initialize(position:, direction:)
|
|
9
|
+
@start = position
|
|
10
|
+
@initial_direction = direction
|
|
11
|
+
@position = position
|
|
12
|
+
@direction = direction
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def respawn
|
|
16
|
+
@position = @start
|
|
17
|
+
@direction = @initial_direction
|
|
18
|
+
@queued_turn = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def queue_turn(new_direction)
|
|
22
|
+
@queued_turn = new_direction
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def advance(maze)
|
|
26
|
+
apply_queued_turn(maze)
|
|
27
|
+
ahead = maze.wrap(position.step(direction))
|
|
28
|
+
@position = ahead unless maze.wall?(ahead)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def apply_queued_turn(maze)
|
|
34
|
+
return unless @queued_turn
|
|
35
|
+
return if maze.wall?(maze.wrap(position.step(@queued_turn)))
|
|
36
|
+
|
|
37
|
+
@direction = @queued_turn
|
|
38
|
+
@queued_turn = nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
Position = Data.define(:row, :col) do
|
|
6
|
+
def step(direction)
|
|
7
|
+
Position.new(row: row + direction.dy, col: col + direction.dx)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def distance_sq(other)
|
|
11
|
+
((row - other.row)**2) + ((col - other.col)**2)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
class Scoreboard
|
|
6
|
+
PELLET_POINTS = 10
|
|
7
|
+
POWER_POINTS = 50
|
|
8
|
+
FIRST_GHOST_BONUS = 200
|
|
9
|
+
|
|
10
|
+
attr_reader :total
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@total = 0
|
|
14
|
+
reset_combo
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def pellet!
|
|
18
|
+
award(PELLET_POINTS)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def power!
|
|
22
|
+
award(POWER_POINTS)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ghost!
|
|
26
|
+
bonus = award(@next_ghost_bonus)
|
|
27
|
+
@next_ghost_bonus *= 2
|
|
28
|
+
bonus
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reset_combo
|
|
32
|
+
@next_ghost_bonus = FIRST_GHOST_BONUS
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def award(points)
|
|
38
|
+
@total += points
|
|
39
|
+
points
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
module Arcade
|
|
5
|
+
# The aggregate root: owns every actor and advances the whole game one tick
|
|
6
|
+
# at a time. As the composition root it accepts the full cast — the wide
|
|
7
|
+
# keyword list is deliberate and isolated behind the .classic factory.
|
|
8
|
+
class World
|
|
9
|
+
GHOST_STRIDE = 2
|
|
10
|
+
FRIGHTENED_STRIDE = 3
|
|
11
|
+
STARTING_LIVES = 3
|
|
12
|
+
|
|
13
|
+
attr_reader :maze, :player, :pellets, :ghosts, :navigator, :rng, :lives, :level
|
|
14
|
+
|
|
15
|
+
def self.classic(rng: Random.new)
|
|
16
|
+
maze = Maze.new(Layouts::CLASSIC)
|
|
17
|
+
new(
|
|
18
|
+
maze: maze,
|
|
19
|
+
player: Player.new(position: maze.player_start, direction: Direction.left),
|
|
20
|
+
pellets: PelletField.new(pellets: maze.pellet_positions, powers: maze.power_positions),
|
|
21
|
+
scoreboard: Scoreboard.new,
|
|
22
|
+
ghosts: classic_ghosts(maze, rng),
|
|
23
|
+
schedule: ModeSchedule.new(scatter_ticks: 40, chase_ticks: 120, frightened_ticks: 50),
|
|
24
|
+
rng: rng
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.classic_ghosts(maze, rng)
|
|
29
|
+
corners = [
|
|
30
|
+
Position.new(row: 1, col: maze.width - 2),
|
|
31
|
+
Position.new(row: 1, col: 1),
|
|
32
|
+
Position.new(row: maze.height - 2, col: maze.width - 2),
|
|
33
|
+
Position.new(row: maze.height - 2, col: 1)
|
|
34
|
+
]
|
|
35
|
+
brains = [Brains::Chase.new, Brains::Ambush.new, Brains::Wander.new(rng: rng), nil]
|
|
36
|
+
maze.ghost_starts.each_with_index.map do |start, index|
|
|
37
|
+
corner = corners.fetch(index % corners.length)
|
|
38
|
+
Ghost.new(
|
|
39
|
+
spawn: Spawn.new(start: start, corner: corner),
|
|
40
|
+
brain: brains.fetch(index % brains.length) || Brains::Corner.new(corner: corner)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(maze:, player:, pellets:, scoreboard:, ghosts: [], schedule: nil, rng: Random.new)
|
|
46
|
+
@maze = maze
|
|
47
|
+
@player = player
|
|
48
|
+
@pellets = pellets
|
|
49
|
+
@scoreboard = scoreboard
|
|
50
|
+
@ghosts = ghosts
|
|
51
|
+
@schedule = schedule
|
|
52
|
+
@rng = rng
|
|
53
|
+
@navigator = Navigator.new(maze)
|
|
54
|
+
@lives = STARTING_LIVES
|
|
55
|
+
@level = 1
|
|
56
|
+
@ticks = 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def over?
|
|
60
|
+
lives <= 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tick
|
|
64
|
+
return [] if over?
|
|
65
|
+
|
|
66
|
+
@ticks += 1
|
|
67
|
+
events = []
|
|
68
|
+
events.concat(advance_schedule)
|
|
69
|
+
player.advance(maze)
|
|
70
|
+
events.concat(eat_at(player.position))
|
|
71
|
+
events.concat(resolve_collisions)
|
|
72
|
+
return events if events.include?(:death)
|
|
73
|
+
|
|
74
|
+
advance_ghosts
|
|
75
|
+
events.concat(resolve_collisions)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def queue_turn(direction)
|
|
79
|
+
player.queue_turn(direction)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def score
|
|
83
|
+
scoreboard.total
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def mode
|
|
87
|
+
schedule ? schedule.mode : :chase
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
attr_reader :scoreboard, :schedule
|
|
93
|
+
|
|
94
|
+
EATEN_EVENTS = {pellet: :pellet_eaten, power: :power_pellet}.freeze
|
|
95
|
+
|
|
96
|
+
def eat_at(position)
|
|
97
|
+
kind = pellets.eat(position)
|
|
98
|
+
return [] unless kind
|
|
99
|
+
|
|
100
|
+
scoreboard.public_send(:"#{kind}!")
|
|
101
|
+
frighten_all if kind == :power
|
|
102
|
+
events = [EATEN_EVENTS.fetch(kind)]
|
|
103
|
+
events.concat(clear_level) if pellets.empty?
|
|
104
|
+
events
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def clear_level
|
|
108
|
+
@level += 1
|
|
109
|
+
@pellets = PelletField.new(pellets: maze.pellet_positions, powers: maze.power_positions)
|
|
110
|
+
player.respawn
|
|
111
|
+
ghosts.each(&:respawn)
|
|
112
|
+
[:level_clear]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def frighten_all
|
|
116
|
+
schedule&.frighten!
|
|
117
|
+
ghosts.each(&:frighten)
|
|
118
|
+
scoreboard.reset_combo
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def advance_schedule
|
|
122
|
+
return [] unless schedule
|
|
123
|
+
return [] unless schedule.tick == :frightened_ended
|
|
124
|
+
|
|
125
|
+
ghosts.each(&:calm)
|
|
126
|
+
scoreboard.reset_combo
|
|
127
|
+
[:frightened_ended]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def advance_ghosts
|
|
131
|
+
ghosts.each { |ghost| ghost.advance(self) if moves_this_tick?(ghost) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def moves_this_tick?(ghost)
|
|
135
|
+
(@ticks % stride(ghost)).zero?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def stride(ghost)
|
|
139
|
+
base = (level == 1) ? GHOST_STRIDE : 1
|
|
140
|
+
ghost.edible? ? base + 1 : base
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def resolve_collisions
|
|
144
|
+
ghosts.select { |ghost| ghost.position == player.position }
|
|
145
|
+
.flat_map { |ghost| resolve_collision(ghost) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def resolve_collision(ghost)
|
|
149
|
+
return [] if ghost.eaten?
|
|
150
|
+
return devour(ghost) if ghost.edible?
|
|
151
|
+
|
|
152
|
+
death
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def devour(ghost)
|
|
156
|
+
ghost.devour
|
|
157
|
+
scoreboard.ghost!
|
|
158
|
+
[:ghost_eaten]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def death
|
|
162
|
+
@lives -= 1
|
|
163
|
+
player.respawn
|
|
164
|
+
ghosts.each(&:respawn)
|
|
165
|
+
[:death]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pacman
|
|
4
|
+
# How much the board is zoomed for a given terminal, and how that zoom slows
|
|
5
|
+
# the game's movement pace so on-screen speed stays roughly constant.
|
|
6
|
+
class BoardScale
|
|
7
|
+
HUD_ROWS = 2
|
|
8
|
+
|
|
9
|
+
def initialize(screen:, maze:)
|
|
10
|
+
@screen = screen
|
|
11
|
+
@maze = maze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def factor
|
|
15
|
+
[width_factor, height_factor].min
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def cramped?
|
|
19
|
+
factor < 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
SLOWDOWN_ZOOM = 3
|
|
23
|
+
|
|
24
|
+
# Timer pulses per movement tick: full speed at modest zooms, one gentle
|
|
25
|
+
# halving once cells get big enough that motion would blur across the board.
|
|
26
|
+
def pace
|
|
27
|
+
(factor >= SLOWDOWN_ZOOM) ? 2 : 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :screen, :maze
|
|
33
|
+
|
|
34
|
+
def width_factor
|
|
35
|
+
screen.width / (maze.width * 2)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def height_factor
|
|
39
|
+
(screen.height - HUD_ROWS) / maze.height
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|