# 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. """盾のない騎士・第1/5段階。詳細はLESSON.md。""" from dataclasses import dataclass, replace, asdict from math import hypot import json, hashlib WALK_SPEED = 15 @dataclass(frozen=True) class Level: title: str start: tuple enemies: tuple = () wall: tuple | None = None LEVELS = (Level('歩く練習', (300, 520)),) @dataclass(frozen=True) class Input: dx: int = 0 dy: int = 0 sword: bool = False dodge: bool = False @dataclass(frozen=True) class State: x: int y: int facing: tuple = (1000, 0) tick: int = 0 status: str = 'play' enemies: tuple = () def initial(level=0): b = LEVELS[level] if isinstance(level, int) else level return State(*b.start) def unit(dx, dy): length = hypot(dx, dy) return (round(dx / length * 1000), round(dy / length * 1000)) if length else (1000, 0) def valid(b, x, y, r=50): return 80 + r <= x <= 1520 - r and 80 + r <= y <= 960 - r def move(b, x, y, direction, speed): xx = x + round(direction[0] * speed / 1000) yy = y + round(direction[1] * speed / 1000) if valid(b, xx, y): x = xx if valid(b, x, yy): y = yy return (x, y) def step(b, s, inp=Input()): dx = max(-1, min(1, inp.dx)) dy = max(-1, min(1, inp.dy)) direction = unit(dx, dy) if dx or dy else s.facing x, y = move(b, s.x, s.y, direction, WALK_SPEED) if dx or dy else (s.x, s.y) return replace(s, x=x, y=y, facing=direction, tick=s.tick + 1) def state_hash(s): return hashlib.sha256(json.dumps(asdict(s), sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest() from pathlib import Path import argparse STAGE = 1 DEFAULT_LEVEL = 'A' LEVEL_NAMES = ('A',) DEMO_RUNS = {'A': ((10, 1, 0, 0, 0), (10, 0, 1, 0, 0), (10, 1, 1, 0, 0))} def demo_input(frame, level): """1始まりのフレーム。各行は(回数, dx, dy, sword, dodge)。""" for count, dx, dy, sword, dodge in DEMO_RUNS[level]: if frame <= count: return Input(dx, dy, bool(sword), bool(dodge)) frame -= count return Input() class Controller: """キー集合を意味入力へ。R/Pのあとだけ全キー解放を待つ。""" def __init__(self, level=DEFAULT_LEVEL): self.b = LEVELS[LEVEL_NAMES.index(level)] self.s = initial(self.b) self.paused = False self.locked = False self.previous = set() def feed(self, held): held = set(held) pressed = held - self.previous self.previous = held if 'R' in pressed: self.s = initial(self.b) self.paused = False self.locked = True return if self.locked: if not held: self.locked = False fields = {name: False for name in ('prev_sword', 'prev_dodge') if hasattr(self.s, name)} self.s = replace(self.s, **fields) return if 'P' in pressed: self.paused = not self.paused self.locked = True return if self.paused: return inp = Input(int('RIGHT' in held) - int('LEFT' in held), int('DOWN' in held) - int('UP' in held), 'Z' in held, 'X' in held) self.s = step(self.b, self.s, inp) class App: def __init__(self, args, pyxel): self.p = pyxel self.args = args self.c = Controller(args.level) self.frame = 0 self.saved = False self.p.init(384, 320, title='032 Shieldless Knight - Stage ' + str(STAGE), fps=30, display_scale=2, headless=args.headless) self.p.run(self.update, self.draw) def update(self): if self.saved or self.p.btnp(self.p.KEY_ESCAPE): self.p.quit() return self.frame += 1 if self.args.still: return if self.args.demo: self.c.s = step(self.c.b, self.c.s, demo_input(self.frame, self.args.level)) else: names = ('UP', 'DOWN', 'LEFT', 'RIGHT', 'Z', 'X', 'P', 'R') self.c.feed({k for k in names if self.p.btn(getattr(self.p, 'KEY_' + k))}) def point(self, x, y): return (32 + x / 5, 45 + y / 5) def draw(self): p, s = (self.p, self.c.s) p.cls(1) p.text(12, 8, '032 SHIELDLESS KNIGHT STAGE ' + str(STAGE), 7) hp = getattr(s, 'hp', '-') p.text(12, 22, f'LEVEL {self.args.level} TICK {s.tick} HP {hp} {s.status.upper()}', 10) p.text(12, 34, f'POSITION (tenths): {s.x},{s.y}', 6) p.rect(48, 61, 288, 176, 3) p.rectb(46, 59, 292, 180, 11) if self.c.b.wall: x1, y1, x2, y2 = self.c.b.wall xx, yy = self.point(x1, y1) p.rect(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 5) p.rectb(xx, yy, (x2 - x1) / 5, (y2 - y1) / 5, 13) xx, yy = self.point(s.x, s.y) p.circ(xx, yy, 6, 12) d = s.sword_dir if getattr(s, 'sword', 0) else s.facing active = 13 <= getattr(s, 'sword', 0) <= 18 length = 36 if active else 16 p.line(xx, yy, xx + d[0] * length / 1000, yy + d[1] * length / 1000, 10 if active else 7) if getattr(s, 'dash', 0): p.circb(xx, yy, 12, 6) p.rect(0, 242, 384, 78, 1) p.text(12, 246, 'ARROWS MOVE / R RESET / P PAUSE / ESC QUIT', 7) p.text(12, 257, 'MOVEMENT ONLY: NO ENEMIES OR CLEAR YET', 6) p.text(12, 269, f'WALK {WALK_SPEED} / X 130..1470 / Y 130..910', 7) p.text(12, 281, 'BLUE: YOU / WHITE LINE: FACING', 13) p.text(12, 293, 'NO ATTACK OR CLEAR IN THIS PRACTICE', 13) label = 'PAUSED' if self.c.paused else 'ALL ENEMIES DEFEATED' if s.status == 'won' else 'DEFEATED - R TO RESET' if s.status == 'lost' else '' if label: p.rect(85, 135, 220, 20, 1) p.text(100, 142, label, 10) if self.args.frames is not None and self.frame >= self.args.frames: if self.args.output: self.args.output.mkdir(parents=True, exist_ok=True) stem = f'stage{STAGE}_{self.args.level}_{self.frame:03}' p.screenshot(str(self.args.output / (stem + '.png')), scale=2) report = {'stage': STAGE, 'level': self.args.level, 'frames': self.frame, 'headless': self.args.headless, 'demo': self.args.demo, 'label': 'Actual Pyxel render, fixed input, silent; not human playtest', 'state': asdict(s), 'state_hash': state_hash(s)} (self.args.output / (stem + '.json')).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') self.saved = True def parse_args(argv=None): parser = argparse.ArgumentParser(description='盾のない騎士・第' + str(STAGE) + '段階') parser.add_argument('--level', choices=LEVEL_NAMES, default=DEFAULT_LEVEL) parser.add_argument('--headless', action='store_true') parser.add_argument('--demo', action='store_true', help='固定入力を再生する') parser.add_argument('--still', action='store_true', help='状態を進めず描画する(検証用)') parser.add_argument('--frames', type=int, help='描画フレーム数。1以上') parser.add_argument('--output', type=Path, help='画像とUTF-8状態JSONの保存先') args = parser.parse_args(argv) if args.frames is not None and args.frames < 1: parser.error('--frames must be >= 1') if args.headless and args.frames is None: parser.error('--headless requires --frames') if args.output is not None and args.frames is None: parser.error('--output requires --frames') return args def main(argv=None): args = parse_args(argv) import pyxel App(args, pyxel) if __name__ == '__main__': main()