# 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. """寝ぼけた巨人・教材3/5。各ファイルは単独実行できます。""" """035: deterministic 30 Hz model. No rendering or hidden-input access.""" from dataclasses import dataclass, field, asdict, replace from copy import deepcopy import math, json, hashlib @dataclass(frozen=True) class Level: name: str start: tuple stones: tuple=() left_sweep: bool=True LEVELS=(Level('起きた左手',(78.,78.)),Level('横に空いた庭',(80.,78.),((24,54,72,64),(88,54,136,64))),Level('片手を眠らせておく',(80.,86.),((72,20,88,60),))) @dataclass(frozen=True) class Observation: player: tuple arm: int awake: bool @dataclass(frozen=True) class Memory: seen: tuple @dataclass(frozen=True) class Intent: arm: int kind: str region: tuple seen: tuple @dataclass class Hand: hp: int=3 awake: bool=False hit_lock: bool=False @dataclass(frozen=True) class Input: dx: int=0 dy: int=0 z: bool=False @dataclass class WorldState: tick: int=0 x: float=78 y: float=78 hp: int=3 invuln: int=0 facing: tuple=(0,-1) hands: list=field(default_factory=lambda:[Hand(),Hand()]) intent: Intent|None=None memory: Memory|None=None sword_age: int=-1 sword_id: int=0 sword_hits: set=field(default_factory=set) zheld: bool=False status: str='playing' events: tuple=() attack_hit: bool=False HANDS=((48.,34.),(112.,34.)) def initial(level):return WorldState(x=level.start[0],y=level.start[1]) def observe(s,arm):return Observation((s.x,s.y),arm,s.hands[arm].awake) def policy(obs,memory,level): kind=('horizontal' if obs.arm==0 else 'vertical') if obs.awake else 'circle' if obs.arm==0 and not level.left_sweep:kind='circle' region=(4.,obs.player[1]-10,156.,obs.player[1]+10) if kind=='horizontal' else (obs.player[0]-10,4.,obs.player[0]+10,104.) if kind=='vertical' else (*HANDS[obs.arm],18.) return Intent(obs.arm,kind,region,obs.player),Memory(obs.player) def slot(s):return (-1,-1) if s.tick<60 else ((s.tick-60)//120%2,(s.tick-60)%120) def arm_phase(s,i): a,age=slot(s) if s.hands[i].hp<=0:return 'gone' if a!=i or age<0:return 'rest' return 'telegraph' if age<48 else 'active' if age<54 else 'recover' if age<96 else 'rest' def blocked(l,x,y,r=4): if not 4+r<=x<=156-r or not 4+r<=y<=104-r:return True return any(a-r=0 and age==0: s.attack_hit=False if s.hands[arm].hp>0: s.intent,s.memory=policy(observe(s,arm),s.memory,level);s.hands[arm].awake=False ev.append(('telegraph',arm,s.intent.kind,s.intent.region)) else:s.intent=None;ev.append(('safe_slot',arm)) s.x,s.y=move(level,s.x,s.y,inp.dx,inp.dy) if inp.dx or inp.dy: n=math.hypot(inp.dx,inp.dy);s.facing=(inp.dx/n,inp.dy/n) if inp.z and not s.zheld and s.sword_age<0: s.sword_age=0;s.sword_id+=1;s.sword_hits=set();ev.append(('strike',s.sword_id)) s.zheld=inp.z if arm>=0 and age==54: s.hands[arm].hit_lock=False;ev.append(('unlock',arm)) if 6<=s.sword_age<12: for i,h in enumerate(s.hands): if h.hp>0 and not h.hit_lock and i not in s.sword_hits and arm_phase(s,i) not in ('telegraph','active') and sword_reaches(s,i): h.hp-=1;h.awake=True;h.hit_lock=True;s.sword_hits.add(i);ev.append(('hand_hit',i,h.hp)) if s.sword_age>=0: s.sword_age+=1 if s.sword_age>=24:s.sword_age=-1 if all(h.hp<=0 for h in s.hands):s.status='won';ev.append(('won',)) s.tick+=1;s.events=tuple(ev);return s def state_hash(s): d=asdict(s);d['sword_hits']=sorted(s.sword_hits) return hashlib.sha256(json.dumps(d,sort_keys=True,ensure_ascii=False,separators=(',',':')).encode('utf-8')).hexdigest() # 教材の操作層。モデル step は描画や Pyxel を知りません。 STAGE = 3 class App: def __init__(self): self.level_index = 0 self.reset() @property def level(self): return LEVELS[self.level_index] def reset(self): self.state = initial(self.level) self.paused = True self.gated = False def control(self, pressed, held): # R は矢印や Z と同時でも優先。押し続けた入力を次の回へ持ち越さない。 if 'R' in pressed: self.reset(); self.gated = True return if self.gated: if not pressed and not held: self.gated = False return if STAGE == 5: for i, key in enumerate(('1', '2', '3')): if key in pressed: self.level_index = i; self.reset(); self.gated = True return if 'P' in pressed: self.paused = not self.paused; self.gated = True return if self.paused and 'SPACE' not in pressed: return inp = Input(int('RIGHT' in held)-int('LEFT' in held), int('DOWN' in held)-int('UP' in held), 'Z' in held) self.state = step(self.level, self.state, inp) def draw(self, p): s = self.state p.cls(1) p.text(12, 10, 'SLEEPY GIANT / STAGE '+str(STAGE)+' / GARDEN '+'ABC'[self.level_index], 10) p.text(12, 28, 'TICK '+str(s.tick)+' '+('PAUSED' if self.paused else 'RUN 30 HZ')+' '+s.status.upper(), 7) p.text(12, 46, 'XY %.2f, %.2f FACING %.2f, %.2f' % (s.x,s.y,*s.facing), 6) ox, oy, scale = 72, 76, 2 def xy(x,y): return ox+x*scale, oy+y*scale p.clip(ox,oy,320,216) p.rect(ox,oy,320,216,3); p.rectb(ox+8,oy+8,304,200,11) visual = replace(s, tick=max(0,s.tick-1)) if STAGE >= 3: arm, age = slot(visual) it = s.intent if it and 0 <= age < 54 and s.hands[it.arm].hp > 0: color = 8 if age >= 48 else 4 if it.kind == 'circle': x,y,r=it.region; xx,yy=xy(x,y);p.circ(xx,yy,r*2,color);p.circb(xx,yy,r*2,10) else: a,c,b,d=it.region; xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,color) for y in range(int(c),int(d),4): x1,y1=xy(a,y);x2,y2=xy(b,y);p.line(x1,y1,x2,y2,10) for a,c,b,d in self.level.stones: xx,yy=xy(a,c);p.rect(xx,yy,(b-a)*2,(d-c)*2,5);p.rectb(xx,yy,(b-a)*2,(d-c)*2,13) hx,hy=xy(80,17);p.elli(hx-35,hy-20,70,36,13);p.line(hx-18,hy-4,hx-8,hy-4,1);p.line(hx+8,hy-4,hx+18,hy-4,1) if STAGE >= 2: for i,((x,y),h) in enumerate(zip(HANDS,s.hands)): xx,yy=xy(x,y);p.circ(xx,yy,18,9 if h.hp else 5) if getattr(h,'awake',False):p.circ(xx-5,yy-3,3,7) else:p.line(xx-9,yy-2,xx-2,yy-2,1) if getattr(h,'hit_lock',False): for dy in (-8,0,8):p.line(xx-15,yy+dy,xx+15,yy+dy,7) p.text(xx-16,yy-27,'LR'[i]+' HP '+str(h.hp),7) xx,yy=xy(s.x,s.y);p.circ(xx,yy,8,12);p.line(xx,yy,xx+s.facing[0]*12,yy+s.facing[1]*12,10) if STAGE >= 2 and 6 <= s.sword_age-1 < 12: fx,fy=s.facing; corners=[(xx+2*(u*fx-v*fy),yy+2*(u*fy+v*fx)) for u,v in ((0,-8),(18,-8),(18,8),(0,8))] for j in range(4):p.line(*corners[j],*corners[(j+1)%4],10) p.clip() if STAGE >= 3: arm, age=slot(visual) phase='PREPARE' if arm<0 else 'LR'[arm]+' '+arm_phase(visual,arm).upper()+' AGE '+str(age) p.text(12,306,phase,10) p.text(12,323,'INTENT '+(s.intent.kind.upper()+' SEEN '+str(s.intent.seen) if s.intent else 'NONE'),6) else:p.text(12,306,'PRACTICE: '+('MOVE ONLY' if STAGE==1 else 'SWORD ONLY / NO ENEMY ATTACK OR HIT LOCK'),14) if STAGE>=4:p.text(12,340,'YOUR HP '+str(s.hp)+' / INVULNERABLE '+str(s.invuln),11) elif STAGE==3:p.text(12,340,'PRACTICE: ENEMY DAMAGE DISABLED',14) if STAGE>=2:p.text(12,357,'SWORD AGE '+str(s.sword_age)+' / ID '+str(s.sword_id),7) p.text(12,380,'ARROWS: MOVE / Z: STRIKE / P: RUN-PAUSE',7) p.text(12,397,'PAUSED SPACE: ONE TICK / R: RESET / ESC: QUIT',7) p.text(12,414,'1-3: GARDENS' if STAGE==5 else 'HOLD ARROWS + TAP SPACE TO MEASURE EXACT TICKS',6) def make_demo_inputs(): # 実際の初期状態から進める。完成状態を捏造して描きません。 if STAGE == 1: return [Input(dx=-1) for _ in range(10)] result = [Input(dx=-1) for _ in range(21)] result += [Input(dy=-1) for _ in range(20)] result += [Input(z=True)] + [Input() for _ in range(6)] if STAGE >= 3: result += [Input() for _ in range(13)] return result def main(headless=False, screenshot=None, demo=False): import pyxel as p p.init(464,436,title='Sleepy giant lesson '+str(STAGE),fps=30,display_scale=2,headless=headless) app=App(); sequence=make_demo_inputs() if demo else [] keymap={k:getattr(p,'KEY_'+k) for k in ('LEFT','RIGHT','UP','DOWN','Z','SPACE','P','R','1','2','3')} limit=max(12,2*len(sequence)+4) def update(): if p.btnp(p.KEY_ESCAPE) or headless and p.frame_count>=limit: if screenshot:app.draw(p);p.screen.save(screenshot,1) p.quit();return if headless: index=p.frame_count//2 if demo and p.frame_count%2==0 and index0 else 'LEFT') if inp.dy:held.add('DOWN' if inp.dy>0 else 'UP') if inp.z:held.add('Z') app.control({'SPACE'},held) else:app.control(set(),set()) else:app.control({k for k,v in keymap.items() if p.btnp(v)}, {k for k,v in keymap.items() if p.btn(v)}) p.run(update,lambda:app.draw(p)) if __name__ == '__main__': import argparse from pathlib import Path parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--headless-smoke',action='store_true') parser.add_argument('--demo-checkpoint',action='store_true') parser.add_argument('--screenshot') args=parser.parse_args() if args.demo_checkpoint and not args.headless_smoke:parser.error('demo requires headless mode') if args.screenshot and not args.headless_smoke:parser.error('screenshot requires headless mode') if args.screenshot and (Path(args.screenshot).suffix.lower()!='.png' or not Path(args.screenshot).parent.is_dir() or Path(args.screenshot).exists()):parser.error('use a new .png in an existing directory') # Pyxel may change cwd during init; preserve the caller's output destination. if args.screenshot: args.screenshot = str(Path(args.screenshot).resolve()) main(args.headless_smoke,args.screenshot,args.demo_checkpoint)