# 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. # 教材画面はASCIIとオリジナル図形のみ。Noto字形データは使いません。 """やわらかいゴール・教材3/5。説明と簡略化の範囲はLESSON.md。""" from dataclasses import dataclass, field, asdict import math import json import hashlib # 位置px、速度px/s、角度degree。入力30Hz、物理120Hz。重力は加えない。 DT = 1 / 120 EPS = 1e-9 RADIUS = 3.0 ORIGINS = (32., 64., 96.) @dataclass(frozen=True) class Level: title: str goal: float direction: int speed: float blocks: tuple = () # 原作Cを一つ選ぶ。上向きに逃げ、下側に小さな衝立がある。 LEVEL = Level('待ってから戻る', 88., -1, 22., ((136., 82., 148., 94.),)) def ray_box(p,d,b): lo=0.;hi=1. for axis in (0,1): if abs(d[axis])hi+EPS:return None return max(0.,lo) if lo<=1+EPS and hi>=0 else None def ray_circle(p,d,c,r=RADIUS): q=(p[0]-c[0],p[1]-c[1]);a=d[0]*d[0]+d[1]*d[1] cc=q[0]*q[0]+q[1]*q[1]-r*r if cc<=EPS:return 0. if a 88 return Observation(active, f.history[0] if active else None, f.g) def policy(observation, previous_direction): """観測と以前の向きだけで判断。速度・現在の球y・入力は受け取らない。""" if not observation.active: return previous_direction diff = observation.delayed[1] - observation.goal_y if abs(diff) > 2: return -1 if diff > 0 else 1 return previous_direction def remember(f, observation): # 減算前の値を保存。30だったら、その後30tickは惰性の目標速度を保つ。 coasting = f.memory_ticks > 0 f.sensing = observation.active if observation.active: f.sensed = observation.delayed f.direction = policy(observation, f.direction) f.memory_ticks = 30 elif f.memory_ticks > 0: f.memory_ticks -= 1 return coasting def physics(level, f): if f.status != 'flying': return f observation = observe(f) coasting = remember(f, observation) oldg, dg = f.g, 0. # 教材2/3は静止ゴール。 remain=1.;elapsed=0. for _ in range(4): d=(f.vx*DT*remain,f.vy*DT*remain);p=f.pos hits=[] for i,b in enumerate(level.blocks): t=circle_box(p,d,b) if t is not None:hits.append((t,0,'block')) h=gate_hit(p,d,oldg+dg*elapsed,dg*remain) if h is not None:hits.append((h,0,'post')) for boundary,axis,sense,name in ((15.,1,-1,'top'),(113.,1,1,'bottom'),(11.,0,-1,'left'),(179.,0,1,'goal')): if d[axis]*sense>EPS: t=(boundary-p[axis])/d[axis] if -EPS<=t<=1+EPS:hits.append((max(0.,t),1 if name=='goal' else 0,name)) if not hits: f.x+=d[0];f.y+=d[1];break t,_,reason=min(hits,key=lambda h:(round(h[0],10),h[1],h[2])) f.x+=d[0]*t;f.y+=d[1]*t;elapsed+=remain*t;remain*=1-t if f.record:f.trail.append(f.pos) if reason in ('top','bottom'): f.vy=-f.vy;f.bounces+=1;f.y+=.01 if reason=='top' else -.01 if remain=720:f.status='timeout' return f def simulate(level,origin,angle,power,moving=True,record=False): f=launch(level,origin,angle,power,moving,record) while f.status=='flying':physics(level,f) return f @dataclass(frozen=True) class Input: pressed:tuple=() held:tuple=() @dataclass class Game: level:int=2 origin:int=1 angle:float=0. mode:str='select' paused:bool=False power:float=20. charging:bool=False require_release:bool=True attempts:int=0 flight:Flight|None=None previous:Flight|None=None moving:bool=True def fresh(level=2,moving=True):return Game(level=level,moving=moving) def frame(s,i=Input()): p=set(i.pressed);h=set(i.held) if 'R' in p:return fresh(s.level,s.moving) if s.require_release: if 'SPACE' not in h:s.require_release=False space=False else:space='SPACE' in p if s.mode=='won': return s if s.mode=='select': if 'LEFT' in p:s.origin=(s.origin-1)%3 if 'RIGHT' in p:s.origin=(s.origin+1)%3 if space:s.mode='aim';s.require_release=True return s if s.mode=='flying': for _ in range(4):physics(LEVEL,s.flight) if s.flight.status!='flying':s.mode='won' if s.flight.status=='scored' else 'result' return s if 'Z' in p: s.previous=s.flight;s.flight=None;s.mode='select';s.charging=False;s.power=20.;s.require_release=True return s if s.mode=='result': if space: s.previous=s.flight;s.flight=None;s.mode='aim';s.power=20.;s.require_release=True return s s.angle=max(-75.,min(75.,s.angle+2*('RIGHT'in h)-2*('LEFT'in h)+.4*('DOWN'in h)-.4*('UP'in h))) if space: s.charging=True;s.power=20. return s if s.charging: if 'SPACE' in h:s.power=min(100.,s.power+80/21) else: s.flight=launch(LEVEL,s.origin,s.angle,s.power,s.moving) s.attempts+=1;s.charging=False;s.mode='flying' return s def state_hash(s): return hashlib.sha256(json.dumps(asdict(s),sort_keys=True,separators=(',',':'),ensure_ascii=False).encode('utf-8')).hexdigest() STAGE = 3 STAGE_TITLE = 'OBSERVE OLD BALL / POLICY' CONTROLS = ('LEFT', 'RIGHT', 'UP', 'DOWN', 'SPACE', 'R', 'Z') class Controller: def __init__(self): self.state = fresh() def tick(self, pressed=(), held=()): pressed = tuple(k for k in pressed if k in CONTROLS) held = tuple(k for k in held if k in CONTROLS) self.state = frame(self.state, Input(pressed, held)) def snapshot(self): return {'state': asdict(self.state), 'hash': state_hash(self.state)} def draw_board(pyxel, controller): s = controller.state f = s.flight g = getattr(f, 'g', LEVEL.goal) pyxel.cls(1) pyxel.text(12, 10, f'FLEEING GOAL 016 / STEP {STAGE}', 10) pyxel.text(12, 24, STAGE_TITLE, 7) ox, oy, scale = 10, 44, 2 def point(x, y): return ox+x*scale, oy+y*scale def line(a, b, color): pyxel.line(*point(*a), *point(*b), color) pyxel.rect(26, 68, 352, 208, 0) for x in range(16, 184, 12): line((x, 12), (x, 116), 1) if STAGE >= 2: line((8, 12), (176, 12), 7) line((8, 116), (176, 116), 7) line((8, 12), (8, 116), 13) for x,y,z,w in LEVEL.blocks: pyxel.rect(*point(x,y), (z-x)*2, (w-y)*2, 4) pyxel.rectb(*point(x,y), (z-x)*2, (w-y)*2, 9) line((176, 12), (176, g-12), 7) line((176, g+12), (176, 116), 7) line((176, g-12), (187, g-12), 7) line((176, g+12), (187, g+12), 7) line((187, g-12), (187, g+12), 6) line((191, 28), (191, 100), 5) if STAGE >= 3: for y in range(12,116,5): line((88,y),(88,y+2),10) pyxel.text(194, 72, 'SENSE x > 88', 10) if f and f.sensed: x,y=f.sensed pyxel.circb(*point(x,y),8,10) line((x,y),(176,g),5) if f: direction=f.direction line((193,g),(193,g+direction*8),10) line((193,g+direction*8),(191,g+direction*5),10) line((193,g+direction*8),(195,g+direction*5),10) if getattr(s,'previous',None): for a,b in zip(s.previous.trail,s.previous.trail[1:]):line(a,b,5) if f: for a,b in zip(f.trail,f.trail[1:]):line(a,b,6) pyxel.circ(*point(f.x,f.y),6,10 if f.status=='scored' else 7) pyxel.circb(*point(f.x,f.y),6,0) else: for k,y in enumerate(ORIGINS): if STAGE==1 and k!=1:continue pyxel.circb(*point(24,y),9,10 if k==s.origin else 5) x,y=24,ORIGINS[s.origin] a=math.radians(s.angle) line((x,y),(x+20*math.cos(a),y+20*math.sin(a)),10) pyxel.circ(*point(x,y),6,10 if s.charging else 7) lines=[('MODE',s.mode.upper(),10),('ANGLE',f'{s.angle:+.1f} deg',7), ('POWER',f'{s.power:.2f}',10),('ATTEMPTS',str(s.attempts),7), ('PHYSICS TICK',str(f.ticks if f else 0),6)] if f:lines += [('BALL',f.status.upper(),7)] if STAGE>=2:lines += [('GOAL Y',f'{g:.3f}',7),('BOUNCES',str(f.bounces if f else 0),6)] if STAGE>=3:lines += [('DELAY','18 ticks = 0.15 s',10),('SENSE',str(bool(f and f.sensing)),7), ('DIRECTION',str(f.direction if f else LEVEL.direction),10)] if STAGE>=4:lines += [('GOAL SPEED',f'{f.gv if f else 0:.3f} px/s',6), ('MEMORY',str(f.memory_ticks if f else 0),6)] for n,(label,value,color) in enumerate(lines): pyxel.text(410,48+n*16,label,13) pyxel.text(497,48+n*16,value,color) pyxel.rectb(30,298,346,12,6) pyxel.rect(32,300,(s.power-20)/80*342,8,10) pyxel.text(12, 322, 'ARROWS: aim / fine adjust SPACE: press, hold, release R: reset', 7) pyxel.text(12, 337, 'One UI frame = 4 physics ticks. Speed is px/s. No gravity.', 6) notes={1:'LAB: charge + straight motion. No walls, goal, selection, or pause.', 2:'LAB: static original C. Swept collisions + reflection; no sensor / AI.', 3:'LAB: old-ball observation + policy arrow. Goal action is still disabled.', 4:'Original C physics. Bottom +28 deg, power 20: try a slow wall return.', 5:'Original C + title/pause. P: pause Z: select again. R beats P.'} pyxel.text(12,353,notes[STAGE],10) pyxel.text(12,369,'SELECT: LEFT/RIGHT then SPACE; release SPACE before starting charge.' if STAGE>=2 else 'After reset/retry: release SPACE first. Short tap gives power 20.',7) pyxel.text(12,385,'WHITE: solid posts/walls BROWN: block YELLOW RING: old observed ball' if STAGE>=2 else 'Hold 21 more UI frames after the new press for maximum power.',13) if getattr(controller,'title',False): pyxel.rect(50,113,322,110,1);pyxel.rectb(50,113,322,110,10) pyxel.text(72,132,'FLEEING GOAL / ORIGINAL CASE C',10) pyxel.text(72,152,'Can a slow ball beat a fleeing goal?',7) pyxel.text(72,173,'The goal sees only an old ball position.',6) pyxel.text(72,200,'SPACE: start, then release once',10) elif getattr(s,'paused',False): pyxel.rect(70,150,292,42,1);pyxel.rectb(70,150,292,42,10) pyxel.text(88,165,'PAUSED: physics + AI + history frozen',10) elif s.mode in ('result','won'): pyxel.rect(42,154,334,43,1);pyxel.rectb(42,154,334,43,10) pyxel.text(56,165,'SCORED! / R: reset same case' if s.mode=='won' else 'RESULT: '+f.status.upper(),10) if s.mode!='won':pyxel.text(56,182,'SPACE: retry aim / Z: select position' if STAGE>=2 else 'SPACE: retry aim',7) def demo_keys(controller, frame_number): """技術QA用の一定入力。AIへの未来情報ではなくプレイヤーの操作列。""" # 05は最初の2フレームでタイトルを抜ける。他は直接開始。 if STAGE==5: if frame_number==1:return ('SPACE',) frame_number-=2 if STAGE==1: if 2<=frame_number<=23:return ('SPACE',) return () # 下を選択し、28度を14入力フレームで作り、最弱で放す。 if frame_number==2:return ('RIGHT',) if frame_number==4:return ('SPACE',) if 6<=frame_number<=19:return ('RIGHT',) if frame_number==21:return ('SPACE',) return () def main(headless=False, frames=180, demo=False, replay=None, screenshot=None, report=None): from pathlib import Path import pyxel # モデルのimportと標準ライブラリテストには不要。 controller=Controller() codes={name:getattr(pyxel,'KEY_'+name) for name in CONTROLS} frame_number=0 old_held=set() changes=[] screenshot=str(Path(screenshot).resolve()) if screenshot else None report=str(Path(report).resolve()) if report else None def update(): nonlocal frame_number,old_held frame_number+=1 if headless: if replay is not None: held=set(replay[frame_number-1]) if frame_number<=len(replay) else set() else: held=set(demo_keys(controller,frame_number) if demo else ()) pressed=held-old_held old_held=held else: held={name for name,code in codes.items() if pyxel.btn(code)} pressed={name for name,code in codes.items() if pyxel.btnp(code)} old_mode=controller.state.mode controller.tick(pressed,held) if pressed or controller.state.mode!=old_mode: changes.append({'frame':frame_number,'pressed':sorted(pressed),'held':sorted(held), 'mode':controller.state.mode,'hash':state_hash(controller.state)}) def draw(): draw_board(pyxel,controller) if headless and frame_number>=frames: if screenshot:pyxel.screenshot(screenshot,scale=1) if report: Path(report).write_text(json.dumps({'label':'Actual Pyxel headless; synthetic controller inputs; silent; not human play', 'stage':STAGE,'requested_frames':frames,'actual_frames':frame_number,'demo':demo, 'changes':changes,'snapshot':controller.snapshot()},ensure_ascii=False,indent=2)+'\n',encoding='utf-8') pyxel.quit() pyxel.init(630,406,title=f'Fleeing Goal / Step {STAGE}',fps=30,headless=headless) pyxel.run(update,draw) if __name__=='__main__': import argparse from pathlib import Path parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless',action='store_true') parser.add_argument('--frames',type=int,default=180) parser.add_argument('--demo',action='store_true') parser.add_argument('--replay-json',help='UTF-8 JSON list of held-key lists, one per UI frame') parser.add_argument('--screenshot',help='PNG output; headless only; relative to current directory') parser.add_argument('--report',help='UTF-8 JSON output; headless only') args=parser.parse_args() if not 12<=args.frames<=100000:parser.error('Use 12 through 100000 frames') if not args.headless and (args.demo or args.replay_json or args.screenshot or args.report): parser.error('QA input/output options require --headless') if args.demo and args.replay_json:parser.error('Choose demo or replay, not both') replay=None if args.replay_json: try:replay=json.loads(Path(args.replay_json).read_text(encoding='utf-8')) except (OSError,ValueError) as error:parser.error(str(error)) if not isinstance(replay,list) or any(not isinstance(keys,list) or any(not isinstance(k,str) or k not in CONTROLS for k in keys) for keys in replay): parser.error('Replay must be a list of lists of supported key names') main(args.headless,args.frames,args.demo,replay,args.screenshot,args.report)