1018 lines
39 KiB
Python
1018 lines
39 KiB
Python
"""
|
|
Pixel Village JRPG
|
|
A cozy little JRPG with town, forest, and turn-based combat!
|
|
|
|
Requirements: pip install pygame
|
|
Run: python jrpg_game.py
|
|
|
|
Controls:
|
|
Arrow Keys - Move
|
|
E / Space - Interact / Confirm
|
|
Escape - Cancel / Back
|
|
"""
|
|
|
|
import pygame
|
|
import sys
|
|
import random
|
|
import math
|
|
|
|
pygame.init()
|
|
|
|
# ── Constants ──────────────────────────────────────────────────────────────
|
|
W, H = 640, 480
|
|
TILE = 32
|
|
FPS = 60
|
|
|
|
# Bright pixel palette
|
|
C = {
|
|
"bg": (20, 20, 35),
|
|
"sky": (100, 180, 255),
|
|
"grass": (80, 200, 80),
|
|
"dark_grass":(60, 160, 60),
|
|
"path": (210, 180, 130),
|
|
"water": (60, 160, 220),
|
|
"tree": (40, 140, 40),
|
|
"tree_top": (50, 200, 60),
|
|
"trunk": (140, 90, 40),
|
|
"house_w": (240, 220, 200),
|
|
"house_r": (220, 60, 60),
|
|
"store_w": (200, 220, 255),
|
|
"store_r": (60, 100, 220),
|
|
"door": (140, 80, 30),
|
|
"window": (180, 230, 255),
|
|
"sign": (240, 200, 100),
|
|
"white": (255, 255, 255),
|
|
"black": (0, 0, 0),
|
|
"gray": (160, 160, 160),
|
|
"dark": (30, 30, 50),
|
|
"panel": (20, 20, 50),
|
|
"panel2": (40, 40, 80),
|
|
"border": (120, 100, 200),
|
|
"gold": (255, 210, 50),
|
|
"red": (240, 60, 60),
|
|
"green": (80, 220, 100),
|
|
"blue": (80, 160, 240),
|
|
"pink": (255, 140, 200),
|
|
"purple": (180, 100, 255),
|
|
"yellow": (255, 240, 80),
|
|
"orange": (255, 160, 40),
|
|
"hp_red": (220, 50, 50),
|
|
"mp_blue": (50, 100, 220),
|
|
"xp_green": (50, 200, 80),
|
|
}
|
|
|
|
screen = pygame.display.set_mode((W, H), pygame.SCALED | pygame.RESIZABLE)
|
|
pygame.display.set_caption("Pixel Village JRPG")
|
|
clock = pygame.time.Clock()
|
|
|
|
# Fonts
|
|
try:
|
|
font_lg = pygame.font.SysFont("couriernew", 22, bold=True)
|
|
font_md = pygame.font.SysFont("couriernew", 16, bold=True)
|
|
font_sm = pygame.font.SysFont("couriernew", 13)
|
|
except:
|
|
font_lg = pygame.font.SysFont(None, 26)
|
|
font_md = pygame.font.SysFont(None, 18)
|
|
font_sm = pygame.font.SysFont(None, 14)
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────────
|
|
def txt(surface, text, x, y, color, fnt=None):
|
|
fnt = fnt or font_md
|
|
surface.blit(fnt.render(str(text), True, color), (x, y))
|
|
|
|
def draw_bar(surf, x, y, w, h, val, mx, color, bg=(60,60,60)):
|
|
pygame.draw.rect(surf, bg, (x, y, w, h))
|
|
fill = int(w * max(0, val) / max(1, mx))
|
|
pygame.draw.rect(surf, color, (x, y, fill, h))
|
|
pygame.draw.rect(surf, C["white"], (x, y, w, h), 1)
|
|
|
|
def panel(surf, x, y, w, h, border=True):
|
|
pygame.draw.rect(surf, C["panel"], (x, y, w, h))
|
|
if border:
|
|
pygame.draw.rect(surf, C["border"], (x, y, w, h), 2)
|
|
|
|
def flash_color(base, t, amp=40):
|
|
v = int(amp * abs(math.sin(t * 4)))
|
|
return tuple(min(255, c + v) for c in base)
|
|
|
|
|
|
# ── Pixel Art Drawing Helpers ──────────────────────────────────────────────
|
|
def draw_tree(surf, x, y):
|
|
pygame.draw.rect(surf, C["trunk"], (x+12, y+28, 8, 12))
|
|
pygame.draw.polygon(surf, C["tree"], [(x+16,y),(x+2,y+28),(x+30,y+28)])
|
|
pygame.draw.polygon(surf, C["tree_top"], [(x+16,y+4),(x+4,y+24),(x+28,y+24)])
|
|
|
|
def draw_house(surf, x, y, color_w=None, color_r=None):
|
|
cw = color_w or C["house_w"]
|
|
cr = color_r or C["house_r"]
|
|
pygame.draw.rect(surf, cw, (x, y+16, 48, 32))
|
|
pygame.draw.polygon(surf, cr, [(x,y+16),(x+24,y),(x+48,y+16)])
|
|
pygame.draw.rect(surf, C["door"], (x+18, y+32, 12, 16))
|
|
pygame.draw.rect(surf, C["window"], (x+4, y+22, 10, 10))
|
|
pygame.draw.rect(surf, C["window"], (x+34, y+22, 10, 10))
|
|
|
|
def draw_store(surf, x, y):
|
|
draw_house(surf, x, y, C["store_w"], C["store_r"])
|
|
# sign
|
|
pygame.draw.rect(surf, C["sign"], (x+8, y+12, 32, 8))
|
|
txt(surf, "SHOP", x+9, y+12, C["dark"], font_sm)
|
|
|
|
def draw_player(surf, x, y, frame=0, facing=0):
|
|
# Simple cute character
|
|
bob = int(math.sin(frame * 0.3) * 2)
|
|
bx, by = x, y + bob
|
|
# body
|
|
pygame.draw.rect(surf, C["blue"], (bx+5, by+10, 12, 12))
|
|
# head
|
|
pygame.draw.rect(surf, (255,210,170), (bx+4, by+2, 14, 12))
|
|
# hair
|
|
pygame.draw.rect(surf, C["orange"], (bx+4, by+2, 14, 5))
|
|
# eyes
|
|
ex = bx+7 if facing != 2 else bx+11
|
|
pygame.draw.rect(surf, C["dark"], (ex, by+6, 2, 2))
|
|
pygame.draw.rect(surf, C["dark"], (ex+4, by+6, 2, 2))
|
|
# legs
|
|
lx = bx+5 + (2 if frame % 20 < 10 else -1)
|
|
pygame.draw.rect(surf, C["dark"], (lx, by+22, 4, 6))
|
|
pygame.draw.rect(surf, C["dark"], (lx+6, by+22, 4, 6))
|
|
|
|
def draw_npc_mitsuha(surf, x, y, frame=0):
|
|
bob = int(math.sin(frame * 0.2) * 1.5)
|
|
bx, by = x, y + bob
|
|
pygame.draw.rect(surf, C["pink"], (bx+5, by+10, 12, 12))
|
|
pygame.draw.rect(surf, (255,210,170), (bx+4, by+2, 14, 12))
|
|
pygame.draw.rect(surf, C["purple"], (bx+4, by+2, 14, 5))
|
|
# ponytail
|
|
pygame.draw.rect(surf, C["purple"], (bx+14, by+4, 4, 10))
|
|
pygame.draw.rect(surf, C["dark"], (bx+7, by+6, 2, 2))
|
|
pygame.draw.rect(surf, C["dark"], (bx+11, by+6, 2, 2))
|
|
pygame.draw.rect(surf, C["pink"], (bx+5, by+22, 4, 6))
|
|
pygame.draw.rect(surf, C["pink"], (bx+11, by+22, 4, 6))
|
|
|
|
def draw_slime(surf, x, y, frame=0, color=None):
|
|
c = color or C["green"]
|
|
bounce = int(abs(math.sin(frame * 0.15)) * 3)
|
|
bx, by = x, y - bounce
|
|
# body
|
|
pygame.draw.ellipse(surf, c, (bx+2, by+10, 20, 16))
|
|
pygame.draw.ellipse(surf, tuple(min(255,v+40) for v in c), (bx+4, by+10, 16, 10))
|
|
# eyes
|
|
pygame.draw.rect(surf, C["white"], (bx+6, by+13, 4, 4))
|
|
pygame.draw.rect(surf, C["white"], (bx+14, by+13, 4, 4))
|
|
pygame.draw.rect(surf, C["dark"], (bx+7, by+14, 2, 2))
|
|
pygame.draw.rect(surf, C["dark"], (bx+15, by+14, 2, 2))
|
|
|
|
def draw_fairy(surf, x, y, frame=0):
|
|
bob = int(math.sin(frame * 0.2) * 4)
|
|
bx, by = x, y + bob
|
|
# wings
|
|
wc = (200, 220, 255, 180)
|
|
wing_surf = pygame.Surface((24, 14), pygame.SRCALPHA)
|
|
pygame.draw.ellipse(wing_surf, (200,220,255,160), (0,0,10,10))
|
|
pygame.draw.ellipse(wing_surf, (200,220,255,160), (14,2,10,8))
|
|
surf.blit(wing_surf, (bx+4, by+8))
|
|
# body
|
|
pygame.draw.circle(surf, C["yellow"], (bx+12, by+12), 7)
|
|
pygame.draw.circle(surf, C["white"], (bx+12, by+12), 5)
|
|
pygame.draw.rect(surf, C["dark"], (bx+10, by+10, 2, 2))
|
|
pygame.draw.rect(surf, C["dark"], (bx+13, by+10, 2, 2))
|
|
|
|
|
|
# ── Game Data ──────────────────────────────────────────────────────────────
|
|
ITEMS = {
|
|
"Potion": {"desc": "Restores 30 HP", "price": 50, "effect": ("hp", 30)},
|
|
"Hi-Potion": {"desc": "Restores 80 HP", "price": 120, "effect": ("hp", 80)},
|
|
"Ether": {"desc": "Restores 20 MP", "price": 60, "effect": ("mp", 20)},
|
|
"Elixir": {"desc": "Restores all HP", "price": 300, "effect": ("hp", 9999)},
|
|
}
|
|
|
|
MONSTERS = [
|
|
{"name": "Green Slime", "hp": 20, "mp": 0, "atk": 5, "def": 2, "xp": 10, "gold": 8, "color": C["green"]},
|
|
{"name": "Blue Slime", "hp": 28, "mp": 0, "atk": 6, "def": 3, "xp": 14, "gold": 10, "color": C["blue"]},
|
|
{"name": "Pink Fairy", "hp": 18, "mp": 15, "atk": 8, "def": 1, "xp": 18, "gold": 14, "color": C["pink"]},
|
|
{"name": "Forest Wisp", "hp": 35, "mp": 20, "atk": 10, "def": 4, "xp": 25, "gold": 20, "color": C["purple"]},
|
|
]
|
|
|
|
MITSUHA_LINES = [
|
|
"Hi there! Beautiful day, isn't it?",
|
|
"I heard there are cute little\nmonsters in the forest!",
|
|
"Be careful out there, okay?\nBut also have fun!",
|
|
"The shop has great potions~",
|
|
"I love living in this village!",
|
|
"Oh! You look stronger today!",
|
|
]
|
|
|
|
HOME_LINES = [
|
|
"A cozy home. You feel rested!",
|
|
"Sweet dreams are made of these~",
|
|
"Home sweet home!",
|
|
]
|
|
|
|
# ── Player ─────────────────────────────────────────────────────────────────
|
|
class Player:
|
|
def __init__(self):
|
|
self.name = "Hero"
|
|
self.x, self.y = 5, 6
|
|
self.hp, self.max_hp = 80, 80
|
|
self.mp, self.max_mp = 30, 30
|
|
self.atk = 15
|
|
self.dfn = 5
|
|
self.xp, self.xp_next = 0, 50
|
|
self.level = 1
|
|
self.gold = 100
|
|
self.inventory = {"Potion": 2}
|
|
self.frame = 0
|
|
self.facing = 1 # 0=up 1=down 2=left 3=right
|
|
self.px = self.x * TILE # pixel x for smooth movement
|
|
self.py = self.y * TILE
|
|
|
|
def gain_xp(self, amount):
|
|
msgs = []
|
|
self.xp += amount
|
|
while self.xp >= self.xp_next:
|
|
self.xp -= self.xp_next
|
|
self.level += 1
|
|
self.xp_next = int(self.xp_next * 1.4)
|
|
self.max_hp += 15
|
|
self.hp = self.max_hp
|
|
self.max_mp += 5
|
|
self.mp = self.max_mp
|
|
self.atk += 3
|
|
self.dfn += 1
|
|
msgs.append(f"Level UP! Now Lv.{self.level}!")
|
|
return msgs
|
|
|
|
def add_item(self, item):
|
|
self.inventory[item] = self.inventory.get(item, 0) + 1
|
|
|
|
def use_item(self, item):
|
|
if self.inventory.get(item, 0) <= 0:
|
|
return False, "No items left!"
|
|
eff = ITEMS[item]["effect"]
|
|
self.inventory[item] -= 1
|
|
if self.inventory[item] == 0:
|
|
del self.inventory[item]
|
|
if eff[0] == "hp":
|
|
healed = min(eff[1], self.max_hp - self.hp)
|
|
self.hp = min(self.max_hp, self.hp + eff[1])
|
|
return True, f"Restored {healed} HP!"
|
|
elif eff[0] == "mp":
|
|
self.mp = min(self.max_mp, self.mp + eff[1])
|
|
return True, f"Restored {eff[1]} MP!"
|
|
return False, "Nothing happened."
|
|
|
|
|
|
# ── Maps ───────────────────────────────────────────────────────────────────
|
|
# T=town, F=forest
|
|
TOWN_MAP = [
|
|
"WWWWWWWWWWWWWWWWWWWW",
|
|
"W..................W",
|
|
"W..H.......S.....tW",
|
|
"W..................W",
|
|
"W...........ttt...W",
|
|
"W....t............W",
|
|
"W..................W",
|
|
"W....P.....M......W",
|
|
"W..................W",
|
|
"W..........t......W",
|
|
"W...##.....t......W",
|
|
"W...##............W",
|
|
"W.................W",
|
|
"WWWWWWWWWFWWWWWWWWW",
|
|
]
|
|
FOREST_MAP = [
|
|
"TTTTTTTTTTTTTTTTTTTT",
|
|
"T.t....t....t.....T",
|
|
"T....t.....t......T",
|
|
"T.t........t....t.T",
|
|
"T.....t...........T",
|
|
"T....t...t........T",
|
|
"T.........t.......T",
|
|
"T...t.............T",
|
|
"T....t....t.......T",
|
|
"T.................T",
|
|
"T.....t...........T",
|
|
"T.....t...........T",
|
|
"T.................T",
|
|
"TTTTTTTTTTTTTTTTTTTT",
|
|
]
|
|
# W=wall, H=home, S=store, t=tree, #=water, P=player start, M=Mitsuha, F=forest entrance, T=town exit
|
|
|
|
|
|
# ── Camera ─────────────────────────────────────────────────────────────────
|
|
class Camera:
|
|
def __init__(self):
|
|
self.ox = 0
|
|
self.oy = 0
|
|
|
|
def update(self, px, py, map_w, map_h):
|
|
self.ox = px - W // 2 + TILE // 2
|
|
self.oy = py - H // 2 + TILE // 2
|
|
self.ox = max(0, min(self.ox, map_w * TILE - W))
|
|
self.oy = max(0, min(self.oy, map_h * TILE - H))
|
|
|
|
def world_to_screen(self, wx, wy):
|
|
return wx - self.ox, wy - self.oy
|
|
|
|
|
|
# ── Battle System ──────────────────────────────────────────────────────────
|
|
class Battle:
|
|
def __init__(self, player, monster_data):
|
|
self.player = player
|
|
self.mon = dict(monster_data)
|
|
self.mon_hp = self.mon["hp"]
|
|
self.mon_max_hp = self.mon["hp"]
|
|
self.state = "player_turn" # player_turn, enemy_turn, win, lose
|
|
self.log = [f"A {self.mon['name']} appeared!"]
|
|
self.frame = 0
|
|
self.anim = None # ("shake_player"/"shake_enemy", timer)
|
|
self.selected = 0 # menu cursor
|
|
self.menu = "main" # main, items
|
|
self.item_list = []
|
|
self.shake_player = 0
|
|
self.shake_enemy = 0
|
|
self.result_msgs = []
|
|
|
|
def add_log(self, msg):
|
|
self.log.append(msg)
|
|
if len(self.log) > 4:
|
|
self.log.pop(0)
|
|
|
|
def player_attack(self):
|
|
dmg = max(1, self.player.atk - self.mon["def"] + random.randint(-3, 3))
|
|
self.mon_hp -= dmg
|
|
self.add_log(f"You dealt {dmg} damage!")
|
|
self.shake_enemy = 10
|
|
if self.mon_hp <= 0:
|
|
self.mon_hp = 0
|
|
self.end_battle(True)
|
|
else:
|
|
self.state = "enemy_turn"
|
|
|
|
def player_magic(self):
|
|
if self.player.mp < 8:
|
|
self.add_log("Not enough MP!")
|
|
return
|
|
self.player.mp -= 8
|
|
dmg = max(1, self.player.atk * 2 - self.mon["def"] + random.randint(-2, 5))
|
|
self.mon_hp -= dmg
|
|
self.add_log(f"Magic! Dealt {dmg} dmg! (-8 MP)")
|
|
self.shake_enemy = 12
|
|
if self.mon_hp <= 0:
|
|
self.mon_hp = 0
|
|
self.end_battle(True)
|
|
else:
|
|
self.state = "enemy_turn"
|
|
|
|
def player_defend(self):
|
|
self.add_log("You brace for impact!")
|
|
self.player.dfn += 5
|
|
self._defending = True
|
|
self.state = "enemy_turn"
|
|
|
|
def enemy_turn(self):
|
|
if self.state != "enemy_turn":
|
|
return
|
|
dmg = max(1, self.mon["atk"] - self.player.dfn + random.randint(-2, 3))
|
|
self.player.hp -= dmg
|
|
self.add_log(f"{self.mon['name']} dealt {dmg} dmg!")
|
|
self.shake_player = 10
|
|
if hasattr(self, "_defending"):
|
|
self.player.dfn -= 5
|
|
del self._defending
|
|
if self.player.hp <= 0:
|
|
self.player.hp = 0
|
|
self.end_battle(False)
|
|
else:
|
|
self.state = "player_turn"
|
|
|
|
def end_battle(self, won):
|
|
if won:
|
|
xp = self.mon["xp"]
|
|
gold = self.mon["gold"]
|
|
self.player.gold += gold
|
|
lvl_msgs = self.player.gain_xp(xp)
|
|
self.add_log(f"Victory! +{xp} XP, +{gold}G")
|
|
for m in lvl_msgs:
|
|
self.add_log(m)
|
|
self.result_msgs = [f"Victory!", f"+{xp} XP +{gold} Gold"] + lvl_msgs
|
|
self.state = "win"
|
|
else:
|
|
self.player.hp = self.player.max_hp // 4
|
|
self.add_log("You were defeated...")
|
|
self.result_msgs = ["You fainted!", "Recovered at home."]
|
|
self.state = "lose"
|
|
|
|
def draw(self, surf):
|
|
self.frame += 1
|
|
# Background
|
|
for yy in range(H):
|
|
ratio = yy / H
|
|
r = int(20 + ratio * 20)
|
|
g = int(10 + ratio * 20)
|
|
b = int(40 + ratio * 30)
|
|
pygame.draw.line(surf, (r, g, b), (0, yy), (W, yy))
|
|
|
|
# Stars
|
|
random.seed(42)
|
|
for _ in range(40):
|
|
sx = random.randint(0, W)
|
|
sy = random.randint(0, H // 2)
|
|
br = 150 + int(50 * math.sin(self.frame * 0.05 + sx))
|
|
pygame.draw.rect(surf, (br, br, br), (sx, sy, 2, 2))
|
|
random.seed()
|
|
|
|
# Ground
|
|
pygame.draw.rect(surf, (60, 160, 60), (0, H-80, W, 80))
|
|
pygame.draw.rect(surf, (40, 120, 40), (0, H-82, W, 4))
|
|
|
|
# Enemy
|
|
ex = W * 3 // 4 - 24
|
|
ey = H // 2 - 40
|
|
sx = int(math.sin(self.shake_enemy * 0.8) * 5) if self.shake_enemy > 0 else 0
|
|
self.shake_enemy = max(0, self.shake_enemy - 1)
|
|
|
|
if self.mon["name"] in ("Green Slime", "Blue Slime"):
|
|
# Draw bigger slime
|
|
c = self.mon["color"]
|
|
for _ in range(3):
|
|
bc = int(abs(math.sin(self.frame * 0.1 + _)) * 3)
|
|
pygame.draw.ellipse(surf, c, (ex+sx+10, ey+bc+20, 60, 48))
|
|
pygame.draw.ellipse(surf, tuple(min(255,v+60) for v in c), (ex+sx+16, ey+24, 46, 30))
|
|
pygame.draw.rect(surf, C["white"], (ex+sx+22, ey+32, 10, 10))
|
|
pygame.draw.rect(surf, C["white"], (ex+sx+42, ey+32, 10, 10))
|
|
pygame.draw.rect(surf, C["dark"], (ex+sx+24, ey+34, 6, 6))
|
|
pygame.draw.rect(surf, C["dark"], (ex+sx+44, ey+34, 6, 6))
|
|
elif "Fairy" in self.mon["name"]:
|
|
bob = int(math.sin(self.frame * 0.2) * 6)
|
|
# Wings
|
|
pygame.draw.ellipse(surf, (200, 230, 255), (ex+sx-10, ey+bob+30, 30, 20))
|
|
pygame.draw.ellipse(surf, (200, 230, 255), (ex+sx+60, ey+bob+30, 30, 20))
|
|
pygame.draw.circle(surf, C["yellow"], (ex+sx+40, ey+bob+40), 22)
|
|
pygame.draw.circle(surf, C["white"], (ex+sx+40, ey+bob+40), 16)
|
|
pygame.draw.rect(surf, C["dark"], (ex+sx+32, ey+bob+34, 6, 6))
|
|
pygame.draw.rect(surf, C["dark"], (ex+sx+44, ey+bob+34, 6, 6))
|
|
elif "Wisp" in self.mon["name"]:
|
|
for ring in range(3):
|
|
alpha_c = 120 - ring * 30
|
|
r2 = 25 + ring * 10 + int(math.sin(self.frame * 0.1 + ring) * 4)
|
|
pygame.draw.circle(surf, C["purple"], (ex+sx+40, ey+40), r2, 2)
|
|
pygame.draw.circle(surf, C["purple"], (ex+sx+40, ey+40), 18)
|
|
pygame.draw.circle(surf, C["white"], (ex+sx+40, ey+40), 10)
|
|
|
|
# HP bar over enemy
|
|
draw_bar(surf, ex+sx+5, ey-12, 70, 8, self.mon_hp, self.mon_max_hp, C["hp_red"])
|
|
txt(surf, self.mon["name"], ex+sx-10, ey-28, C["white"], font_sm)
|
|
|
|
# Player sprite (left side)
|
|
psx = W // 4 - 12
|
|
psy = H // 2
|
|
pshake = int(math.sin(self.shake_player * 0.8) * 5) if self.shake_player > 0 else 0
|
|
self.shake_player = max(0, self.shake_player - 1)
|
|
draw_player(surf, psx + pshake, psy, self.frame, 3)
|
|
|
|
# Player stats panel
|
|
panel(surf, 10, H-130, 180, 120)
|
|
txt(surf, f"Lv.{self.player.level} {self.player.name}", 18, H-126, C["gold"])
|
|
draw_bar(surf, 18, H-108, 160, 10, self.player.hp, self.player.max_hp, C["hp_red"])
|
|
txt(surf, f"HP {self.player.hp}/{self.player.max_hp}", 18, H-96, C["white"], font_sm)
|
|
draw_bar(surf, 18, H-82, 160, 10, self.player.mp, self.player.max_mp, C["mp_blue"])
|
|
txt(surf, f"MP {self.player.mp}/{self.player.max_mp}", 18, H-70, C["white"], font_sm)
|
|
draw_bar(surf, 18, H-56, 160, 8, self.player.xp, self.player.xp_next, C["xp_green"])
|
|
txt(surf, f"XP {self.player.xp}/{self.player.xp_next}", 18, H-44, C["white"], font_sm)
|
|
|
|
# Battle log
|
|
panel(surf, W//2-160, H-130, 320, 70)
|
|
for i, line in enumerate(self.log[-3:]):
|
|
txt(surf, line, W//2-152, H-126 + i*22, C["white"], font_sm)
|
|
|
|
# Menu / result
|
|
if self.state in ("win", "lose"):
|
|
panel(surf, W//2-120, H//2-60, 240, 120)
|
|
color = C["gold"] if self.state == "win" else C["red"]
|
|
txt(surf, self.result_msgs[0], W//2-80, H//2-50, color)
|
|
for i, m in enumerate(self.result_msgs[1:]):
|
|
txt(surf, m, W//2-100, H//2-20 + i*22, C["white"], font_sm)
|
|
txt(surf, "Press E to continue", W//2-90, H//2+55, C["gray"], font_sm)
|
|
|
|
elif self.state == "player_turn":
|
|
if self.menu == "main":
|
|
panel(surf, W-200, H-130, 190, 120)
|
|
options = ["Attack", "Magic (8MP)", "Defend", "Items"]
|
|
for i, opt in enumerate(options):
|
|
color = C["yellow"] if i == self.selected else C["white"]
|
|
prefix = "► " if i == self.selected else " "
|
|
txt(surf, prefix + opt, W-192, H-122 + i*26, color)
|
|
elif self.menu == "items":
|
|
panel(surf, W-220, H-150, 210, 140)
|
|
txt(surf, "Items:", W-212, H-146, C["gold"])
|
|
if not self.item_list:
|
|
txt(surf, "No items!", W-200, H-120, C["gray"])
|
|
else:
|
|
for i, (name, qty) in enumerate(self.item_list):
|
|
color = C["yellow"] if i == self.selected else C["white"]
|
|
prefix = "► " if i == self.selected else " "
|
|
txt(surf, f"{prefix}{name} x{qty}", W-212, H-120 + i*24, color, font_sm)
|
|
txt(surf, "ESC=Back", W-212, H-18, C["gray"], font_sm)
|
|
|
|
def handle_input(self, event):
|
|
if event.type != pygame.KEYDOWN:
|
|
return None
|
|
|
|
if self.state in ("win", "lose"):
|
|
if event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
|
|
return self.state
|
|
return None
|
|
|
|
if self.state != "player_turn":
|
|
return None
|
|
|
|
if self.menu == "main":
|
|
if event.key == pygame.K_UP:
|
|
self.selected = (self.selected - 1) % 4
|
|
elif event.key == pygame.K_DOWN:
|
|
self.selected = (self.selected + 1) % 4
|
|
elif event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
|
|
if self.selected == 0:
|
|
self.player_attack()
|
|
elif self.selected == 1:
|
|
self.player_magic()
|
|
elif self.selected == 2:
|
|
self.player_defend()
|
|
# enemy attacks immediately
|
|
pygame.time.wait(400)
|
|
self.enemy_turn()
|
|
elif self.selected == 3:
|
|
self.item_list = [(n, q) for n, q in self.player.inventory.items() if n in ITEMS]
|
|
self.selected = 0
|
|
self.menu = "items"
|
|
elif self.menu == "items":
|
|
if event.key == pygame.K_ESCAPE:
|
|
self.menu = "main"
|
|
self.selected = 0
|
|
elif event.key == pygame.K_UP:
|
|
self.selected = max(0, self.selected - 1)
|
|
elif event.key == pygame.K_DOWN:
|
|
self.selected = min(len(self.item_list)-1, self.selected + 1)
|
|
elif event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
|
|
if self.item_list:
|
|
name, _ = self.item_list[self.selected]
|
|
ok, msg = self.player.use_item(name)
|
|
self.add_log(msg)
|
|
self.item_list = [(n, q) for n, q in self.player.inventory.items() if n in ITEMS]
|
|
if ok:
|
|
self.menu = "main"
|
|
self.selected = 0
|
|
self.state = "enemy_turn"
|
|
|
|
# Enemy responds if state changed
|
|
if self.state == "enemy_turn":
|
|
pygame.time.wait(300)
|
|
self.enemy_turn()
|
|
|
|
return None
|
|
|
|
|
|
# ── Shop ───────────────────────────────────────────────────────────────────
|
|
class Shop:
|
|
def __init__(self, player):
|
|
self.player = player
|
|
self.items = list(ITEMS.keys())
|
|
self.selected = 0
|
|
|
|
def draw(self, surf):
|
|
panel(surf, W//2-180, 60, 360, 360)
|
|
txt(surf, "= ITEM SHOP =", W//2-100, 72, C["gold"])
|
|
txt(surf, f"Gold: {self.player.gold}G", W//2+60, 72, C["yellow"], font_sm)
|
|
pygame.draw.line(surf, C["border"], (W//2-175, 95), (W//2+175, 95), 1)
|
|
|
|
for i, name in enumerate(self.items):
|
|
info = ITEMS[name]
|
|
y = 105 + i * 58
|
|
color = C["yellow"] if i == self.selected else C["white"]
|
|
prefix = "► " if i == self.selected else " "
|
|
txt(surf, prefix + name, W//2-170, y, color)
|
|
txt(surf, info["desc"], W//2-155, y+20, C["gray"], font_sm)
|
|
txt(surf, f"{info['price']}G", W//2+120, y, C["gold"], font_sm)
|
|
|
|
txt(surf, "E=Buy ESC=Leave", W//2-80, 380, C["gray"], font_sm)
|
|
|
|
def handle_input(self, event):
|
|
if event.type != pygame.KEYDOWN:
|
|
return False
|
|
if event.key == pygame.K_ESCAPE:
|
|
return True # close
|
|
if event.key == pygame.K_UP:
|
|
self.selected = (self.selected - 1) % len(self.items)
|
|
elif event.key == pygame.K_DOWN:
|
|
self.selected = (self.selected + 1) % len(self.items)
|
|
elif event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
|
|
name = self.items[self.selected]
|
|
price = ITEMS[name]["price"]
|
|
if self.player.gold >= price:
|
|
self.player.gold -= price
|
|
self.player.add_item(name)
|
|
# else: not enough gold (silent)
|
|
return False
|
|
|
|
|
|
# ── Dialogue ───────────────────────────────────────────────────────────────
|
|
class Dialogue:
|
|
def __init__(self, lines):
|
|
self.lines = lines if isinstance(lines, list) else [lines]
|
|
self.idx = 0
|
|
|
|
@property
|
|
def done(self):
|
|
return self.idx >= len(self.lines)
|
|
|
|
def advance(self):
|
|
self.idx += 1
|
|
|
|
def draw(self, surf):
|
|
if self.done:
|
|
return
|
|
panel(surf, 30, H-110, W-60, 100)
|
|
line = self.lines[self.idx]
|
|
for i, l in enumerate(line.split("\n")):
|
|
txt(surf, l, 46, H-100 + i*22, C["white"])
|
|
txt(surf, "▼ E", W-70, H-24, C["gray"], font_sm)
|
|
|
|
|
|
# ── World Renderer ─────────────────────────────────────────────────────────
|
|
def tile_walkable(tile):
|
|
return tile not in ("W", "t", "#", "T")
|
|
|
|
def render_map(surf, map_data, cam, frame):
|
|
rows = len(map_data)
|
|
cols = len(map_data[0])
|
|
for row in range(rows):
|
|
for col in range(col_count := cols):
|
|
tile = map_data[row][col] if col < len(map_data[row]) else " "
|
|
wx = col * TILE
|
|
wy = row * TILE
|
|
sx, sy = cam.world_to_screen(wx, wy)
|
|
if sx > W+TILE or sy > H+TILE or sx < -TILE or sy < -TILE:
|
|
continue
|
|
|
|
# Base tile
|
|
grass_c = C["dark_grass"] if (row + col) % 2 == 0 else C["grass"]
|
|
pygame.draw.rect(surf, grass_c, (sx, sy, TILE, TILE))
|
|
|
|
if tile == "W":
|
|
pygame.draw.rect(surf, (100, 80, 60), (sx, sy, TILE, TILE))
|
|
pygame.draw.rect(surf, (80, 60, 40), (sx, sy, TILE, TILE), 2)
|
|
elif tile == "#":
|
|
water_off = int(math.sin(frame * 0.05 + col * 0.5) * 2)
|
|
pygame.draw.rect(surf, C["water"], (sx, sy+water_off, TILE, TILE))
|
|
pygame.draw.rect(surf, (100, 200, 255), (sx+4, sy+4+water_off, TILE-8, 4), 1)
|
|
elif tile == ".":
|
|
pass # just grass
|
|
elif tile == "P":
|
|
pass # just grass (player starts here)
|
|
elif tile == "M":
|
|
pass # NPC drawn separately
|
|
elif tile == "t":
|
|
draw_tree(surf, sx, sy - 8)
|
|
elif tile == "F":
|
|
# Forest entrance
|
|
pygame.draw.rect(surf, C["dark_grass"], (sx, sy, TILE, TILE))
|
|
pygame.draw.rect(surf, C["green"], (sx+4, sy+4, TILE-8, TILE-8), 2)
|
|
elif tile == "T":
|
|
# Town exit
|
|
pygame.draw.rect(surf, C["path"], (sx, sy, TILE, TILE))
|
|
elif tile == "H":
|
|
draw_house(surf, sx-8, sy-16)
|
|
elif tile == "S":
|
|
draw_store(surf, sx-8, sy-16)
|
|
|
|
|
|
# ── Main Game ──────────────────────────────────────────────────────────────
|
|
class Game:
|
|
TOWN = "town"
|
|
FOREST = "forest"
|
|
|
|
def __init__(self):
|
|
self.player = Player()
|
|
self.cam = Camera()
|
|
self.scene = self.TOWN
|
|
self.current_map = TOWN_MAP
|
|
|
|
self.dialogue = None
|
|
self.shop = None
|
|
self.battle = None
|
|
self.frame = 0
|
|
|
|
self.npc_frame = 0
|
|
self.message = None # (text, timer)
|
|
|
|
self.monster_encounter_timer = 0
|
|
self.encounter_rate = 60 # frames between encounter checks
|
|
|
|
self.mitsuha_dialogue_idx = 0
|
|
|
|
# Floating particles (sparkles)
|
|
self.sparks = []
|
|
|
|
def show_message(self, text, duration=120):
|
|
self.message = (text, duration)
|
|
|
|
def add_spark(self, x, y, color=None):
|
|
c = color or random.choice([C["yellow"], C["white"], C["gold"], C["pink"]])
|
|
self.sparks.append({
|
|
"x": x, "y": y, "vx": random.uniform(-1.5, 1.5),
|
|
"vy": random.uniform(-3, -0.5), "life": 40, "color": c
|
|
})
|
|
|
|
def get_tile(self, tx, ty):
|
|
m = self.current_map
|
|
if ty < 0 or ty >= len(m) or tx < 0 or tx >= len(m[ty]):
|
|
return "W"
|
|
return m[ty][tx]
|
|
|
|
def try_move(self, dx, dy):
|
|
nx = self.player.x + dx
|
|
ny = self.player.y + dy
|
|
if not tile_walkable(self.get_tile(nx, ny)):
|
|
return
|
|
|
|
# Check transitions
|
|
if self.scene == self.TOWN and self.get_tile(nx, ny) == "F":
|
|
self.scene = self.FOREST
|
|
self.current_map = FOREST_MAP
|
|
self.player.x, self.player.y = 10, 12
|
|
self.player.px = self.player.x * TILE
|
|
self.player.py = self.player.y * TILE
|
|
self.show_message("Entered the Forest!")
|
|
return
|
|
if self.scene == self.FOREST and self.get_tile(nx, ny) == "T":
|
|
self.scene = self.TOWN
|
|
self.current_map = TOWN_MAP
|
|
self.player.x, self.player.y = 9, 12
|
|
self.player.px = self.player.x * TILE
|
|
self.player.py = self.player.y * TILE
|
|
self.show_message("Back in town!")
|
|
return
|
|
|
|
self.player.x = nx
|
|
self.player.y = ny
|
|
self.player.px = nx * TILE
|
|
self.player.py = ny * TILE
|
|
|
|
if dx > 0: self.player.facing = 3
|
|
elif dx < 0: self.player.facing = 2
|
|
elif dy > 0: self.player.facing = 1
|
|
else: self.player.facing = 0
|
|
|
|
# Random encounter in forest
|
|
if self.scene == self.FOREST:
|
|
if random.random() < 0.2:
|
|
mon = random.choice(MONSTERS)
|
|
self.battle = Battle(self.player, mon)
|
|
|
|
def try_interact(self):
|
|
# Check adjacent tiles for interactive objects
|
|
fx, fy = self.player.x, self.player.y
|
|
facing = self.player.facing
|
|
if facing == 0: fy -= 1
|
|
elif facing == 1: fy += 1
|
|
elif facing == 2: fx -= 1
|
|
elif facing == 3: fx += 1
|
|
|
|
tile = self.get_tile(fx, fy)
|
|
|
|
# Check if near Mitsuha (in town map)
|
|
if self.scene == self.TOWN:
|
|
for row_i, row in enumerate(TOWN_MAP):
|
|
if "M" in row:
|
|
mx, my = row.index("M"), row_i
|
|
if abs(mx - self.player.x) + abs(my - self.player.y) <= 1:
|
|
line = MITSUHA_LINES[self.mitsuha_dialogue_idx % len(MITSUHA_LINES)]
|
|
self.mitsuha_dialogue_idx += 1
|
|
self.dialogue = Dialogue(line)
|
|
return
|
|
|
|
if tile == "H":
|
|
# Home - rest
|
|
self.player.hp = self.player.max_hp
|
|
self.player.mp = self.player.max_mp
|
|
self.dialogue = Dialogue(random.choice(HOME_LINES) + "\n(HP and MP restored!)")
|
|
for _ in range(12):
|
|
self.add_spark(self.player.px + random.randint(-20, 20),
|
|
self.player.py + random.randint(-30, 10), C["gold"])
|
|
elif tile == "S":
|
|
self.shop = Shop(self.player)
|
|
|
|
def update(self):
|
|
self.frame += 1
|
|
self.npc_frame += 1
|
|
self.player.frame += 1
|
|
|
|
# Update sparks
|
|
for s in self.sparks[:]:
|
|
s["x"] += s["vx"]
|
|
s["y"] += s["vy"]
|
|
s["vy"] += 0.1
|
|
s["life"] -= 1
|
|
if s["life"] <= 0:
|
|
self.sparks.remove(s)
|
|
|
|
if self.message:
|
|
text, timer = self.message
|
|
self.message = (text, timer - 1) if timer > 0 else None
|
|
|
|
def draw_world(self, surf):
|
|
surf.fill(C["sky"])
|
|
render_map(surf, self.current_map, self.cam, self.frame)
|
|
|
|
# Draw Mitsuha NPC (in town)
|
|
if self.scene == self.TOWN:
|
|
for row_i, row in enumerate(TOWN_MAP):
|
|
if "M" in row:
|
|
mx, my = row.index("M"), row_i
|
|
sx, sy = self.cam.world_to_screen(mx * TILE, my * TILE)
|
|
draw_npc_mitsuha(surf, sx, sy, self.npc_frame)
|
|
# Name tag
|
|
panel(surf, sx-4, sy-20, 56, 16)
|
|
txt(surf, "Mitsuha", sx-2, sy-19, C["pink"], font_sm)
|
|
|
|
# Draw player
|
|
px, py = self.cam.world_to_screen(self.player.px, self.player.py)
|
|
draw_player(surf, px, py, self.player.frame, self.player.facing)
|
|
|
|
# Sparks
|
|
for s in self.sparks:
|
|
alpha = max(0, s["life"] * 6)
|
|
c = tuple(min(255, v) for v in s["color"])
|
|
pygame.draw.rect(surf, c, (int(s["x"]) - self.cam.ox, int(s["y"]) - self.cam.oy, 3, 3))
|
|
|
|
def draw_hud(self, surf):
|
|
# Mini stats
|
|
panel(surf, 8, 8, 200, 68)
|
|
txt(surf, f"Lv.{self.player.level} {self.player.name}", 14, 12, C["gold"])
|
|
draw_bar(surf, 14, 32, 180, 8, self.player.hp, self.player.max_hp, C["hp_red"])
|
|
txt(surf, f"HP {self.player.hp}/{self.player.max_hp}", 14, 42, C["white"], font_sm)
|
|
draw_bar(surf, 14, 54, 180, 8, self.player.mp, self.player.max_mp, C["mp_blue"])
|
|
txt(surf, f"MP {self.player.mp}/{self.player.max_mp}", 14, 64, C["white"], font_sm)
|
|
|
|
# Gold
|
|
panel(surf, W-140, 8, 132, 26)
|
|
txt(surf, f"Gold: {self.player.gold}G", W-134, 12, C["gold"])
|
|
|
|
# Location
|
|
loc = "** Village **" if self.scene == self.TOWN else "~~ Forest ~~"
|
|
panel(surf, W//2-60, 8, 120, 26)
|
|
txt(surf, loc, W//2-52, 12, C["white"], font_sm)
|
|
|
|
# Controls hint
|
|
txt(surf, "Arrows:Move E:Interact", 10, H-18, C["gray"], font_sm)
|
|
|
|
# Floating message
|
|
if self.message:
|
|
text, timer = self.message
|
|
alpha = min(255, timer * 4)
|
|
panel(surf, W//2-120, H//2-20, 240, 40)
|
|
fc = flash_color(C["white"], self.frame * 0.02)
|
|
txt(surf, text, W//2-100, H//2-12, fc)
|
|
|
|
def run(self):
|
|
running = True
|
|
while running:
|
|
for event in pygame.event.get():
|
|
if event.type == pygame.QUIT:
|
|
running = False
|
|
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
|
|
if self.dialogue:
|
|
self.dialogue = None
|
|
elif self.shop:
|
|
self.shop = None
|
|
elif self.battle:
|
|
pass # can't escape battle easily
|
|
else:
|
|
running = False
|
|
|
|
# Battle input
|
|
if self.battle:
|
|
result = self.battle.handle_input(event)
|
|
if result in ("win", "lose"):
|
|
if result == "lose":
|
|
self.scene = self.TOWN
|
|
self.current_map = TOWN_MAP
|
|
self.player.x, self.player.y = 5, 6
|
|
self.player.px = self.player.x * TILE
|
|
self.player.py = self.player.y * TILE
|
|
self.battle = None
|
|
continue
|
|
|
|
# Shop input
|
|
if self.shop:
|
|
if self.shop.handle_input(event):
|
|
self.shop = None
|
|
continue
|
|
|
|
# Dialogue
|
|
if self.dialogue:
|
|
if event.type == pygame.KEYDOWN and event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
|
|
self.dialogue.advance()
|
|
if self.dialogue.done:
|
|
self.dialogue = None
|
|
continue
|
|
|
|
# Movement
|
|
if event.type == pygame.KEYDOWN:
|
|
if event.key == pygame.K_UP:
|
|
self.try_move(0, -1)
|
|
elif event.key == pygame.K_DOWN:
|
|
self.try_move(0, 1)
|
|
elif event.key == pygame.K_LEFT:
|
|
self.try_move(-1, 0)
|
|
elif event.key == pygame.K_RIGHT:
|
|
self.try_move(1, 0)
|
|
elif event.key in (pygame.K_e, pygame.K_SPACE, pygame.K_RETURN):
|
|
self.try_interact()
|
|
|
|
self.update()
|
|
self.cam.update(self.player.px, self.player.py,
|
|
len(self.current_map[0]), len(self.current_map))
|
|
|
|
if self.battle:
|
|
self.battle.draw(screen)
|
|
else:
|
|
self.draw_world(screen)
|
|
self.draw_hud(screen)
|
|
if self.shop:
|
|
self.shop.draw(screen)
|
|
if self.dialogue:
|
|
self.dialogue.draw(screen)
|
|
|
|
pygame.display.flip()
|
|
clock.tick(FPS)
|
|
|
|
pygame.quit()
|
|
sys.exit()
|
|
|
|
|
|
# ── Title Screen ───────────────────────────────────────────────────────────
|
|
def title_screen():
|
|
frame = 0
|
|
while True:
|
|
for event in pygame.event.get():
|
|
if event.type == pygame.QUIT:
|
|
pygame.quit(); sys.exit()
|
|
if event.type == pygame.KEYDOWN:
|
|
return
|
|
|
|
frame += 1
|
|
screen.fill(C["bg"])
|
|
|
|
# Stars
|
|
random.seed(7)
|
|
for _ in range(60):
|
|
sx = random.randint(0, W)
|
|
sy = random.randint(0, H)
|
|
br = 100 + int(80 * math.sin(frame * 0.03 + sx * 0.1))
|
|
pygame.draw.rect(screen, (br, br, int(br*0.8)), (sx, sy, 2, 2))
|
|
random.seed()
|
|
|
|
# Animated title shimmer
|
|
tc = flash_color(C["gold"], frame * 0.04, 30)
|
|
title = font_lg.render(">> Pixel Village <<", True, tc)
|
|
screen.blit(title, (W//2 - title.get_width()//2, 100))
|
|
|
|
sub = font_md.render("~ A Cozy Little JRPG ~", True, C["pink"])
|
|
screen.blit(sub, (W//2 - sub.get_width()//2, 140))
|
|
|
|
# Demo sprites
|
|
draw_player(screen, W//2-80, 230, frame)
|
|
draw_npc_mitsuha(screen, W//2-20, 230, frame)
|
|
draw_slime(screen, W//2+60, 240, frame, C["green"])
|
|
draw_fairy(screen, W//2+100, 230, frame)
|
|
|
|
# Features
|
|
features = [
|
|
"[H] Cozy village with shop & home",
|
|
"[?] Chat with neighbor Mitsuha",
|
|
"[T] Forest full of cute monsters",
|
|
"[!] Turn-based combat",
|
|
"[*] Level up your hero!",
|
|
]
|
|
for i, f in enumerate(features):
|
|
fc = C["white"] if i % 2 == 0 else C["gray"]
|
|
fs = font_sm.render(f, True, fc)
|
|
screen.blit(fs, (W//2 - fs.get_width()//2, 310 + i*20))
|
|
|
|
blink = int(frame / 30) % 2 == 0
|
|
if blink:
|
|
ps = font_md.render("Press any key to start!", True, C["yellow"])
|
|
screen.blit(ps, (W//2 - ps.get_width()//2, 420))
|
|
|
|
pygame.display.flip()
|
|
clock.tick(FPS)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
title_screen()
|
|
game = Game()
|
|
game.run()
|