# 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と図形のみで描画します。 """三手の剣舞・教材 1/5。単独で実行できる段階別プログラム。""" from dataclasses import dataclass, replace, asdict ACTIONS = ('F', 'B', 'W') @dataclass(frozen=True) class State: player: int = 3 enemy: int = 6 turns: int = 0 def initial(): return State() def transition(s, action): if action not in ACTIONS: return s candidate = s.player + {'F': 1, 'B': -1, 'W': 0}[action] if not 1 <= candidate <= 9: return s # 盤外入力は手数を使わない。 if candidate >= s.enemy: candidate = s.player # 同じマスへ入れない。有効な1手として数える。 return State(candidate, s.enemy, s.turns + 1) 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', '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 1', 10) pyxel.text(10, 21, 'TRAINING: MOVEMENT ONLY', 13) pyxel.text(10, 35, f'TURN {min(s.turns,9999)} DISTANCE {s.enemy - s.player}', 7) pyxel.text(10, 48, 'TARGET: REACH CELL 5; NO DAMAGE YET', 6) pyxel.text(10, 74, 'HISTORY: NOT ADDED IN THIS STAGE', 5) pyxel.text(10, 109, 'ENEMY DOES NOT MOVE OR ATTACK.', 13) 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, 216, 'F/B: ONE CELL. W: ONE TURN WITHOUT MOVING.', 6) pyxel.text(10, 228, 'WALL INPUT: NO TURN. CONTACT: STAY, USE TURN.', 13) pyxel.text(10, 252, 'LEFT/A: BACK RIGHT/D: FORWARD SPACE: WAIT', 7) pyxel.text(10, 266, '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 1', 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 'FBWK' for action in actions): parser.error('Supported replay actions: FBWK') 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)