generated from mwc/project_game
I am proud of my game. I was stuck on the ghost movement logic for a while. I am worried that my game might be unbabalancd in difficulty. this hasn't sparked many interests mainly because of how restrited it was here to make a game using specific imports. I have learned a couple new skills such as how to use some variables and new functions.
This commit is contained in:
0
E/Proposal.md
Normal file
0
E/Proposal.md
Normal file
393
game.py
Normal file
393
game.py
Normal file
@@ -0,0 +1,393 @@
|
||||
from ghosts.ghosts import create_ghosts, _trigger_game_over
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ================= CONFIG =================
|
||||
WIDTH = 40
|
||||
HEIGHT = 15
|
||||
MOVE_SPEED = 3
|
||||
GHOST_RELEASE_DELAY = 50
|
||||
CHASE_MEMORY = 30
|
||||
# =========================================
|
||||
|
||||
RAW_MAP = [
|
||||
"########################################",
|
||||
"#......................................#",
|
||||
"#.####.#####.#.##.#####.#####.#.##.#.###",
|
||||
"#............#............#........#.###",
|
||||
"#.#.####.###.#########.########.####.###",
|
||||
"#.#......#............................##",
|
||||
"#.######.#####################.######.##",
|
||||
"#.........####################........##",
|
||||
"#.#######.###GGGGGGGGGGGGG####.#########",
|
||||
"#.........###GGGGGGGGGGGGG####.........#",
|
||||
"#.####.#.########GGGGG###########.#.####",
|
||||
"#.#....#.#.............#........#...####",
|
||||
"#.#.####.###.######.####.#####.####.####",
|
||||
"#...................................####",
|
||||
"########################################",
|
||||
]
|
||||
|
||||
MAZE = RAW_MAP
|
||||
|
||||
DIRS = {
|
||||
"left": (-1, 0),
|
||||
"right": (1, 0),
|
||||
"up": (0, -1),
|
||||
"down": (0, 1),
|
||||
}
|
||||
|
||||
# ---------- Player start ----------
|
||||
for y in range(HEIGHT):
|
||||
for x in range(WIDTH):
|
||||
if MAZE[y][x] == ".":
|
||||
START_POS = (x, y)
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
|
||||
def trigger_game_over(game):
|
||||
# delegate to the ghosts module helper which handles headless vs UI
|
||||
# and schedules a short delay for the UI.
|
||||
try:
|
||||
_trigger_game_over(game)
|
||||
except Exception:
|
||||
# fallback: attempt immediate end
|
||||
if getattr(game, 'ended', None) is None:
|
||||
setattr(game, 'ended', True)
|
||||
try:
|
||||
game.end()
|
||||
except Exception:
|
||||
setattr(game, 'game_over', True)
|
||||
|
||||
|
||||
def add_points(game, pts):
|
||||
"""Add points to the game's canonical score storage.
|
||||
This updates common places where score may be kept so the
|
||||
final display always reads the same value.
|
||||
"""
|
||||
# primary: top-level attribute
|
||||
if hasattr(game, 'score'):
|
||||
try:
|
||||
game.score += pts
|
||||
except Exception:
|
||||
try:
|
||||
setattr(game, 'score', getattr(game, 'score', 0) + pts)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
setattr(game, 'score', getattr(game, 'score', 0) + pts)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# secondary: if the game keeps a state dict-like object, update that too
|
||||
st = getattr(game, 'state', None)
|
||||
if isinstance(st, dict):
|
||||
st['score'] = st.get('score', 0) + pts
|
||||
|
||||
|
||||
class PacMan:
|
||||
name = "pacman"
|
||||
character = "C"
|
||||
display = True
|
||||
z = 3
|
||||
|
||||
def __init__(self):
|
||||
self.position = START_POS
|
||||
self.prev_position = START_POS
|
||||
self.current_dir = None
|
||||
self.buffered_dir = None
|
||||
self.last_move = -999
|
||||
|
||||
def handle_keystroke(self, key, game):
|
||||
if game.ended:
|
||||
return
|
||||
# guard against non-key events
|
||||
if key is None:
|
||||
return
|
||||
|
||||
# extract a readable name from common key event shapes
|
||||
kn = None
|
||||
if isinstance(key, str):
|
||||
kn = key.lower()
|
||||
else:
|
||||
# prefer .char, then .name, then .key
|
||||
for attr in ('char', 'name', 'key'):
|
||||
val = getattr(key, attr, None)
|
||||
if val:
|
||||
kn = str(val).lower()
|
||||
break
|
||||
if not kn:
|
||||
return
|
||||
|
||||
# ignore arrow keys explicitly
|
||||
if any(x in kn for x in ('left', 'right', 'up', 'down', 'arrow')):
|
||||
return
|
||||
|
||||
# normalize to tokens and look for a single-letter wasd token
|
||||
import re
|
||||
tokens = [t for t in re.sub(r'[^a-z0-9]', '_', kn).split('_') if t]
|
||||
letter = None
|
||||
for t in tokens:
|
||||
if t in ('w', 'a', 's', 'd'):
|
||||
letter = t
|
||||
break
|
||||
# fallback: if kn itself is a single char and is wasd
|
||||
if letter is None and len(kn) == 1 and kn in ('w', 'a', 's', 'd'):
|
||||
letter = kn
|
||||
|
||||
if not letter:
|
||||
return
|
||||
|
||||
if letter == 'w':
|
||||
self.buffered_dir = 'up'
|
||||
elif letter == 'a':
|
||||
self.buffered_dir = 'left'
|
||||
elif letter == 's':
|
||||
self.buffered_dir = 'down'
|
||||
elif letter == 'd':
|
||||
self.buffered_dir = 'right'
|
||||
|
||||
def can_move(self, d):
|
||||
dx, dy = DIRS[d]
|
||||
x, y = self.position[0] + dx, self.position[1] + dy
|
||||
return 0 <= x < WIDTH and 0 <= y < HEIGHT and MAZE[y][x] != "#"
|
||||
|
||||
def play_turn(self, game):
|
||||
if game.ended:
|
||||
return
|
||||
|
||||
if game.turn_number - self.last_move < MOVE_SPEED:
|
||||
return
|
||||
|
||||
if self.buffered_dir and self.can_move(self.buffered_dir):
|
||||
self.current_dir = self.buffered_dir
|
||||
|
||||
if self.current_dir and self.can_move(self.current_dir):
|
||||
dx, dy = DIRS[self.current_dir]
|
||||
self.prev_position = self.position
|
||||
self.position = (self.position[0] + dx, self.position[1] + dy)
|
||||
self.last_move = game.turn_number
|
||||
|
||||
for agent in game.agents:
|
||||
if getattr(agent, "name", "").startswith("ghost_"):
|
||||
if agent.position == self.position:
|
||||
trigger_game_over(game)
|
||||
return
|
||||
|
||||
|
||||
class Wall:
|
||||
display = True
|
||||
character = "#"
|
||||
z = 0
|
||||
|
||||
def __init__(self, pos):
|
||||
self.position = pos
|
||||
self.name = f"wall_{pos}"
|
||||
|
||||
|
||||
class Pellet:
|
||||
display = True
|
||||
character = "."
|
||||
z = 1
|
||||
|
||||
def __init__(self, pos):
|
||||
self.position = pos
|
||||
self.name = f"pellet_{pos}"
|
||||
|
||||
def play_turn(self, game):
|
||||
if game.ended:
|
||||
return
|
||||
|
||||
pac = game.get_agent_by_name("pacman")
|
||||
if pac and pac.position == self.position:
|
||||
add_points(game, 10)
|
||||
game.remove_agent_by_name(self.name)
|
||||
# if there are no pellets left, we've collected them all -> max
|
||||
any_pellets = any(getattr(a, 'name', '').startswith('pellet_') for a in game.agents)
|
||||
if not any_pellets:
|
||||
setattr(game, 'maxed', True)
|
||||
# trigger the same game over flow as being caught by a ghost
|
||||
trigger_game_over(game)
|
||||
|
||||
|
||||
class GameOverWatcher:
|
||||
"""Watches for a scheduled game-over timestamp and calls end()
|
||||
once the wall-clock time has passed. This is a fallback if
|
||||
threading.Timer isn't available; normally _trigger_game_over
|
||||
will start a timer.
|
||||
"""
|
||||
name = "gameover_watcher"
|
||||
display = False
|
||||
|
||||
def play_turn(self, game):
|
||||
if getattr(game, 'ended', False):
|
||||
return
|
||||
if not getattr(game, 'game_over', False):
|
||||
return
|
||||
sched = getattr(game, '_game_over_scheduled', None)
|
||||
if sched is None:
|
||||
# no schedule found; call end immediately for safety
|
||||
try:
|
||||
game.end()
|
||||
except Exception:
|
||||
setattr(game, 'ended', True)
|
||||
return
|
||||
import time as _time
|
||||
if _time.time() >= sched:
|
||||
try:
|
||||
game.end()
|
||||
except Exception:
|
||||
setattr(game, 'ended', True)
|
||||
|
||||
|
||||
class GameOverlay:
|
||||
"""Create temporary high-z agents that draw a centered overlay text
|
||||
when `game.game_over` is set. The overlay stays in place until
|
||||
the game actually ends.
|
||||
"""
|
||||
name = "game_overlay"
|
||||
display = False
|
||||
|
||||
def __init__(self):
|
||||
self._created = False
|
||||
|
||||
def _make_char_agent(self, ch, pos):
|
||||
# simple per-char agent
|
||||
class _C:
|
||||
display = True
|
||||
z = 100
|
||||
def __init__(self, pos, ch):
|
||||
self.position = pos
|
||||
self.character = ch
|
||||
self.name = f"overlay_{pos[0]}_{pos[1]}"
|
||||
return _C(pos, ch)
|
||||
|
||||
def play_turn(self, game):
|
||||
# create overlay once when game_over is flagged
|
||||
if getattr(game, 'game_over', False) and not getattr(game, '_overlay_present', False):
|
||||
# build two lines
|
||||
w = getattr(game, 'board_size', (WIDTH, HEIGHT))[0] if hasattr(game, 'board_size') else WIDTH
|
||||
h = getattr(game, 'board_size', (WIDTH, HEIGHT))[1] if hasattr(game, 'board_size') else HEIGHT
|
||||
mx = " [max]" if getattr(game, 'maxed', False) else ""
|
||||
lines = ["GAME OVER", f"SCORE: {getattr(game, 'score', 0)}{mx}"]
|
||||
start_y = h//2 - len(lines)//2
|
||||
overlay_agents = []
|
||||
for i, line in enumerate(lines):
|
||||
y = start_y + i
|
||||
x0 = max(0, w//2 - len(line)//2)
|
||||
for j, ch in enumerate(line):
|
||||
pos = (x0 + j, y)
|
||||
a = self._make_char_agent(ch, pos)
|
||||
overlay_agents.append(a)
|
||||
# attach overlay agents to the game and mark presence
|
||||
game.agents.extend(overlay_agents)
|
||||
game._overlay_present = True
|
||||
game._overlay_agents = overlay_agents
|
||||
|
||||
# remove overlay agents once the game has ended
|
||||
if getattr(game, 'ended', False) and getattr(game, '_overlay_present', False):
|
||||
for a in list(getattr(game, '_overlay_agents', [])):
|
||||
try:
|
||||
# remove by name
|
||||
game.remove_agent_by_name(a.name)
|
||||
except Exception:
|
||||
try:
|
||||
game.agents.remove(a)
|
||||
except Exception:
|
||||
pass
|
||||
game._overlay_present = False
|
||||
game._overlay_agents = []
|
||||
|
||||
|
||||
# ---------- Build agents ----------
|
||||
agents = [PacMan()]
|
||||
ghost_spawns = []
|
||||
|
||||
for y, row in enumerate(MAZE):
|
||||
for x, ch in enumerate(row):
|
||||
if ch == "#":
|
||||
agents.append(Wall((x, y)))
|
||||
elif ch == ".":
|
||||
agents.append(Pellet((x, y)))
|
||||
elif ch == "G":
|
||||
ghost_spawns.append((x, y))
|
||||
|
||||
ghosts = create_ghosts(
|
||||
ghost_spawns,
|
||||
MAZE,
|
||||
DIRS,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
GHOST_RELEASE_DELAY,
|
||||
MOVE_SPEED + 1
|
||||
)
|
||||
|
||||
agents.extend(ghosts)
|
||||
|
||||
# watcher to transition from flagged game_over to final end() after a short delay
|
||||
agents.append(GameOverWatcher())
|
||||
agents.append(GameOverlay())
|
||||
|
||||
# ---------- Start ----------
|
||||
HEADLESS = os.environ.get("HEADLESS") == "1" or "--headless" in sys.argv
|
||||
|
||||
if HEADLESS:
|
||||
class HeadlessGame:
|
||||
def __init__(self, agents):
|
||||
self.agents = agents
|
||||
self.turn_number = 0
|
||||
self.score = 0
|
||||
self.ended = False
|
||||
|
||||
def get_agent_by_name(self, name):
|
||||
for a in self.agents:
|
||||
if getattr(a, "name", None) == name:
|
||||
return a
|
||||
return None
|
||||
|
||||
def remove_agent_by_name(self, name):
|
||||
self.agents = [a for a in self.agents if getattr(a, "name", None) != name]
|
||||
|
||||
def end(self):
|
||||
print("\n===== GAME OVER =====")
|
||||
mx = " [max]" if getattr(self, 'maxed', False) else ""
|
||||
print(f"Final Score: {self.score}{mx}")
|
||||
print("=====================")
|
||||
self.ended = True
|
||||
|
||||
def play(self, max_turns=500):
|
||||
while not self.ended and self.turn_number < max_turns:
|
||||
for agent in list(self.agents):
|
||||
if hasattr(agent, "play_turn"):
|
||||
agent.play_turn(self)
|
||||
self.turn_number += 1
|
||||
|
||||
HeadlessGame(agents).play()
|
||||
|
||||
else:
|
||||
# import the graphical runtime only when running with UI
|
||||
from retro.game import Game
|
||||
from retro.agent import ArrowKeyAgent
|
||||
|
||||
ArrowKeyAgent()
|
||||
game = Game(agents, {"score": 0}, board_size=(WIDTH, HEIGHT))
|
||||
game.score = 0
|
||||
game.ended = False
|
||||
|
||||
print("Pac-Man | Correct Ghost AI")
|
||||
game.play()
|
||||
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
print("\n" * 3)
|
||||
print("########################################")
|
||||
print("# #")
|
||||
print("# GAME OVER #")
|
||||
print("# #")
|
||||
mx = " [max]" if getattr(game, 'maxed', False) else ""
|
||||
print(f"# FINAL SCORE: {game.score:<6}{mx} #")
|
||||
print("# #")
|
||||
print("########################################")
|
||||
BIN
ghosts/__pycache__/ghosts.cpython-311.pyc
Normal file
BIN
ghosts/__pycache__/ghosts.cpython-311.pyc
Normal file
Binary file not shown.
468
ghosts/ghosts.py
Normal file
468
ghosts/ghosts.py
Normal file
@@ -0,0 +1,468 @@
|
||||
from collections import deque
|
||||
import time
|
||||
import heapq
|
||||
import random
|
||||
|
||||
# small randomness factor injected into ghost decision-making
|
||||
# (keeps behavior mostly deterministic/chasing but adds slight variation)
|
||||
RANDOMNESS = 0.12
|
||||
|
||||
class GhostManager:
|
||||
"""Tracks ghost instances and per-turn reservations to avoid collisions."""
|
||||
def __init__(self):
|
||||
self.instances = []
|
||||
self.reserved_turn = -1
|
||||
self.reserved = set()
|
||||
self.turn = -1
|
||||
|
||||
def register(self, g):
|
||||
self.instances.append(g)
|
||||
|
||||
def start_turn(self, turn):
|
||||
if self.reserved_turn != turn:
|
||||
self.reserved_turn = turn
|
||||
self.reserved.clear()
|
||||
self.turn = turn
|
||||
|
||||
def reserve(self, pos):
|
||||
self.reserved.add(pos)
|
||||
|
||||
def is_reserved(self, pos):
|
||||
return pos in self.reserved
|
||||
|
||||
def occupied_positions(self, exclude=None):
|
||||
return {g.position for g in self.instances if g is not exclude}
|
||||
|
||||
|
||||
# path search helpers
|
||||
|
||||
def bfs_find(maze, start, goal_test, on_board, blocked=None):
|
||||
if blocked is None:
|
||||
blocked = set()
|
||||
q = deque([start])
|
||||
prev = {start: None}
|
||||
while q:
|
||||
cur = q.popleft()
|
||||
if goal_test(cur):
|
||||
path = []
|
||||
node = cur
|
||||
while node is not None:
|
||||
path.append(node)
|
||||
node = prev[node]
|
||||
path.reverse()
|
||||
return path
|
||||
x, y = cur
|
||||
for dx, dy in ((1,0),(-1,0),(0,1),(0,-1)):
|
||||
nx, ny = x+dx, y+dy
|
||||
npos = (nx, ny)
|
||||
if npos in prev:
|
||||
continue
|
||||
if not on_board(npos):
|
||||
continue
|
||||
if maze[ny][nx] == '#':
|
||||
continue
|
||||
if npos in blocked:
|
||||
continue
|
||||
prev[npos] = cur
|
||||
q.append(npos)
|
||||
return None
|
||||
|
||||
|
||||
def astar_find(maze, start, goal, on_board, blocked=None):
|
||||
if blocked is None:
|
||||
blocked = set()
|
||||
def h(a,b):
|
||||
return abs(a[0]-b[0]) + abs(a[1]-b[1])
|
||||
openq = []
|
||||
heapq.heappush(openq, (h(start, goal), 0, start))
|
||||
came_from = {start: None}
|
||||
cost_so_far = {start: 0}
|
||||
while openq:
|
||||
_, cost, current = heapq.heappop(openq)
|
||||
if current == goal:
|
||||
path = []
|
||||
node = current
|
||||
while node is not None:
|
||||
path.append(node)
|
||||
node = came_from[node]
|
||||
path.reverse()
|
||||
return path
|
||||
x, y = current
|
||||
for dx, dy in ((1,0),(-1,0),(0,1),(0,-1)):
|
||||
nx, ny = x+dx, y+dy
|
||||
npos = (nx, ny)
|
||||
if not on_board(npos):
|
||||
continue
|
||||
if maze[ny][nx] == '#':
|
||||
continue
|
||||
if npos in blocked and npos != goal:
|
||||
continue
|
||||
new_cost = cost + 1
|
||||
if npos not in cost_so_far or new_cost < cost_so_far[npos]:
|
||||
cost_so_far[npos] = new_cost
|
||||
priority = new_cost + h(npos, goal)
|
||||
heapq.heappush(openq, (priority, new_cost, npos))
|
||||
came_from[npos] = current
|
||||
return None
|
||||
|
||||
|
||||
def _trigger_game_over(game):
|
||||
"""End the game in a way that's friendly to both headless and UI runs.
|
||||
HeadlessGame defines an `ended` attribute and an `end()` method we can call.
|
||||
In graphical runs we prefer to set a `game_over` flag so the main loop
|
||||
/ input handling stays active and the player can press a key to quit.
|
||||
"""
|
||||
# prefer headless behavior: if the object *looks* like HeadlessGame
|
||||
# (it exposes an `ended` attribute) then call end() immediately.
|
||||
if getattr(game, 'ended', None) is not None and callable(getattr(game, 'end', None)):
|
||||
try:
|
||||
game.end()
|
||||
return
|
||||
except Exception:
|
||||
# fall back to flagging if end() misbehaves
|
||||
pass
|
||||
|
||||
# otherwise, set a flag the UI can react to and avoid calling end()
|
||||
setattr(game, 'game_over', True)
|
||||
|
||||
# schedule a background timer to call end() after a short delay
|
||||
# so the UI has a small 'caught' moment before the final screen.
|
||||
if getattr(game, '_game_over_timer', None) is None:
|
||||
try:
|
||||
import threading
|
||||
|
||||
def _delayed_end():
|
||||
try:
|
||||
game.end()
|
||||
except Exception:
|
||||
setattr(game, 'ended', True)
|
||||
|
||||
t = threading.Timer(1.0, _delayed_end)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
setattr(game, '_game_over_timer', t)
|
||||
except Exception:
|
||||
# as a fallback, set a scheduled timestamp watched by GameOverWatcher
|
||||
if getattr(game, '_game_over_scheduled', None) is None:
|
||||
setattr(game, '_game_over_scheduled', time.time() + 1.0)
|
||||
|
||||
|
||||
class Ghost:
|
||||
# use a backing attribute and property so we can log unexpected
|
||||
# changes to the display flag (this helps trace flicker/invisibility)
|
||||
# keep the public API the same: `ghost.display` reads/writes still work.
|
||||
|
||||
def __init__(self, name, char, start_pos, release_turn, maze, dirs, width, height, move_speed, manager, chase_memory=30, detection_radius=6):
|
||||
self.name = name
|
||||
self.character = char
|
||||
# rendering order: higher z renders on top of lower z
|
||||
# ensure ghosts are drawn above pellets/walls so they don't appear to "go invisible"
|
||||
# rendering order: make ghosts above pellets/walls and PacMan to avoid occlusion
|
||||
# set to 4 so ghosts render on top of PacMan (PacMan.z == 3)
|
||||
self.z = 4
|
||||
# ensure instance-level display flag so renderers that check the
|
||||
# instance attribute won't accidentally inherit a wrong value
|
||||
# from other code paths
|
||||
# use a private backing field; set it directly to avoid logging during construction
|
||||
self._display = True
|
||||
self.position = start_pos
|
||||
self.release_turn = release_turn
|
||||
# default to released so ghosts start roaming immediately
|
||||
# (this makes them active from turn 0 instead of waiting for
|
||||
# a delayed release). If you want delayed release again later,
|
||||
# set release_turn to a positive value and change this flag.
|
||||
self.released = True
|
||||
self.current_dir = random.choice(list(dirs.keys()))
|
||||
self.last_move = -999
|
||||
self.chasing = False
|
||||
self.chase_until = -1
|
||||
# set prev_position to the starting position so renderers that
|
||||
# interpolate between previous/current positions can do so
|
||||
# even before the first move (helps smooth motion)
|
||||
self.prev_position = start_pos
|
||||
# once a ghost leaves the G-spawn area it should never re-enter
|
||||
self.left_spawn = False
|
||||
self.last_seen_pos = None
|
||||
self.last_seen_turn = -999
|
||||
self.ghost_type = name.split('_')[-1]
|
||||
|
||||
# references
|
||||
self.MAZE = maze
|
||||
self.DIRS = dirs
|
||||
self.WIDTH = width
|
||||
self.HEIGHT = height
|
||||
self.MOVE_SPEED = move_speed
|
||||
self.manager = manager
|
||||
self.chase_memory = chase_memory
|
||||
self.detection_radius = detection_radius
|
||||
|
||||
manager.register(self)
|
||||
|
||||
@property
|
||||
def display(self):
|
||||
return getattr(self, '_display', True)
|
||||
|
||||
@display.setter
|
||||
def display(self, val):
|
||||
old = getattr(self, '_display', True)
|
||||
if old != val:
|
||||
# try to include turn info if manager has it; fall back to None
|
||||
mgr_turn = getattr(self.manager, 'turn', None) if getattr(self, 'manager', None) is not None else None
|
||||
print(f"[Ghost][DEBUG] {self.name} display changed {old} -> {val} at manager.turn={mgr_turn}")
|
||||
self._display = val
|
||||
|
||||
def on_board(self, pos):
|
||||
x,y = pos
|
||||
return 0 <= x < self.WIDTH and 0 <= y < self.HEIGHT
|
||||
|
||||
def can_walk(self, pos):
|
||||
# ensure position is on board before indexing the maze
|
||||
if not self.on_board(pos):
|
||||
return False
|
||||
x, y = pos
|
||||
# prevent re-entering the spawn area (G) after we've left it
|
||||
if self.MAZE[y][x] == 'G' and self.left_spawn:
|
||||
return False
|
||||
return self.MAZE[y][x] != '#'
|
||||
|
||||
def is_spawn(self):
|
||||
x,y = self.position
|
||||
return self.MAZE[y][x] == 'G'
|
||||
|
||||
def line_of_sight(self, pac_pos):
|
||||
gx, gy = self.position
|
||||
px, py = pac_pos
|
||||
if gx == px:
|
||||
step = 1 if py > gy else -1
|
||||
for y in range(gy + step, py, step):
|
||||
if self.MAZE[y][gx] == '#':
|
||||
return False
|
||||
return True
|
||||
if gy == py:
|
||||
step = 1 if px > gx else -1
|
||||
for x in range(gx + step, px, step):
|
||||
if self.MAZE[gy][x] == '#':
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
def neighbors(self, pos):
|
||||
x,y = pos
|
||||
for dx, dy in self.DIRS.values():
|
||||
yield (x+dx, y+dy)
|
||||
|
||||
def next_pos(self, dir_key):
|
||||
"""Return next position from current position following direction key."""
|
||||
if dir_key not in self.DIRS:
|
||||
return None
|
||||
dx, dy = self.DIRS[dir_key]
|
||||
return (self.position[0] + dx, self.position[1] + dy)
|
||||
|
||||
def play_turn(self, game):
|
||||
# start turn bookkeeping
|
||||
self.manager.start_turn(game.turn_number)
|
||||
|
||||
# if the UI/game has already flagged game_over, don't take actions
|
||||
if getattr(game, 'game_over', False):
|
||||
return
|
||||
|
||||
if game.turn_number - self.last_move < self.MOVE_SPEED:
|
||||
return
|
||||
|
||||
if not self.released:
|
||||
if game.turn_number >= self.release_turn:
|
||||
self.released = True
|
||||
else:
|
||||
return
|
||||
|
||||
pac = game.get_agent_by_name('pacman')
|
||||
if not pac:
|
||||
return
|
||||
|
||||
# occupied and blocked positions
|
||||
occupied = self.manager.occupied_positions(exclude=self)
|
||||
# avoid swapping: treat other ghosts' previous positions as blocked for this turn
|
||||
swap_block = {g.prev_position for g in self.manager.instances if g is not self and getattr(g, 'prev_position', None) is not None}
|
||||
blocked = set(occupied) | set(self.manager.reserved) | set(swap_block)
|
||||
|
||||
# inside spawn: try to exit quickly
|
||||
if self.is_spawn():
|
||||
path = bfs_find(self.MAZE, self.position, lambda p: self.MAZE[p[1]][p[0]] != 'G', self.on_board, blocked)
|
||||
if path and len(path) > 1:
|
||||
move = path[1]
|
||||
if move not in blocked:
|
||||
self.prev_position = self.position
|
||||
self.position = move
|
||||
# once we've moved out of a G tile, lock spawn re-entry
|
||||
if not self.is_spawn():
|
||||
self.left_spawn = True
|
||||
self.last_move = game.turn_number
|
||||
self.manager.reserve(self.position)
|
||||
if pac.position == self.position:
|
||||
# end the game; UI will display a generic 'Game Over'
|
||||
_trigger_game_over(game)
|
||||
return
|
||||
|
||||
# sensing
|
||||
los = self.line_of_sight(pac.position)
|
||||
if los:
|
||||
self.last_seen_pos = pac.position
|
||||
self.last_seen_turn = game.turn_number
|
||||
self.chasing = True
|
||||
self.chase_until = game.turn_number + self.chase_memory
|
||||
|
||||
if self.chasing and game.turn_number > self.chase_until:
|
||||
self.chasing = False
|
||||
|
||||
seen_recently = (game.turn_number - self.last_seen_turn) <= self.chase_memory
|
||||
within_radius = (abs(self.position[0]-pac.position[0]) + abs(self.position[1]-pac.position[1])) <= self.detection_radius
|
||||
|
||||
target = None
|
||||
if self.chasing or seen_recently or within_radius:
|
||||
if self.ghost_type == 'red':
|
||||
target = pac.position
|
||||
elif self.ghost_type == 'pink':
|
||||
if pac.current_dir and pac.current_dir in self.DIRS:
|
||||
dx, dy = self.DIRS[pac.current_dir]
|
||||
tx = pac.position[0] + dx*2
|
||||
ty = pac.position[1] + dy*2
|
||||
tx = max(0, min(self.WIDTH-1, tx))
|
||||
ty = max(0, min(self.HEIGHT-1, ty))
|
||||
target = (tx, ty)
|
||||
else:
|
||||
target = pac.position
|
||||
elif self.ghost_type == 'blue':
|
||||
red = next((g for g in self.manager.instances if g.ghost_type == 'red'), None)
|
||||
if red and pac.current_dir and pac.current_dir in self.DIRS:
|
||||
dx, dy = self.DIRS[pac.current_dir]
|
||||
ahead = (pac.position[0] + dx*2, pac.position[1] + dy*2)
|
||||
tx = ahead[0]*2 - red.position[0]
|
||||
ty = ahead[1]*2 - red.position[1]
|
||||
tx = max(0, min(self.WIDTH-1, tx))
|
||||
ty = max(0, min(self.HEIGHT-1, ty))
|
||||
target = (tx, ty)
|
||||
else:
|
||||
# unpredictable fallback
|
||||
if random.random() < 0.5:
|
||||
# pick random pellet or nearby tile
|
||||
tx = pac.position[0] + random.randint(-3,3)
|
||||
ty = pac.position[1] + random.randint(-3,3)
|
||||
tx = max(0, min(self.WIDTH-1, tx))
|
||||
ty = max(0, min(self.HEIGHT-1, ty))
|
||||
target = (tx, ty)
|
||||
else:
|
||||
target = pac.position
|
||||
|
||||
# add a tiny bit of randomness — occasionally ignore the target
|
||||
# so ghosts feel less deterministic
|
||||
if target and random.random() < RANDOMNESS:
|
||||
target = None
|
||||
|
||||
if target:
|
||||
# allow moving into pac position
|
||||
blocked_for_path = set(blocked)
|
||||
if pac.position in blocked_for_path:
|
||||
blocked_for_path.remove(pac.position)
|
||||
path = astar_find(self.MAZE, self.position, target, self.on_board, blocked_for_path)
|
||||
if path and len(path) > 1:
|
||||
next_pos = path[1]
|
||||
# avoid reversing
|
||||
if self.prev_position and next_pos == self.prev_position:
|
||||
# try alternative neighbors from the path (prefer non-reversing)
|
||||
alts = [p for p in path[2:6] if p != self.prev_position and p not in blocked]
|
||||
if alts:
|
||||
next_pos = random.choice(alts)
|
||||
if next_pos not in blocked:
|
||||
self.prev_position = self.position
|
||||
self.position = next_pos
|
||||
# lock spawn re-entry if we left
|
||||
if not self.is_spawn():
|
||||
self.left_spawn = True
|
||||
self.last_move = game.turn_number
|
||||
self.manager.reserve(self.position)
|
||||
if pac.position == self.position:
|
||||
_trigger_game_over(game)
|
||||
return
|
||||
|
||||
# fallback movement: try forward, else other options (avoid reverse)
|
||||
forward = None
|
||||
if self.current_dir in self.DIRS:
|
||||
dx, dy = self.DIRS[self.current_dir]
|
||||
forward = (self.position[0]+dx, self.position[1]+dy)
|
||||
if forward and self.can_walk(forward) and forward not in blocked:
|
||||
if self.prev_position and forward == self.prev_position:
|
||||
# pick alternative
|
||||
# build neighbor options from direction keys (safe next_pos helper)
|
||||
options = [p for p in (self.next_pos(d) for d in self.DIRS) if p is not None and self.can_walk(p) and p not in blocked and p != self.prev_position]
|
||||
if options:
|
||||
forward = random.choice(options)
|
||||
self.prev_position = self.position
|
||||
self.position = forward
|
||||
# lock spawn re-entry if we left
|
||||
if not self.is_spawn():
|
||||
self.left_spawn = True
|
||||
self.last_move = game.turn_number
|
||||
self.manager.reserve(self.position)
|
||||
if pac.position == self.position:
|
||||
_trigger_game_over(game)
|
||||
return
|
||||
|
||||
options = [p for d in self.DIRS for p in [ (self.position[0]+self.DIRS[d][0], self.position[1]+self.DIRS[d][1]) ] if self.can_walk(p) and p not in blocked and p != self.prev_position]
|
||||
if options:
|
||||
chosen = random.choice(options)
|
||||
self.prev_position = self.position
|
||||
self.position = chosen
|
||||
if not self.is_spawn():
|
||||
self.left_spawn = True
|
||||
self.last_move = game.turn_number
|
||||
self.manager.reserve(self.position)
|
||||
if pac.position == self.position:
|
||||
_trigger_game_over(game)
|
||||
return
|
||||
|
||||
# If we reached here, the ghost had no non-blocked options. To keep
|
||||
# ghosts constantly roaming the map, try progressively weaker
|
||||
# fallbacks instead of staying still:
|
||||
# 1) try any walkable neighbor ignoring reservations (but avoid immediate reverse)
|
||||
options_relaxed = [p for d in self.DIRS for p in [ (self.position[0]+self.DIRS[d][0], self.position[1]+self.DIRS[d][1]) ] if self.can_walk(p) and p != self.prev_position]
|
||||
if options_relaxed:
|
||||
chosen = random.choice(options_relaxed)
|
||||
self.prev_position = self.position
|
||||
self.position = chosen
|
||||
if not self.is_spawn():
|
||||
self.left_spawn = True
|
||||
self.last_move = game.turn_number
|
||||
# still reserve our new position so others have a hint
|
||||
self.manager.reserve(self.position)
|
||||
if pac.position == self.position:
|
||||
_trigger_game_over(game)
|
||||
return
|
||||
|
||||
# 2) as a last resort, allow reversing back to previous position so the ghost doesn't stall
|
||||
if self.prev_position and self.can_walk(self.prev_position):
|
||||
rev = self.prev_position
|
||||
self.prev_position = self.position
|
||||
self.position = rev
|
||||
if not self.is_spawn():
|
||||
self.left_spawn = True
|
||||
self.last_move = game.turn_number
|
||||
self.manager.reserve(self.position)
|
||||
if pac.position == self.position:
|
||||
_trigger_game_over(game)
|
||||
return
|
||||
|
||||
|
||||
def create_ghosts(spawn_points, maze, dirs, width, height, ghost_release_delay, move_speed):
|
||||
manager = GhostManager()
|
||||
ghosts = []
|
||||
# choose three distinct spawn points if possible
|
||||
unique = list(dict.fromkeys(spawn_points))
|
||||
if len(unique) < 3:
|
||||
while len(unique) < 3:
|
||||
unique.append(unique[-1])
|
||||
s0, s1, s2 = unique[0], unique[len(unique)//2], unique[-1]
|
||||
|
||||
ghosts.append(Ghost('ghost_red', 'R', s0, 0, maze, dirs, width, height, move_speed, manager))
|
||||
ghosts.append(Ghost('ghost_blue', 'B', s1, ghost_release_delay, maze, dirs, width, height, move_speed, manager))
|
||||
ghosts.append(Ghost('ghost_pink', 'P', s2, ghost_release_delay*2, maze, dirs, width, height, move_speed, manager))
|
||||
return ghosts
|
||||
36
poetry.lock
generated
36
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "ansicon"
|
||||
@@ -15,21 +15,23 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "blessed"
|
||||
version = "1.20.0"
|
||||
version = "1.25.0"
|
||||
description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities."
|
||||
optional = false
|
||||
python-versions = ">=2.7"
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "blessed-1.20.0-py2.py3-none-any.whl", hash = "sha256:0c542922586a265e699188e52d5f5ac5ec0dd517e5a1041d90d2bbf23f906058"},
|
||||
{file = "blessed-1.20.0.tar.gz", hash = "sha256:2cdd67f8746e048f00df47a2880f4d6acbcdb399031b604e34ba8f71d5787680"},
|
||||
{file = "blessed-1.25.0-py3-none-any.whl", hash = "sha256:e52b9f778b9e10c30b3f17f6b5f5d2208d1e9b53b270f1d94fc61a243fc4708f"},
|
||||
{file = "blessed-1.25.0.tar.gz", hash = "sha256:606aebfea69f85915c7ca6a96eb028e0031d30feccc5688e13fd5cec8277b28d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""}
|
||||
six = ">=1.9.0"
|
||||
wcwidth = ">=0.1.4"
|
||||
|
||||
[package.extras]
|
||||
docs = ["Pillow", "Sphinx (>3)", "sphinx-paramlinks", "sphinx_rtd_theme", "sphinxcontrib-manpage"]
|
||||
|
||||
[[package]]
|
||||
name = "jinxed"
|
||||
version = "1.3.0"
|
||||
@@ -61,31 +63,19 @@ files = [
|
||||
[package.dependencies]
|
||||
blessed = ">=1.20.0,<2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
description = "Python 2 and 3 compatibility utilities"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
|
||||
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.13"
|
||||
version = "0.2.14"
|
||||
description = "Measures the displayed width of unicode strings in a terminal"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"},
|
||||
{file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
|
||||
{file = "wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"},
|
||||
{file = "wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"},
|
||||
]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<4.0"
|
||||
content-hash = "03cc38c17964eb2c920ecf014cbfcf966c0c719418a127947b33382f086a0a6e"
|
||||
content-hash = "609c277eba88c4596adefc15c413c02733f0ae22a13b8742df3e77c0c52b2db3"
|
||||
|
||||
16
proposal.md
16
proposal.md
@@ -8,20 +8,18 @@
|
||||
The name of my game will be
|
||||
|
||||
## Descirbe my version of my game
|
||||
my game will be monopoly with countries cities as properties, it should have very simple graphics and easy to code
|
||||
with print statements and more it should be fun if I add an AI to play against or something if I can.
|
||||
my game will be pac-man, the ghosts will be G's and they will try to attack you while you grab tiny particles for points
|
||||
and big particles to eat the ghosts for a limited amount of time for even more points.
|
||||
|
||||
## Core Mechanics
|
||||
it should have these core mechanics
|
||||
-selling properties
|
||||
-buying properties
|
||||
-houses (3 houses --> hotel)
|
||||
-A very simple trade option
|
||||
I have about 6 weeks to work on this so i heavily belive I can make these work with using my recources and my
|
||||
little knowledge and help from the teacher/AI at points.
|
||||
- walking to eat particles
|
||||
- point system
|
||||
- high score system
|
||||
- eating ghosts/particles for more points.
|
||||
|
||||
## Milestone(s)
|
||||
My first step will be making the board and spaces, the next step will make spaces into properties, and the final step would be trading and polishing.
|
||||
My first step will be making the map and then ghosts, the particles, then map selections and AI pathfinding
|
||||
|
||||
## Challenges
|
||||
I don't know what specific challenges I could face through this, but I don't think It will be easy either way.
|
||||
@@ -1,21 +1,27 @@
|
||||
[project]
|
||||
name = "project-game"
|
||||
name = "Pac-Nom"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
description = "Eat all the pellets and don't get caught"
|
||||
authors = [
|
||||
{name = "Chris Proctor",email = "chris@chrisproctor.net"}
|
||||
{name="Jacob Bayati", email="jacobbayat28@lockportschools.net"}
|
||||
]
|
||||
license = {text = "MIT"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<4.0"
|
||||
dependencies = [
|
||||
"retro-games (>=1.1.0,<2.0.0)"
|
||||
"retro-games>=1.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
play = "game.py"
|
||||
|
||||
[tool.retro]
|
||||
author = "Jacob"
|
||||
description = "Eat all the pellets and do not get caught. if you get all of them you win!"
|
||||
instructions = "Joystick for up,down,left and right"
|
||||
result_file = "result.json"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
package-mode = false
|
||||
package-mode = false
|
||||
Reference in New Issue
Block a user