# 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. # # The bundled Noto-derived bitmap glyphs are separately licensed under SIL OFL. # See assets/NOTO-LICENSE.txt. # この教材はNoto字形を同梱せず、ASCIIと図形のみで描画します。 """三手の剣舞・教材 3/5。単独で実行できる段階別プログラム。""" from dataclasses import dataclass, replace, asdict ACTIONS = ('F', 'S', 'B', 'W') @dataclass(frozen=True) class Memory: actions: tuple = () @dataclass(frozen=True) class Intent: kind: str move: int ranges: tuple counter: str @dataclass(frozen=True) class State: player: int enemy: int enemy_hp: int memory: Memory intent: Intent turns: int = 0 outcome: str = 'playing' def policy(memory): # 呼び出し時に確定済みの過去3手だけを読む。今回の入力は引数にしない。 recent = memory.actions[-3:] counts = {a: recent.count(a) for a in ('F', 'S', 'B')} most = 'F' # 空履歴・Wだけなら前進への対策。 maximum = max(counts.values()) for action in reversed(recent): if action in counts and counts[action] == maximum: most = action # 同数なら新しい方から最初に見つかった手。 break if most == 'F': return Intent('guard', 0, (1, 2), most) if most == 'S': return Intent('retreat', 1, (2,), most) return Intent('chase', -2, (), most) def initial(): memory = Memory() return State(3, 6, 5, memory, policy(memory)) def transition(s, action): if s.outcome != 'playing' or action not in ACTIONS: return s candidate = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action] if not 1 <= candidate <= 9: return s if candidate >= s.enemy: candidate = s.player # 03は読みを表示する練習版。相手のIntentはまだ実行しない。 combo = bool(s.memory.actions and s.memory.actions[-1] == 'S') damage = (2 if combo else 1) if action == 'S' and s.enemy - candidate == 1 else 0 hp = max(0, s.enemy_hp - damage) outcome = 'won' if hp == 0 else 'playing' memory = Memory((s.memory.actions + (action,))[-3:]) return State(candidate, s.enemy, hp, memory, policy(memory), s.turns + 1, outcome) class Game: def __init__(self): self.reset() def reset(self): self.state = initial() def act(self, action): self.state = transition(self.state, action) KEY_ACTIONS = {'LEFT': 'B', 'A': 'B', 'UP': 'S', 'Z': 'S', 'RIGHT': 'F', 'D': 'F', 'SPACE': 'W'} class Controller: """Pyxelの押下とゲーム1手を分離。合成入力でも同じ窓口を使う。""" def __init__(self): self.game = Game() self.release_gate = False def tick(self, pressed=(), held=()): pressed, held = set(pressed), set(held) controls = set(KEY_ACTIONS) | {'R', 'X'} if self.release_gate: if not (held & controls): self.release_gate = False return if 'R' in pressed: self.game.reset() self.release_gate = True return chosen = {action for key, action in KEY_ACTIONS.items() if key in pressed} held_actions = {action for key, action in KEY_ACTIONS.items() if key in held} if len(chosen) == 1 and len(held_actions) == 1: self.game.act(next(iter(chosen))) def main(headless=False, screenshot=None, actions='', frames=12, report=None): import json from pathlib import Path # Pyxel初期化によるcwd変更前に利用者の出力先を確定する。 screenshot = str(Path(screenshot).resolve()) if screenshot else None report = str(Path(report).resolve()) if report else None import pyxel controller = Controller() events = [] tick = 0 keys = {key: getattr(pyxel, 'KEY_' + key) for key in (*KEY_ACTIONS, 'R', 'X')} replay_keys = {'F': 'RIGHT', 'B': 'LEFT', 'W': 'SPACE', 'S': 'Z', 'K': 'R', 'X': 'X'} def update(): nonlocal tick tick += 1 if headless: if tick <= len(actions): # QA専用の合成押下。OSキーボード操作・人の試遊ではない。 key = replay_keys[actions[tick - 1]] controller.tick((), ()) controller.tick((key,), (key,)) events.append({'tick': tick, 'action': actions[tick - 1], 'state': asdict(controller.game.state)}) else: controller.tick([key for key, value in keys.items() if pyxel.btnp(value)], [key for key, value in keys.items() if pyxel.btn(value)]) def fighter(position, y, color, direction): x = 32 + (position - 1) * 42 pyxel.circ(x, y - 13, 5, color) pyxel.line(x, y - 7, x, y + 8, color) pyxel.line(x, y + 8, x - 6, y + 16, color) pyxel.line(x, y + 8, x + 6, y + 16, color) pyxel.line(x, y - 3, x + direction * 12, y - 9, color) pyxel.line(x + direction * 12, y - 9, x + direction * 21, y - 20, 7) def draw(): s = controller.game.state pyxel.cls(1) pyxel.text(10, 8, 'THREE MOVE DANCE / STEP 3', 10) pyxel.text(10, 21, 'TRAINING: INTENT IS READ-ONLY', 13) pyxel.text(10, 35, f'TURN {min(s.turns,9999)} DISTANCE {s.enemy - s.player}', 7) pyxel.text(10, 48, f'ENEMY HP {s.enemy_hp}/5 {s.outcome.upper()}', 11 if s.outcome == 'won' else 7) history = ('-',) * (3 - len(s.memory.actions)) + s.memory.actions pyxel.text(10, 66, 'PAST 3 COMMITTED ACTIONS: OLD -> NEW', 6) for i, action in enumerate(history): x = 10 + i * 57 pyxel.rectb(x, 77, 49, 18, 6) pyxel.text(x + 22, 83, action, 7) pyxel.text(196, 81, 'F=FORWARD S=SLASH', 13) pyxel.text(196, 90, 'B=BACK W=WAIT', 13) pyxel.text(10, 109, f'NEXT: {s.intent.kind.upper()} READ: {s.intent.counter}', 10) for position in range(1, 10): x = 32 + (position - 1) * 42 pyxel.rectb(x - 19, 133, 38, 57, 5) pyxel.text(x - 2, 194, str(position), 13) fighter(s.player, 166, 6, 1) fighter(s.enemy, 161, 8, -1) pyxel.text(10, 210, 'PREVIOUS ACTION S: NEXT HIT DEALS 2.', 6) pyxel.text(10, 223, 'W TAKES A MEMORY SLOT, BUT GETS NO VOTES.', 13) pyxel.text(10, 236, 'NEXT IS ONLY A PREVIEW IN STEP 3.', 10) pyxel.text(10, 252, 'LEFT/A: BACK UP/Z: SLASH RIGHT/D: FORWARD', 7) pyxel.text(10, 266, 'SPACE: WAIT R: RESET ESC: QUIT', 7) pyxel.text(10, 284, 'NO TIME LIMIT / SILENT / R TO RESTART', 13) if headless and tick >= frames: if screenshot: pyxel.screenshot(screenshot, scale=2) if report: Path(report).write_text(json.dumps({ 'label': 'Synthetic input; actual Pyxel headless render; silent', 'frames': tick, 'actions': actions, 'events': events, 'final_state': asdict(s), }, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') pyxel.quit() pyxel.init(400, 302, title='Three Move Dance - Stage 3', fps=30, headless=headless) pyxel.run(update, draw) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--headless-replay', help='Synthetic actions; K resets; not OS keys') parser.add_argument('--actions', default='', help='Synthetic actions within a 12-frame smoke') parser.add_argument('--screenshot', help='PNG path, headless only') parser.add_argument('--report', help='UTF-8 JSON path, headless only') args = parser.parse_args() if args.headless_smoke and args.headless_replay is not None: parser.error('Choose smoke or replay, not both') actions = args.actions if args.headless_replay is None else args.headless_replay if any(action not in 'FSBWK' for action in actions): parser.error('Supported replay actions: FSBWK') headless = args.headless_smoke or args.headless_replay is not None if not headless and (args.actions or args.screenshot or args.report): parser.error('actions, screenshot and report require a headless mode') if args.headless_smoke and len(actions) > 12: parser.error('Smoke accepts at most 12 actions; use replay') frames = 12 if args.headless_replay is None else max(12, len(actions) + 2) main(headless, args.screenshot, actions, frames, args.report)