# MIT License # # Copyright (c) 2026 Tidepost contributors # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # Exception: assets/glyphs.json contains rasterized glyph data derived from # Noto Sans CJK JP (SIL Open Font License 1.1). See assets/NOTO-LICENSE.txt. # Pyxel is an external dependency under its own MIT license. """おばけ洗濯日和・教材 5/5。原作を簡略化した独立実行ファイル。 実行: python 05_round.py コード・図形: MIT。原作者と条件は隣の LICENSE を参照。 """ from dataclasses import dataclass WIDTH, HEIGHT = 240, 180 GROUND = 136 FPS = 30 CAPACITY = 8 SHEET_POSITIONS = (120, 150, 90, 60, 180) ROUND_FRAMES = 45 * FPS GOAL = 80 def bundle_value(count): """原作と同じ束の計算。追加点は最大12点。""" return 10 * count + 3 * min(max(count - 1, 0), 4) @dataclass class Drop: x: float y: float = 24 kind: str = "sheet" class Game: def __init__(self): self.x = WIDTH / 2 self.frames = 0 self.cargo = 0 self.drops = [] self.score = 0 self.cooldown = 0 self.done = False @property def near_basket(self): return self.x <= 28 or self.x >= WIDTH - 28 @property def speed(self): return max(1.2, 2.0 - self.cargo * 0.1) def step(self, direction=0, bank=False): if self.done: return self.frames += 1 self.cooldown = max(0, self.cooldown - 1) # 左右を同時に押すと direction は0。画面の外に出さない。 self.x = max(10, min(WIDTH - 10, self.x + direction * self.speed)) if bank and self.near_basket and self.cargo > 0: self.score += bundle_value(self.cargo) self.cargo = 0 if self.frames % 30 == 1: index = ((self.frames - 1) // 30) % len(SHEET_POSITIONS) self.drops.append(Drop(SHEET_POSITIONS[index])) if self.frames % 110 == 55: # 発生時の着地点を保存する。プレイヤーを後から追尾しない。 self.drops.append(Drop(self.x, kind="dirt")) # 元リストから削除するため、コピーを使って全ての落下物を調べる。 for drop in self.drops[:]: drop.y += 2.0 if drop.kind == "dirt" else 1.5 if drop.y >= GROUND: if abs(self.x - drop.x) < 12: if drop.kind == "sheet": self.cargo = min(CAPACITY, self.cargo + 1) elif self.cooldown == 0: lost = (self.cargo + 1) // 2 self.cargo -= lost self.cooldown = 36 # 30 fpsで1.2秒の連続被弾防止 self.drops.remove(drop) if self.frames >= ROUND_FRAMES: self.done = True # 持ち物は自動で得点にならない。 def main(headless=False, screenshot=None): # 純粋な Game のテストではPyxelを読み込まない。 import pyxel game = Game() def update(): nonlocal game if headless and pyxel.frame_count >= 12: if screenshot: pyxel.screenshot(screenshot, scale=2) pyxel.quit() return if pyxel.btnp(pyxel.KEY_R): game = Game() return direction = int(pyxel.btn(pyxel.KEY_RIGHT)) - int(pyxel.btn(pyxel.KEY_LEFT)) game.step(direction, pyxel.btnp(pyxel.KEY_SPACE)) def draw(): pyxel.cls(1) pyxel.text(8, 8, "GHOST LAUNDRY / STEP 5", 10) pyxel.line(0, GROUND + 8, WIDTH - 1, GROUND + 8, 3) for bx in (15, WIDTH - 15): pyxel.rect(bx - 12, GROUND - 2, 24, 12, 9) pyxel.rectb(bx - 12, GROUND - 2, 24, 12, 4) pyxel.text(8, 20, f"SCORE {game.score} CARRY {game.cargo}/{CAPACITY}", 7) pyxel.text(8, 30, f"BANK +{bundle_value(game.cargo)}", 6) for drop in game.drops: if drop.kind == "dirt": pyxel.circ(drop.x, drop.y, 4, 8) pyxel.line(drop.x - 4, drop.y - 4, drop.x + 4, drop.y + 4, 0) pyxel.line(drop.x + 4, drop.y - 4, drop.x - 4, drop.y + 4, 0) else: pyxel.rect(drop.x - 5, drop.y - 3, 10, 6, 7) x = game.x pyxel.circ(x, GROUND - 6, 7, 7) pyxel.rect(x - 7, GROUND - 6, 15, 11, 7) pyxel.pset(x - 3, GROUND - 7, 1) pyxel.pset(x + 3, GROUND - 7, 1) for i in range(game.cargo): pyxel.rect(x - 5, GROUND - 18 - i * 4, 10, 3, 6) # 予告は最後に描く。持ち物に隠れないよう地面の下へ置く。 for drop in game.drops: if drop.kind == "dirt": pyxel.line(drop.x - 4, GROUND + 10, drop.x + 4, GROUND + 16, 8) pyxel.line(drop.x + 4, GROUND + 10, drop.x - 4, GROUND + 16, 8) else: pyxel.line(drop.x - 5, GROUND + 10, drop.x + 5, GROUND + 10, 6) if game.cooldown > 0: pyxel.rectb(x - 9, GROUND - 16, 19, 23, 8) left = max(0, (ROUND_FRAMES - game.frames + FPS - 1) // FPS) pyxel.text(168, 30, f"TIME {left:02}", 10) if game.done: pyxel.rect(29, 58, 182, 52, 0) label = "GOAL!" if game.score >= GOAL else "TIME UP" pyxel.text(90, 65, label, 10) pyxel.text(40, 79, f"SCORE {game.score} / GOAL {GOAL}", 7) pyxel.text(40, 90, "CARRY IS NOT BANKED. R: RETRY", 6) pyxel.text(8, 164, "ARROWS: MOVE SPACE: BANK R: RESET", 6) pyxel.init(WIDTH, HEIGHT, title="Ghost Laundry - Stage 5", fps=FPS, headless=headless) pyxel.run(update, draw) if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--headless-smoke", action="store_true", help="12 frames then quit; not a playtest") parser.add_argument("--screenshot", help="PNG path used with --headless-smoke") args = parser.parse_args() main(headless=args.headless_smoke, screenshot=args.screenshot)