# MIT License # # Copyright (c) 2026 Pyxel 100 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_result.py 01〜04はメーター観察の練習、05は小魚1匹を釣るゲームです。 日本語フォント素材は使わず、標準フォントと図形だけで描きます。 """ WIDTH, HEIGHT = 280, 210 FPS = 30 CRUISE_FRAMES = 36 WARNING_FRAMES = 24 REST_FRAMES = 24 CYCLE_FRAMES = CRUISE_FRAMES + WARNING_FRAMES + REST_FRAMES TURN_FRAME = CRUISE_FRAMES + WARNING_FRAMES - 1 # 0始まりなので59 MIN_RELEASE = 3 MAX_RELEASE = 18 REEL_DELAY = 5 class Game: def __init__(self): self.distance = 70.0 self.held = False self.reeled = 0 self.released = 0 self.tension = 20.0 self.tick = 0 self.feedback = "" self.feedback_ticks = 0 self.stamina = 0 self.good_turns = 0 self.status = "play" self.paused = False def toggle_pause(self): self.paused = not self.paused @property def local(self): return self.tick % CYCLE_FRAMES @property def phase(self): if self.local < CRUISE_FRAMES: return "cruise" if self.local < CRUISE_FRAMES + WARNING_FRAMES: return "warning" return "rest" @property def cue(self): # この教材にはフェイントがない。予兆は全て本物の外向き矢印。 return "out" if self.phase == "warning" else "" @property def turn_in(self): # 次のstepを含む残り回数。予兆開始で24、判定直前で1。 return TURN_FRAME - self.local + 1 if self.phase == "warning" else 0 def step(self, held=False): if self.paused or self.status != "play": return self.held = held # btnでは長押しを読む。離したフレームで反対のカウンターを0にする。 self.reeled = self.reeled + 1 if held else 0 self.released = 0 if held else self.released + 1 self.feedback_ticks = max(0, self.feedback_ticks - 1) if held: # 1〜5フレーム目はたるみ取り。6フレーム目から距離が減る。 if self.reeled > REEL_DELAY: self.distance -= 0.42 + 0.12 * self.stamina self.tension += 0.20 if self.phase == "rest" else 0.60 else: self.distance += 0.45 self.tension = max(0.0, self.tension - 1.30) if self.local == TURN_FRAME: # 先に今回の入力を数えたので、releasedには今回も含まれる。 if MIN_RELEASE <= self.released <= MAX_RELEASE: self.distance -= 24 self.stamina = min(3, self.stamina + 1) self.good_turns += 1 self.feedback = "GOOD TURN! DISTANCE -24" else: self.tension += 65 if held else 16 self.distance += 8 self.feedback = "MISSED TURN!" self.feedback_ticks = 35 # 同時に到達したら、糸切れ > 逃走 > 釣り上げの順で決める。 if self.tension >= 100: self.status = "cut" elif self.distance >= 100: self.status = "escape" elif self.distance <= 0: self.status = "caught" self.tick += 1 def main(headless=False, screenshot=None): # importしてGameを試すだけならPyxelは不要。描画のときだけ読み込む。 import pyxel from pathlib import Path # Pyxel初期化より前に、呼び出した場所からの保存先を確定する。 if screenshot: screenshot = str(Path(screenshot).expanduser().resolve()) game = Game() frames = 0 screenshot_saved = False def update(): nonlocal game, frames # 12回の更新と12回の描画の後、次の更新で終了する。 if headless and frames >= 12: pyxel.quit() return frames += 1 if pyxel.btnp(pyxel.KEY_R): game = Game() return if pyxel.btnp(pyxel.KEY_P): game.toggle_pause() return game.step(pyxel.btn(pyxel.KEY_SPACE)) def bar(y, label, value, color): shown = max(0.0, min(100.0, value)) pyxel.text(10, y, label, 7) pyxel.rect(64, y, 164, 7, 0) pyxel.rect(65, y + 1, int(shown * 1.62), 5, color) pyxel.text(237, y, f"{shown:5.1f}", color) def draw(): nonlocal screenshot_saved pyxel.cls(1) pyxel.text(10, 8, "THE LAST FISH / STEP 5", 10) pyxel.text(10, 19, "CATCH ONE FISH. DISTANCE 0 = WIN", 6) pyxel.text(10, 35, "SPACE: " + ("HELD / REEL" if game.held else "RELEASED / SLACK"), 7) pyxel.text(10, 46, f"HELD {game.reeled:3}f RELEASED {game.released:3}f", 6) bar(60, "DIST", game.distance, 12) bar(74, "TENSION", game.tension, 8 if game.tension >= 75 else 10) # 距離が0なら岸側、100なら画面右。状態の計算はここでしない。 x = 54 + max(0.0, min(100.0, game.distance)) * 1.90 pyxel.rect(0, 134, WIDTH, 38, 5) pyxel.line(0, 140, WIDTH - 1, 140, 6) pyxel.rect(0, 131, 38, 9, 4) pyxel.line(24, 133, 42, 122, 9) pyxel.line(42, 122, x, 151, 7) pyxel.elli(x - 9, 146, 18, 10, 10) pyxel.tri(x + 8, 151, x + 16, 145, x + 16, 157, 10) pyxel.pset(x - 5, 149, 0) pyxel.text(10, 92, f"PHASE {game.phase.upper():7} CYCLE {game.local:02}/{CYCLE_FRAMES}", 6) if game.cue: pyxel.line(15, 111, 37, 111, 10) pyxel.tri(43, 111, 34, 106, 34, 116, 10) pyxel.text(51, 103, f"REAL TURN IN {game.turn_in:02} FRAMES", 10) pyxel.text(51, 115, "RELEASE FOR 3-18f AT THE TURN", 7) elif game.feedback_ticks: pyxel.text(10, 109, game.feedback, 10) pyxel.text(10, 178, f"STAMINA {game.stamina}/3 GOOD TURNS {game.good_turns}", 14) if 0 < game.reeled <= REEL_DELAY: pyxel.text(147, 35, "TAKING UP SLACK", 10) if game.status != "play": pyxel.rect(8, 89, 264, 54, 0) pyxel.rectb(8, 89, 264, 54, 10) label = {"caught": "CAUGHT!", "cut": "LINE CUT!", "escape": "FISH ESCAPED!"}[game.status] pyxel.text(55, 105, label, 10 if game.status == "caught" else 8) pyxel.text(55, 120, "R: TRY THE SAME FISH AGAIN", 7) if game.paused: pyxel.rect(64, 97, 152, 42, 0) pyxel.rectb(64, 97, 152, 42, 6) pyxel.text(78, 108, "PAUSED / P: RESUME", 10) pyxel.text(78, 121, "INPUT COUNTERS STOP TOO", 7) pyxel.text(10, 196, "SPACE: REEL R: RETRY P: PAUSE ESC: QUIT", 6) # 12回目に描いた本物のPyxel画面を保存。自動プレイ画像ではない。 if screenshot and frames >= 12 and not screenshot_saved: pyxel.screenshot(screenshot, scale=2) screenshot_saved = True pyxel.init(WIDTH, HEIGHT, title="The Last Fish - 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="Render 12 real Pyxel frames then quit; not a full playtest") parser.add_argument("--screenshot", metavar="PATH", help="Save the 12th rendered frame as a PNG") args = parser.parse_args() main(headless=args.headless_smoke, screenshot=args.screenshot)