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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +46 -0
  4. data/app/components/board_component.rb +100 -0
  5. data/app/components/hud_component.rb +15 -0
  6. data/app/controllers/application_controller.rb +24 -0
  7. data/app/controllers/game_controller.rb +68 -0
  8. data/app/controllers/game_over_controller.rb +26 -0
  9. data/app/controllers/home_controller.rb +22 -0
  10. data/app/models/application_record.rb +7 -0
  11. data/app/models/high_score.rb +7 -0
  12. data/app/state/application_state.rb +6 -0
  13. data/app/state/game_over_state.rb +9 -0
  14. data/app/state/game_state.rb +9 -0
  15. data/app/state/home_state.rb +7 -0
  16. data/app/views/game/show_view.rb +32 -0
  17. data/app/views/game_over/show_view.rb +40 -0
  18. data/app/views/home/show_view.rb +42 -0
  19. data/app/views/layouts/application_layout.rb +34 -0
  20. data/config/database.rb +14 -0
  21. data/config/routes.rb +7 -0
  22. data/db/development.sqlite3 +0 -0
  23. data/db/migrate/20260719010011_create_high_scores.rb +11 -0
  24. data/db/schema.rb +20 -0
  25. data/db/seeds.rb +1 -0
  26. data/db/test.sqlite3 +0 -0
  27. data/exe/pacmanrb +7 -0
  28. data/lib/pacman/application.rb +13 -0
  29. data/lib/pacman/arcade/brains.rb +46 -0
  30. data/lib/pacman/arcade/direction.rb +19 -0
  31. data/lib/pacman/arcade/ghost.rb +72 -0
  32. data/lib/pacman/arcade/layouts.rb +28 -0
  33. data/lib/pacman/arcade/maze.rb +67 -0
  34. data/lib/pacman/arcade/mode_schedule.rb +52 -0
  35. data/lib/pacman/arcade/navigator.rb +36 -0
  36. data/lib/pacman/arcade/pellet_field.rb +39 -0
  37. data/lib/pacman/arcade/player.rb +42 -0
  38. data/lib/pacman/arcade/position.rb +15 -0
  39. data/lib/pacman/arcade/scoreboard.rb +43 -0
  40. data/lib/pacman/arcade/spawn.rb +8 -0
  41. data/lib/pacman/arcade/world.rb +169 -0
  42. data/lib/pacman/board_scale.rb +42 -0
  43. data/lib/pacman/sprites.rb +145 -0
  44. data/lib/pacman/version.rb +5 -0
  45. data/lib/pacman.rb +22 -0
  46. 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pacman
4
+ module Arcade
5
+ # Where a ghost lives: its starting cell and the corner it retreats to in scatter mode.
6
+ Spawn = Data.define(:start, :corner)
7
+ end
8
+ 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