# -*- coding: utf-8 -*- # LICENSE.txt (unchanged text): # 022 Word Bridge / ことばの橋 # Original code and pixel shapes: provided for the user's game project. # Bundled Japanese bitmap glyphs were rendered from Noto Sans CJK Regular. # Noto font data is licensed under SIL Open Font License 1.1. # See assets/NOTO-LICENSE.txt for copyright and the complete license. # Glyph generation: tools/build_font.py. Pillow is a development-only dependency. # Runtime engine: Pyxel 2.9.9 (MIT license, installed separately by pip). # No downloaded sprites, music, sound recordings, network services or LLM models. # DISTRIBUTION_PERMISSION.txt (unchanged text): # 022 ことばの橋:公開・教材配布の補足 # 記録日:2026年10月6日 # # この作品のコードと自作図形、および本講座用の段階教材は、プロジェクト所有者の承認に基づき、 # 「Pyxel入門 Pythonゲームの作り方」で学習・改造用のソース付き教材として配布しています。 # 教材をダウンロードし、内容を読んで学び、ご自身の制作のために変更して試すことができます。 # # 原納品物の LICENSE.txt は変更せずに同梱しています。 # この補足は今回の公開・教材配布と学習利用の許諾を記録するものであり、原LICENSEをMIT本文へ # 置き換えたり、元からMITの記載があったものとして扱ったりするものではありません。 # 本作品と原作の権利表示を、ほかの作品の権利表示で上書きしないでください。 # # コード・図形と外部ライブラリー・字形データの条件は区別します。 # ・ゲームの図形は、Pyxelの矩形・線・円・三角形などで描いたものです。 # ・元の完成版の日本語字形はNoto Sans CJK Regular由来です。 # SIL Open Font License 1.1の原文を assets/NOTO-LICENSE.txt に保持しています。 # ・Pyxelは別途インストールする外部ライブラリーです。Pyxel自身のライセンスが適用されます。 # ・段階教材の画面は標準ASCII描画を使い、Noto字形データの追加導入を必要としません。 # # 元コード・図形の提供に関する記載、外部由来の字形の著作権とライセンスは、原本のまま残しています。 """022 Word Bridge: a standalone, incrementally runnable teaching stage. Original notice: LICENSE.txt. Teaching distribution: DISTRIBUTION_PERMISSION.txt. No image/font file is needed. Pyxel is imported only when the window starts. """ from dataclasses import dataclass, replace, asdict from pathlib import Path import json STAGE = 2 STAGE_TITLE = 'SAFE GRID MOVEMENT' CONTROLS = ['ARROWS MOVE ONE TILE / NO RULE SWAP IN STAGE 02', 'R RESET / Q TITLE / ESC QUIT / RELEASE EVERY KEY', 'STARTS WITH LIGHT FLOATING AS A MOVEMENT EXERCISE'] KEYS = {'UP', 'DOWN', 'LEFT', 'RIGHT', 'SPACE', 'R', 'Q'} DIRECTIONS = {'UP': (0, -1), 'DOWN': (0, 1), 'LEFT': (-1, 0), 'RIGHT': (1, 0)} MOVEMENT_PRIORITY = ('UP', 'DOWN', 'LEFT', 'RIGHT') LIGHT_X = range(1, 6) @dataclass(frozen=True) class Level: width: int = 7 height: int = 5 dry: frozenset = frozenset(((0, 2), (6, 2))) light: frozenset = frozenset((x, 2) for x in LIGHT_X) heavy: frozenset = frozenset((x, 1) for x in (1, 2, 3)) start: tuple = (0, 2) goal: tuple = (6, 2) LEVELS = (Level(),) def opposite(floating): return 'H' if floating == 'L' else 'L' def rules(state): # The two complete assignments always contain one FLOAT and one SINK. return {'L': 'FLOAT' if state.floating == 'L' else 'SINK', 'H': 'FLOAT' if state.floating == 'H' else 'SINK'} def terrain(level, position): if position in level.dry: return 'DRY' if position in level.light: return 'LIGHT' if position in level.heavy: return 'HEAVY' return 'WATER' def walkable(level, position, floating): return position in level.dry or position in (level.light if floating == 'L' else level.heavy) INITIAL_FLOATING = 'L' @dataclass(frozen=True) class State: position: tuple floating: str moves: int = 0 changes: int = 0 status: str = 'playing' @dataclass(frozen=True) class Result: state: State event: str changed: bool = False def initial(level): return State(level.start, INITIAL_FLOATING) def transition(level, state, action): if state.status == 'success': return Result(state, 'terminal') if action not in DIRECTIONS: return Result(state, 'invalid') dx, dy = DIRECTIONS[action] destination = (state.position[0] + dx, state.position[1] + dy) if not walkable(level, destination, state.floating): event = 'sunk' if destination in level.light | level.heavy else 'water' return Result(state, event) end = destination == level.goal return Result(replace(state, position=destination, moves=state.moves + 1, status='success' if end else 'playing'), 'success' if end else 'move', True) class Game: def __init__(self): self.level = 0 self.title = True self.reset(False) @property def layout(self): return LEVELS[self.level] def reset(self, gate=True): self.state = initial(self.layout) self.gate = gate self.event = 'ready' def act(self, action): result = transition(self.layout, self.state, action) self.event = result.event self.state = result.state if self.state.status == 'success': self.gate = True return result def update(self, held, edges): # Mode changes require an entirely neutral frame, including new edges. if self.gate: if not (held | edges) & KEYS: self.gate = False return if self.title: if 'SPACE' in edges: self.title = False self.gate = True return if 'Q' in edges: self.title = True self.gate = True return if 'R' in edges: self.reset() return if self.state.status == 'success': return for key in MOVEMENT_PRIORITY: if key in edges: self.act(key) break class App: """Turn sampled keys into new edges; drawing cannot change the model.""" def __init__(self): self.game = Game() self.previous = set() def feed(self, held): held = set(held) edges = held - self.previous self.previous = held self.game.update(held, edges) def draw(self, p): g = self.game level, state = g.layout, g.state editing = getattr(g, 'editing', False) paused = getattr(g, 'paused', False) p.cls(1) p.text(16, 12, '022 WORD BRIDGE / STAGE ' + str(STAGE), 10) p.text(16, 27, 'LAYOUT ' + 'ABC'[g.level] + ' / ' + STAGE_TITLE, 7) p.text(16, 45, 'L = LIGHT / H = HEAVY / S = START / G = GOAL', 6) cell, ox, oy = 36, (512 - level.width * 36) // 2, 70 for y in range(level.height): for x in range(level.width): pos = (x, y) xx, yy = ox + x * cell, oy + y * cell p.rectb(xx, yy, cell, cell, 5) p.line(xx + 7, yy + 27, xx + 15, yy + 27, 5) if pos in level.dry: p.rect(xx + 2, yy + 2, cell - 4, cell - 4, 4) p.rectb(xx + 2, yy + 2, cell - 4, cell - 4, 9) p.text(xx + 4, yy + 5, 'S' if pos == level.start else 'G' if pos == level.goal else 'ISLE', 10) elif pos in level.light | level.heavy: category = 'L' if pos in level.light else 'H' active = category == state.floating color = 11 if category == 'L' else 15 if active: p.rect(xx + 5, yy + 5, cell - 10, cell - 10, color) p.text(xx + 15, yy + 14, category, 1) else: p.rectb(xx + 6, yy + 11, cell - 12, cell - 15, 5) p.text(xx + 15, yy + 16, category, 6) if editing: if category == g.proposed: for a in range(1, cell - 1, 4): p.pset(xx + a, yy + 1, 10) p.pset(xx + a, yy + cell - 2, 10) p.pset(xx + 1, yy + a, 10) p.pset(xx + cell - 2, yy + a, 10) else: p.text(xx + 26, yy + 3, 'v', 8) if STAGE == 1: x, y = g.probe xx, yy = ox + x * cell, oy + y * cell p.rectb(xx, yy, cell, cell, 7) p.rectb(xx + 1, yy + 1, cell - 2, cell - 2, 7) label = terrain(level, g.probe) allowed = walkable(level, g.probe, state.floating) p.text(16, 300, 'PROBE ' + str(g.probe) + ' / ' + label + ' / ' + ('WALKABLE' if allowed else 'BLOCKED'), 10) else: x, y = state.position xx, yy = ox + x * cell, oy + y * cell p.circ(xx + 18, yy + 17, 7, 8) p.pset(xx + 20, yy + 15, 7) p.rect(xx + 24, yy + 19, 6, 7, 10) p.text(16, 300, 'POS ' + str(state.position) + ' / MOVES ' + str(state.moves) + ' / CHANGES ' + str(state.changes), 7) if STAGE == 5: p.text(332, 300, 'HISTORY ' + str(len(g.history)), 6) current = rules(state) p.rectb(12, 319, 488, 62, 5) p.text(22, 329, 'CURRENT / L ' + current['L'] + ' / H ' + current['H'], 7) if editing: proposed = {'L': 'FLOAT' if g.proposed == 'L' else 'SINK', 'H': 'FLOAT' if g.proposed == 'H' else 'SINK'} p.text(22, 345, 'PROPOSED / L ' + proposed['L'] + ' / H ' + proposed['H'], 10) allowed = state.position in level.dry p.text(22, 363, 'DRY: CONFIRM ALLOWED' if allowed else 'ON STONE: CONFIRM WILL BE REJECTED', 11 if allowed else 8) else: p.text(22, 350, 'ONE FLOAT + ONE SINK / BOTH ROWS CHANGE TOGETHER', 6) p.text(22, 365, 'NO TIMER / NO LIVES / NO LANGUAGE MODEL', 6) status = 'PREVIEW ONLY: DOTS = WILL FLOAT / v = WILL SINK' if editing else EVENTS.get(g.event, g.event.upper()) p.text(16, 395, status, 8 if g.event in ('bank', 'water', 'sunk') and not editing else 10) for i, line in enumerate(CONTROLS): p.text(16, 417 + i * 15, line, 7 if i == 0 else 6) if getattr(state, 'status', '') == 'success': p.rect(90, 130, 332, 74, 1) p.rectb(90, 130, 332, 74, 10) p.text(160, 148, 'DELIVERY COMPLETE', 10) p.text(108, 174, 'N NEXT / Z UNDO / R RETRY' if STAGE == 5 else 'R RETRY / Q TITLE / ESC QUIT', 7) if g.title: p.rect(40, 82, 432, 200, 1) p.rectb(40, 82, 432, 200, 10) lines = ['WORD BRIDGE / STAGE ' + str(STAGE), STAGE_TITLE, 'ARROWS INSPECT TILES; CURSOR IS NOT A PLAYER' if STAGE == 1 else 'WALK ONLY ON DRY LAND OR FLOATING STONES', 'TWO COMPLEMENTARY RULES: ONE FLOAT, ONE SINK', 'SPACE START / RELEASE BEFORE THE NEXT ACTION', 'LEFT RIGHT: CHOOSE A / B / C' if STAGE == 5 else 'THIS STAGE USES ONLY LAYOUT A', 'SAVE CODE, QUIT AND RERUN AFTER AN EDIT', 'R RESETS THE STATE; IT DOES NOT RELOAD CODE'] for i, line in enumerate(lines): p.text(57, 102 + i * 22, line, 10 if i in (0, 4) else 7) if paused: p.rect(68, 144, 376, 75, 1) p.rectb(68, 144, 376, 75, 10) message = 'PAUSED STATE KEPT / SPACE START, THEN P RESUME' if g.title else 'PAUSED / RELEASE, PRESS P, RELEASE TO RESUME' p.text(88, 165, message, 10) p.text(88, 190, 'STATE AND HISTORY STAY UNCHANGED', 7) def tap(app, key): app.feed({key}) app.feed(set()) def snapshot(app): g = app.game result = {'stage': STAGE, 'level': g.level, 'state': asdict(g.state), 'title': g.title, 'event': g.event, 'gate': g.gate, 'scope': 'actual Pyxel Linux headless rendering with synthetic input'} for field in ('probe', 'editing', 'proposed', 'paused'): if hasattr(g, field): result[field] = getattr(g, field) if hasattr(g, 'history'): result['history'] = [asdict(state) for state in g.history] return result def demo(app): tap(app, 'SPACE') if STAGE == 1: tap(app, 'RIGHT') elif STAGE == 2: tap(app, 'RIGHT') tap(app, 'RIGHT') elif STAGE == 3: tap(app, 'SPACE') tap(app, 'RIGHT') tap(app, 'SPACE') elif STAGE == 4: tap(app, 'SPACE') else: tap(app, 'SPACE') tap(app, 'SPACE') tap(app, 'RIGHT') tap(app, 'Z') EVENTS = {'ready': 'READY', 'probe': 'INSPECTION ONLY', 'assignment': 'LAB ASSIGNMENT CHANGED', 'move': 'MOVED ONE TILE', 'water': 'WATER OR OUTSIDE: STATE UNCHANGED', 'sunk': 'SUNK STONE: STATE UNCHANGED', 'bank': 'SWAP REJECTED: STAND ON DRY LAND', 'swap': 'BOTH RULES COMMITTED TOGETHER', 'success': 'DELIVERY COMPLETE', 'invalid': 'INVALID ACTION OR RULE PAIR', 'terminal': 'ALREADY DELIVERED', 'cancel': 'PREVIEW CANCELLED: STATE UNCHANGED', 'same': 'SAME ASSIGNMENT: STATE UNCHANGED', 'undo': 'ONE ACCEPTED ACTION UNDONE'} def main(headless=False, screenshot=None, state_json=None, checkpoint=False, case=None): import pyxel as p app = App() if case is not None: app.game.level = case app.game.reset(False) if checkpoint: demo(app) p.init(512, 480, title='022 Word Bridge / Stage ' + str(STAGE), fps=30, display_scale=1, headless=headless) keymap = {key: getattr(p, 'KEY_' + key) for key in KEYS} def update(): if headless and p.frame_count >= 4: app.draw(p) if screenshot: p.screen.save(screenshot, 1) if state_json: with Path(state_json).open('x', encoding='utf-8') as stream: json.dump(snapshot(app), stream, ensure_ascii=False, indent=2) stream.write('\n') p.quit() return app.feed(set() if headless else {key for key, code in keymap.items() if p.btn(code)}) p.run(update, lambda: app.draw(p)) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description='Standalone Word Bridge teaching stage') parser.add_argument('--headless-smoke', action='store_true') parser.add_argument('--demo-checkpoint', action='store_true') parser.add_argument('--screenshot') parser.add_argument('--state-json') parser.add_argument('--case', choices=('A', 'B', 'C')) args = parser.parse_args() if (args.demo_checkpoint or args.screenshot is not None or args.state_json is not None or args.case) and not args.headless_smoke: parser.error('diagnostic options require --headless-smoke') if args.case and STAGE != 5: parser.error('--case is available only in stage 05') for field, suffix in (('screenshot', '.png'), ('state_json', '.json')): value = getattr(args, field) if value is not None: if not value.strip(): parser.error('output path must not be empty') target = Path(value).absolute() if target.suffix.lower() != suffix or not target.parent.is_dir() or target.exists() or target.is_symlink(): parser.error('output must be a new ' + suffix + ' in an existing directory') setattr(args, field, str(target)) main(args.headless_smoke, args.screenshot, args.state_json, args.demo_checkpoint, 'ABC'.index(args.case) if args.case else None)