# 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と図形のみで描画します。 """三手の剣舞・教材 5/5。単独で実行できる段階別プログラム。""" from dataclasses import dataclass, replace, asdict ACTIONS = ('F', 'S', 'B', 'W') @dataclass(frozen=True) class Level: title: str player: int enemy: int history: tuple LEVEL = Level('一回斬って、間を置く', 3, 6, ()) # 原作Aだけを収録。 @dataclass(frozen=True) class Observation: distance: int own_position: int @dataclass(frozen=True) class Memory: actions: tuple recovering: bool @dataclass(frozen=True) class Intent: kind: str move: int ranges: tuple counter: str @dataclass(frozen=True) class State: player: int enemy: int hp: int enemy_hp: int memory: Memory intent: Intent turns: int = 0 outcome: str = 'playing' @dataclass(frozen=True) class Result: state: State accepted: bool reason: str dealt: int = 0 taken: int = 0 collision: bool = False def observe(s): return Observation(s.enemy - s.player, s.enemy) def policy(observation, memory): # observationは原作との共通窓口。この方策では過去の手と回復だけを使う。 # 今回の未確定入力を引数に渡さない。 if memory.recovering: return Intent('breath', 0, (), '-') recent = memory.actions[-3:] counts = {a: recent.count(a) for a in ('F', 'S', 'B')} most = 'F' 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(LEVEL.history, False) s = State(LEVEL.player, LEVEL.enemy, 3, 5, memory, Intent('', 0, (), '')) return replace(s, intent=policy(observe(s), memory)) def enemy_proposal(s): # 追い足は自分の旧位置の隣まで、引き突きは右端9まで。 return max(s.player + 1, min(9, s.enemy + s.intent.move)) def transition(s, action): if s.outcome != 'playing': return Result(s, False, 'terminal') if action not in ACTIONS: return Result(s, False, 'invalid') old_intent = s.intent # 1. 入力前から公開済みの構えを固定。 player = s.player + {'F': 1, 'S': 0, 'B': -1, 'W': 0}[action] if not 1 <= player <= 9: return Result(s, False, 'edge') enemy = enemy_proposal(s) # 2. 両者の旧位置から候補を作る。 collision = player >= enemy if collision: player, enemy = s.player, s.enemy # 3. 衝突なら双方を元に戻す。 distance = enemy - player combo = bool(s.memory.actions and s.memory.actions[-1] == 'S') dealt = (2 if combo else 1) if action == 'S' and distance == 1 else 0 taken = 1 if distance in old_intent.ranges else 0 hp = max(0, s.hp - taken) # 4. 最終距離で同時にダメージ。 enemy_hp = max(0, s.enemy_hp - dealt) if hp == 0 and enemy_hp == 0: outcome = 'mutual' elif hp == 0: outcome = 'lost' elif enemy_hp == 0: outcome = 'won' else: outcome = 'playing' # 5. 今回の手をここで初めて確定履歴へ。空振りSも残す。 memory = Memory((s.memory.actions + (action,))[-3:], old_intent.kind in ('guard', 'retreat')) new = State(player, enemy, hp, enemy_hp, memory, old_intent, s.turns + 1, outcome) new = replace(new, intent=policy(observe(new), memory)) reason = ('mutual' if outcome == 'mutual' else 'hit' if dealt else 'hurt' if taken else 'clash' if collision else 'miss' if action == 'S' else 'moved') return Result(new, True, reason, dealt, taken, collision) @dataclass(frozen=True) class Timeline: state: State past: tuple = () def step(timeline, action): if action == 'undo': if timeline.past: return Timeline(timeline.past[-1], timeline.past[:-1]), None return timeline, None result = transition(timeline.state, action) if not result.accepted: return timeline, result return Timeline(result.state, timeline.past + (timeline.state,)), result class Game: def __init__(self): self.reset() @property def state(self): return self.timeline.state def reset(self): self.timeline = Timeline(initial()) self.last = None def act(self, action): self.timeline, self.last = step(self.timeline, 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 if 'X' in pressed: self.game.act('undo') 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 5', 10) pyxel.text(10, 21, 'ORIGINAL A RULES / FULL UNDO', 13) pyxel.text(10, 35, f'TURN {min(s.turns,9999)} DISTANCE {s.enemy - s.player}', 7) pyxel.text(10, 48, f'HP {s.hp}/3 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) proposal = enemy_proposal(s) if position == proposal: pyxel.text(x - 8, 123, 'NEXT', 10) if proposal - position in s.intent.ranges: for offset in range(-14, 15, 7): pyxel.line(x + offset, 182, x + offset + 4, 174, 8) fighter(s.player, 166, 6, 1) fighter(s.enemy, 161, 8, -1) pyxel.text(10, 210, 'NEXT: CANDIDATE. COLLISION CANCELS BOTH MOVES.', 13) pyxel.text(10, 223, 'RED: ATTACK DISTANCES / PREVIOUS S: POWER 2', 13) pyxel.text(10, 236, 'X RESTORES HP, MEMORY, INTENT AND TURN.', 6) pyxel.text(10, 252, 'LEFT/A: BACK UP/Z: SLASH RIGHT/D: FORWARD', 7) pyxel.text(10, 266, 'SPACE: WAIT X: UNDO 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 5', 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 'FSBWKX' for action in actions): parser.error('Supported replay actions: FSBWKX') 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)