Initial commit: Plants turn-based battle game
This commit is contained in:
+437
@@ -0,0 +1,437 @@
|
||||
import pygame
|
||||
|
||||
from assets import icon as icon_path
|
||||
from engine import collection
|
||||
from engine.plants import POOL, RARITY_COLORS, plant_rarity
|
||||
from engine import runes
|
||||
from ui.scaling import fit_surface, load_rune_icon, make_window, request_quit, to_logical
|
||||
|
||||
WIN_W, WIN_H = 1040, 720
|
||||
BG = (24, 30, 24)
|
||||
TEXT = (230, 235, 230)
|
||||
MUTED = (150, 155, 150)
|
||||
HINT = (255, 220, 120)
|
||||
PANEL = (32, 38, 32)
|
||||
BTN = (60, 90, 60)
|
||||
BTN_HOVER = (85, 125, 85)
|
||||
ACCENT = (120, 220, 190)
|
||||
DANGER = (150, 70, 70)
|
||||
SLOT_BORDER = (90, 110, 90)
|
||||
|
||||
SLOT_SIZE = 64
|
||||
|
||||
|
||||
def draw_rune_chips(screen, get_icon, colors, x, y, size=14, gap=2, max_chips=3):
|
||||
"""Draw small rune icons for the given colors starting at (x, y). `get_icon(color)`
|
||||
returns a surface (already sized) or None. Used to show equipped runes at a glance."""
|
||||
for i, color in enumerate(colors[:max_chips]):
|
||||
ic = get_icon(color)
|
||||
if ic:
|
||||
screen.blit(ic, (x + i * (size + gap), y))
|
||||
|
||||
|
||||
class RuneScreen:
|
||||
def __init__(self, plant: str):
|
||||
pygame.init()
|
||||
self.plant = plant
|
||||
self.window, self.scale = make_window(WIN_W, WIN_H)
|
||||
self.screen = pygame.Surface((WIN_W, WIN_H))
|
||||
pygame.display.set_caption(f"Plants — Runes · {plant}")
|
||||
self.clock = pygame.time.Clock()
|
||||
self.font = pygame.font.SysFont("segoeui", 30)
|
||||
self.bold = pygame.font.SysFont("segoeui", 20, bold=True)
|
||||
self.small = pygame.font.SysFont("segoeui", 16)
|
||||
self.tiny = pygame.font.SysFont("segoeui", 13)
|
||||
self.back_rect = pygame.Rect(16, 16, 90, 34)
|
||||
self.info_rect = pygame.Rect(WIN_W - 100, 16, 84, 34)
|
||||
self.mouse_pos = (0, 0)
|
||||
self.selected = None # rune id selected from the inventory
|
||||
self.confirm = None # {"kind","slot","rune",...} prompt
|
||||
self.confirm_ok = pygame.Rect(0, 0, 0, 0)
|
||||
self.confirm_no = pygame.Rect(0, 0, 0, 0)
|
||||
self.info = False # rune-effects info popup
|
||||
self.equip_msg = "" # transient message (e.g. rune-type limit)
|
||||
self.equip_msg_until = 0
|
||||
self.filter_tier = 0 # 0 all, 1 tier 1, 2 tier 2
|
||||
self.filter_color = None # None all, else a rune color
|
||||
self.sort_best = False # sort by proportional roll
|
||||
self.filter_rects = [] # (kind, value, rect)
|
||||
self.sort_rect = pygame.Rect(WIN_W - 170, 284, 150, 24)
|
||||
self.slot_rects = [] # (slot_index, rect)
|
||||
self.inv_rects = [] # (rune_id, rect)
|
||||
self.scroll = 0
|
||||
self.scroll_max = 0
|
||||
self._icons = {}
|
||||
|
||||
def _icon_for(self, color):
|
||||
if color not in self._icons:
|
||||
self._icons[color] = load_rune_icon(color, 40)
|
||||
return self._icons[color]
|
||||
|
||||
def run(self):
|
||||
running = True
|
||||
while running:
|
||||
self.clock.tick(60)
|
||||
self.mouse_pos = to_logical(pygame.mouse.get_pos(), self.scale)
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
request_quit()
|
||||
running = False
|
||||
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
|
||||
pos = to_logical(event.pos, self.scale)
|
||||
if self.back_rect.collidepoint(pos):
|
||||
running = False
|
||||
continue
|
||||
if self.info:
|
||||
self.info = False
|
||||
continue
|
||||
if self.info_rect.collidepoint(pos):
|
||||
self.info = True
|
||||
continue
|
||||
if self.confirm is not None:
|
||||
if self.confirm_ok.collidepoint(pos):
|
||||
if self.confirm["kind"] == "move":
|
||||
collection.equip_rune(self.plant, self.confirm["slot"],
|
||||
self.confirm["rune"]["id"])
|
||||
self.selected = None
|
||||
else:
|
||||
collection.unequip_rune(self.plant, self.confirm["slot"])
|
||||
self.confirm = None
|
||||
elif self.confirm_no.collidepoint(pos):
|
||||
self.confirm = None
|
||||
continue
|
||||
self._click(pos)
|
||||
elif event.type == pygame.MOUSEWHEEL:
|
||||
self.scroll = max(0, min(self.scroll_max, self.scroll - event.y * 44))
|
||||
self._draw()
|
||||
pygame.transform.smoothscale(self.screen, self.window.get_size(), self.window)
|
||||
pygame.display.flip()
|
||||
|
||||
def _click(self, pos):
|
||||
if self._click_filter(pos):
|
||||
return
|
||||
equipped = collection.equipped_runes(self.plant)
|
||||
inventory = collection.rune_inventory()
|
||||
by_id = {r["id"]: r for r in inventory}
|
||||
for slot, rect in self.slot_rects:
|
||||
if rect.collidepoint(pos):
|
||||
if self.selected is not None:
|
||||
rune = by_id.get(self.selected)
|
||||
owner = collection.rune_location(self.selected)
|
||||
if owner and owner != self.plant:
|
||||
self.confirm = {"kind": "move", "slot": slot,
|
||||
"rune": rune, "from": owner}
|
||||
else:
|
||||
reason = collection.equip_rune(self.plant, slot, self.selected)
|
||||
self.equip_msg = reason or ""
|
||||
self.equip_msg_until = (pygame.time.get_ticks() + 2000) if reason else 0
|
||||
self.selected = None
|
||||
elif slot < len(equipped) and equipped[slot] is not None:
|
||||
self.confirm = {"kind": "remove", "slot": slot,
|
||||
"rune": by_id.get(equipped[slot])}
|
||||
return
|
||||
for rune_id, rect in self.inv_rects:
|
||||
if rect.collidepoint(pos):
|
||||
self.selected = None if self.selected == rune_id else rune_id
|
||||
return
|
||||
|
||||
def _click_filter(self, pos):
|
||||
for kind, val, rect in self.filter_rects:
|
||||
if rect.collidepoint(pos):
|
||||
if kind == "tier":
|
||||
self.filter_tier = val
|
||||
elif kind == "color":
|
||||
self.filter_color = val
|
||||
return True
|
||||
if self.sort_rect.collidepoint(pos):
|
||||
self.sort_best = not self.sort_best
|
||||
return True
|
||||
return False
|
||||
|
||||
def _displayed_runes(self, inventory):
|
||||
shown = [r for r in inventory
|
||||
if (self.filter_tier == 0 or runes.tier(r["color"]) == self.filter_tier)
|
||||
and (self.filter_color is None or r["color"] == self.filter_color)]
|
||||
if self.sort_best:
|
||||
shown.sort(key=runes.roll_ratio, reverse=True)
|
||||
return shown
|
||||
|
||||
def _small_icon(self, color):
|
||||
key = ("small", color)
|
||||
if key not in self._icons:
|
||||
ic = self._icon_for(color)
|
||||
self._icons[key] = fit_surface(ic, 22) if ic else None
|
||||
return self._icons[key]
|
||||
|
||||
def _draw_filters(self):
|
||||
self.filter_rects = []
|
||||
y, h = 284, 24
|
||||
tl = self.tiny.render("Tier:", True, MUTED)
|
||||
self.screen.blit(tl, (60, y + 5))
|
||||
x = 92
|
||||
for val, label in ((0, "All"), (1, "T1"), (2, "T2")):
|
||||
active = self.filter_tier == val
|
||||
rect = pygame.Rect(x, y, 30, h)
|
||||
pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45),
|
||||
rect, border_radius=4)
|
||||
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
|
||||
rect, 1, border_radius=4)
|
||||
t = self.tiny.render(label, True, TEXT if active else MUTED)
|
||||
self.screen.blit(t, t.get_rect(center=rect.center))
|
||||
self.filter_rects.append(("tier", val, rect))
|
||||
x += 36
|
||||
ty = self.tiny.render("Type:", True, MUTED)
|
||||
self.screen.blit(ty, (210, y + 5))
|
||||
x = 244
|
||||
all_rect = pygame.Rect(x, y, 36, h)
|
||||
active = self.filter_color is None
|
||||
pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45),
|
||||
all_rect, border_radius=4)
|
||||
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
|
||||
all_rect, 1, border_radius=4)
|
||||
t = self.tiny.render("All", True, TEXT if active else MUTED)
|
||||
self.screen.blit(t, t.get_rect(center=all_rect.center))
|
||||
self.filter_rects.append(("color", None, all_rect))
|
||||
x += 42
|
||||
for color in runes.RUNE_COLORS:
|
||||
rect = pygame.Rect(x, y, 24, h)
|
||||
active = self.filter_color == color
|
||||
pygame.draw.rect(self.screen, (45, 50, 45), rect, border_radius=4)
|
||||
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
|
||||
rect, 1 if active else 2, border_radius=4)
|
||||
sw = self._small_icon(color)
|
||||
if sw:
|
||||
self.screen.blit(sw, sw.get_rect(center=rect.center))
|
||||
self.filter_rects.append(("color", color, rect))
|
||||
x += 28
|
||||
active = self.sort_best
|
||||
pygame.draw.rect(self.screen, (90, 120, 90) if active else (45, 50, 45),
|
||||
self.sort_rect, border_radius=4)
|
||||
pygame.draw.rect(self.screen, HINT if active else (70, 80, 70),
|
||||
self.sort_rect, 1, border_radius=4)
|
||||
sl = self.tiny.render("Sort: best roll" if active else "Sort: default",
|
||||
True, TEXT if active else MUTED)
|
||||
self.screen.blit(sl, sl.get_rect(center=self.sort_rect.center))
|
||||
|
||||
def _draw(self):
|
||||
self.slot_rects = []
|
||||
self.inv_rects = []
|
||||
self.screen.fill(BG)
|
||||
|
||||
pygame.draw.rect(self.screen, BTN, self.back_rect, border_radius=6)
|
||||
bt = self.small.render("Back", True, TEXT)
|
||||
self.screen.blit(bt, bt.get_rect(center=self.back_rect.center))
|
||||
|
||||
ihov = self.info_rect.collidepoint(self.mouse_pos)
|
||||
pygame.draw.rect(self.screen, (90, 120, 90) if (self.info or ihov) else BTN,
|
||||
self.info_rect, border_radius=6)
|
||||
il = self.tiny.render("Rune info", True, TEXT)
|
||||
self.screen.blit(il, il.get_rect(center=self.info_rect.center))
|
||||
|
||||
spec = POOL[self.plant]
|
||||
rar = plant_rarity(self.plant)
|
||||
title = self.font.render(self.plant, True, RARITY_COLORS.get(rar, TEXT))
|
||||
self.screen.blit(title, title.get_rect(center=(WIN_W // 2, 44)))
|
||||
sub = self.small.render(f"Rune slots — {spec['role']}", True, MUTED)
|
||||
self.screen.blit(sub, sub.get_rect(center=(WIN_W // 2, 76)))
|
||||
|
||||
slots = collection.rune_slots(self.plant)
|
||||
equipped = collection.equipped_runes(self.plant)
|
||||
inventory = collection.rune_inventory()
|
||||
by_id = {r["id"]: r for r in inventory}
|
||||
|
||||
# --- slots -----------------------------------------------------
|
||||
st = self.bold.render("Rune slots", True, HINT)
|
||||
self.screen.blit(st, (60, 110))
|
||||
locked = self.tiny.render(
|
||||
"Evolve to unlock more slots (permanent)", True, MUTED)
|
||||
self.screen.blit(locked, (200, 116))
|
||||
|
||||
total_w = slots * SLOT_SIZE + (slots - 1) * 20
|
||||
x0 = (WIN_W - total_w) // 2
|
||||
y = 140
|
||||
for i in range(slots):
|
||||
rect = pygame.Rect(x0 + i * (SLOT_SIZE + 20), y, SLOT_SIZE, SLOT_SIZE)
|
||||
pygame.draw.rect(self.screen, PANEL, rect, border_radius=10)
|
||||
pygame.draw.rect(self.screen, SLOT_BORDER, rect, 2, border_radius=10)
|
||||
rune = by_id.get(equipped[i]) if i < len(equipped) else None
|
||||
if rune:
|
||||
ic = self._icon_for(rune["color"])
|
||||
if ic:
|
||||
self.screen.blit(ic, ic.get_rect(center=rect.center))
|
||||
else:
|
||||
num = self.bold.render(str(i + 1), True, MUTED)
|
||||
num.set_alpha(70) # faint hint that the slot is empty
|
||||
self.screen.blit(num, num.get_rect(center=rect.center))
|
||||
lbl = self.tiny.render(f"Slot {i + 1}", True, MUTED)
|
||||
self.screen.blit(lbl, lbl.get_rect(center=(rect.centerx, rect.bottom + 14)))
|
||||
self.slot_rects.append((i, rect))
|
||||
|
||||
# --- equipped-effects summary (right of the slots) ------------
|
||||
equipped_runes = [by_id[e] for e in equipped if e in by_id]
|
||||
line_h = 18
|
||||
panel = pygame.Rect(x0 + total_w + 40, y, 240,
|
||||
26 + line_h * max(1, len(equipped_runes)) + 8)
|
||||
pygame.draw.rect(self.screen, PANEL, panel, border_radius=8)
|
||||
pygame.draw.rect(self.screen, (60, 70, 60), panel, 1, border_radius=8)
|
||||
t = self.bold.render("Equipped effects", True, HINT)
|
||||
self.screen.blit(t, (panel.x + 8, panel.y + 6))
|
||||
py = panel.y + 28
|
||||
if equipped_runes:
|
||||
for rune in equipped_runes:
|
||||
eff = self.tiny.render(
|
||||
runes.effect_text(rune["color"], rune.get("pct", 30)), True, TEXT)
|
||||
self.screen.blit(eff, (panel.x + 8, py))
|
||||
py += line_h
|
||||
else:
|
||||
e = self.tiny.render("No runes equipped", True, MUTED)
|
||||
self.screen.blit(e, (panel.x + 8, py))
|
||||
|
||||
if pygame.time.get_ticks() < self.equip_msg_until:
|
||||
b = self.tiny.render(self.equip_msg, True, DANGER)
|
||||
self.screen.blit(b, b.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44)))
|
||||
elif self.selected is not None:
|
||||
sel = by_id.get(self.selected)
|
||||
if sel:
|
||||
owner = collection.rune_location(self.selected)
|
||||
if owner and owner != self.plant:
|
||||
hint = self.bold.render(
|
||||
f"Click a slot to move {sel['name']} from {owner}", True, ACCENT)
|
||||
else:
|
||||
hint = self.bold.render(
|
||||
f"Click a slot to equip {sel['name']}", True, ACCENT)
|
||||
self.screen.blit(hint, hint.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44)))
|
||||
elif slots == 1:
|
||||
h = self.tiny.render("Click a rune below, then click a slot to equip it.",
|
||||
True, MUTED)
|
||||
self.screen.blit(h, h.get_rect(center=(WIN_W // 2, y + SLOT_SIZE + 44)))
|
||||
|
||||
# --- inventory -------------------------------------------------
|
||||
it = self.bold.render("Runes you own", True, HINT)
|
||||
self.screen.blit(it, (60, 260))
|
||||
dr = self.tiny.render(
|
||||
f"Drop rates — {runes.BATTLE_DROP_CHANCE * 100:.0f}% for any rune "
|
||||
f"from floor battles · {runes.PACK_DROP_CHANCE * 100:.0f}% for any "
|
||||
f"rune from packs",
|
||||
True, MUTED)
|
||||
self.screen.blit(dr, dr.get_rect(midright=(WIN_W - 60, 270)))
|
||||
self._draw_filters()
|
||||
display = self._displayed_runes(inventory)
|
||||
list_y = 318
|
||||
self.scroll_max = max(0, len(display) * 46 - (WIN_H - list_y - 20))
|
||||
self.scroll = max(0, min(self.scroll, self.scroll_max))
|
||||
|
||||
old_clip = self.screen.get_clip()
|
||||
self.screen.set_clip(pygame.Rect(0, list_y, WIN_W, WIN_H - list_y - 20))
|
||||
ry = list_y - self.scroll
|
||||
for rune in display:
|
||||
rect = pygame.Rect(60, ry, 780, 40)
|
||||
pygame.draw.rect(self.screen, PANEL, rect, border_radius=6)
|
||||
if rune["id"] == self.selected:
|
||||
pygame.draw.rect(self.screen, ACCENT, rect, 2, border_radius=6)
|
||||
else:
|
||||
pygame.draw.rect(self.screen, (60, 70, 60), rect, 1, border_radius=6)
|
||||
ic = self._icon_for(rune["color"])
|
||||
if ic:
|
||||
self.screen.blit(ic, (rect.x + 8, rect.y + 4))
|
||||
owner = collection.rune_location(rune["id"])
|
||||
on_this = owner == self.plant
|
||||
name = self.small.render(rune["name"], True,
|
||||
TEXT if not on_this else MUTED)
|
||||
self.screen.blit(name, (rect.x + 58, rect.y + 11))
|
||||
eff = self.tiny.render(runes.effect_text(rune["color"], rune.get("pct", 30)),
|
||||
True, MUTED)
|
||||
self.screen.blit(eff, (rect.x + 58 + name.get_width() + 10, rect.y + 14))
|
||||
if on_this:
|
||||
tag = self.tiny.render("equipped", True, MUTED)
|
||||
self.screen.blit(tag, (rect.right - 70, rect.y + 13))
|
||||
elif owner:
|
||||
tag = self.tiny.render(f"Equipped in: {owner}", True, MUTED)
|
||||
self.screen.blit(tag, tag.get_rect(midright=(rect.right - 14, rect.centery)))
|
||||
self.inv_rects.append((rune["id"], rect))
|
||||
ry += 46
|
||||
self.screen.set_clip(old_clip)
|
||||
|
||||
if not inventory:
|
||||
empty = self.small.render(
|
||||
"No runes yet — win floor battles or open seed packs to find some.",
|
||||
True, MUTED)
|
||||
self.screen.blit(empty, (60, list_y + 10))
|
||||
elif not display:
|
||||
empty = self.small.render("No runes match the current filter.",
|
||||
True, MUTED)
|
||||
self.screen.blit(empty, (60, list_y + 10))
|
||||
if self.scroll_max > 0:
|
||||
hint = "▲" if self.scroll > 0 else ""
|
||||
if self.scroll < self.scroll_max:
|
||||
hint += "▼"
|
||||
ht = self.tiny.render(hint, True, MUTED)
|
||||
self.screen.blit(ht, ht.get_rect(center=(WIN_W // 2, WIN_H - 18)))
|
||||
|
||||
if self.confirm is not None:
|
||||
self._draw_confirm()
|
||||
if self.info:
|
||||
self._draw_info()
|
||||
|
||||
def _draw_info(self):
|
||||
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
|
||||
overlay.fill((0, 0, 0, 160))
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
w, h = 560, 400
|
||||
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
|
||||
pygame.draw.rect(self.screen, PANEL, panel, border_radius=12)
|
||||
pygame.draw.rect(self.screen, (150, 190, 150), panel, 2, border_radius=12)
|
||||
title = self.bold.render("Rune effects", True, HINT)
|
||||
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 30)))
|
||||
y = panel.y + 62
|
||||
for color in runes.RUNE_COLORS:
|
||||
ic = self._icon_for(color)
|
||||
if ic:
|
||||
self.screen.blit(ic, (panel.x + 40, y))
|
||||
nm = self.bold.render(runes.name(color), True, TEXT)
|
||||
self.screen.blit(nm, (panel.x + 92, y))
|
||||
eff = self.small.render(runes.summary(color), True, MUTED)
|
||||
self.screen.blit(eff, (panel.x + 92, y + 24))
|
||||
rg = self.tiny.render(
|
||||
f"Tier {runes.tier(color)} · Rolls {runes.roll_range(color)}",
|
||||
True, ACCENT)
|
||||
self.screen.blit(rg, rg.get_rect(topright=(panel.right - 16, y + 10)))
|
||||
y += 58
|
||||
note = self.tiny.render(
|
||||
"Runes of the same effect stack additively; a plant can equip at "
|
||||
"most %d runes of the same type." % runes.MAX_RUNES_PER_TYPE,
|
||||
True, MUTED)
|
||||
self.screen.blit(note, note.get_rect(center=(panel.centerx, panel.bottom - 24)))
|
||||
close = self.tiny.render("Click anywhere to close", True, MUTED)
|
||||
self.screen.blit(close, close.get_rect(center=(panel.centerx, panel.bottom - 8)))
|
||||
|
||||
def _draw_confirm(self):
|
||||
overlay = pygame.Surface((WIN_W, WIN_H), pygame.SRCALPHA)
|
||||
overlay.fill((0, 0, 0, 150))
|
||||
self.screen.blit(overlay, (0, 0))
|
||||
w, h = 480, 190
|
||||
panel = pygame.Rect((WIN_W - w) // 2, (WIN_H - h) // 2, w, h)
|
||||
pygame.draw.rect(self.screen, PANEL, panel, border_radius=12)
|
||||
pygame.draw.rect(self.screen, DANGER, panel, 2, border_radius=12)
|
||||
rune = self.confirm["rune"]
|
||||
name = rune["name"] if rune else "Rune"
|
||||
if self.confirm["kind"] == "move":
|
||||
title = self.bold.render("Move rune?", True, TEXT)
|
||||
msg = self.small.render(
|
||||
f"Remove {name} from {self.confirm['from']} to slot it here?", True, TEXT)
|
||||
ok_label = "Move"
|
||||
else:
|
||||
slot = self.confirm["slot"] + 1
|
||||
title = self.bold.render("Remove rune?", True, TEXT)
|
||||
msg = self.small.render(f"Unequip {name} from slot {slot}?", True, TEXT)
|
||||
ok_label = "Remove"
|
||||
self.screen.blit(title, title.get_rect(center=(panel.centerx, panel.y + 34)))
|
||||
self.screen.blit(msg, msg.get_rect(center=(panel.centerx, panel.y + 66)))
|
||||
self.confirm_ok = pygame.Rect(panel.centerx - 110, panel.y + 110, 100, 36)
|
||||
self.confirm_no = pygame.Rect(panel.centerx + 10, panel.y + 110, 100, 36)
|
||||
pygame.draw.rect(self.screen, DANGER, self.confirm_ok, border_radius=8)
|
||||
ok = self.bold.render(ok_label, True, TEXT)
|
||||
self.screen.blit(ok, ok.get_rect(center=self.confirm_ok.center))
|
||||
pygame.draw.rect(self.screen, BTN, self.confirm_no, border_radius=8)
|
||||
no = self.bold.render("Cancel", True, TEXT)
|
||||
self.screen.blit(no, no.get_rect(center=self.confirm_no.center))
|
||||
Reference in New Issue
Block a user