Back to GRAV / RIFT
SOURCE / PINNED RELEASE

Made of little things.

GRAV / RIFT

Release
288aa261ce07…
Author-recorded commit
c549b5a4e17e…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-8a7361981e8

Archive hash verified: e52a01aff70810b3…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/controls/radial.ts
import { MODE_NAMES, type Mode } from '../game/game.js';
import { RELEASE_NAMES, type ReleaseMode } from '../game/launcher.js';
type Choice={field?: Mode;release?: ReleaseMode};

/** Screen-space selector. Combat receives settings, never menu mouse events. */
export class RadialMenu {
  isOpen=false;
  private hovered: Choice | null=null;
  private index=0;
  private tabHeld=false;
  private abort=new AbortController();
  private buttons: HTMLButtonElement[]=[];
  constructor(private root: HTMLElement,private choose: (choice: Choice)=>void,private changed: (open: boolean)=>void) {
    const dial=root.querySelector<HTMLElement>('.radial-dial')!;
    const choices: Choice[]=[{field:0},{field:1},{field:2},{field:3},{release:0},{release:1},{release:2}];
    choices.forEach((choice,i)=>{
      const button=document.createElement('button'),angle=(-90+i*360/7)*Math.PI/180;
      button.className='radial-choice';button.style.setProperty('--x',String(50+Math.cos(angle)*36));button.style.setProperty('--y',String(50+Math.sin(angle)*36));
      button.dataset[choice.field!==undefined?'selectMode':'selectRelease']=String(choice.field??choice.release);
      button.innerHTML=`<small>${choice.field!==undefined?'FIELD':'RELEASE'}</small><b>${choice.field!==undefined?MODE_NAMES[choice.field]:RELEASE_NAMES[choice.release!]}</b>`;
      button.addEventListener('pointerenter',()=>this.highlight(i,choice),{signal:this.abort.signal});
      button.addEventListener('focus',()=>this.highlight(i,choice),{signal:this.abort.signal});
      button.addEventListener('click',()=>{this.choose(choice);this.highlight(i,choice);},{signal:this.abort.signal});
      dial.append(button);this.buttons.push(button);
    });
    root.querySelector('button[data-close]')!.addEventListener('click',()=>this.close(false),{signal:this.abort.signal});
  }
  private highlight(index: number,choice: Choice): void {this.index=index;this.hovered=choice;this.buttons.forEach((b,i)=>b.classList.toggle('hovered',i===index));}
  open(tabHeld=false): void {if(this.isOpen)return;this.tabHeld=tabHeld;this.isOpen=true;this.hovered=null;this.root.hidden=false;this.buttons.forEach(b=>b.classList.remove('hovered'));this.changed(true);}
  close(commit=false): void {if(!this.isOpen)return;if(commit&&this.hovered)this.choose(this.hovered);this.isOpen=false;this.tabHeld=false;this.root.hidden=true;this.changed(false);}
  keyDown(event: KeyboardEvent): boolean {
    if(event.code==='Tab'){event.preventDefault();if(!event.repeat){if(!this.isOpen)this.open(true);else if(!this.tabHeld)this.close(true);}return true;}
    if(!this.isOpen)return false;
    if(event.code==='Escape'){event.preventDefault();this.close(false);return true;}
    if(event.code==='Enter'){event.preventDefault();this.close(true);return true;}
    if(['ArrowRight','ArrowDown','ArrowLeft','ArrowUp'].includes(event.code)){
      event.preventDefault();const next=(this.index+(event.code==='ArrowRight'||event.code==='ArrowDown'?1:-1)+7)%7;
      this.buttons[next].focus();return true;
    }
    return true;
  }
  keyUp(event: KeyboardEvent): void {if(event.code==='Tab'&&this.tabHeld){event.preventDefault();this.close(true);}}
  update(field: Mode,release: ReleaseMode): void {
    this.buttons.forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.selectMode!==undefined?Number(b.dataset.selectMode)===field:Number(b.dataset.selectRelease)===release)));
    this.root.querySelector('[data-field-name]')!.textContent=MODE_NAMES[field];this.root.querySelector('[data-release-name]')!.textContent=RELEASE_NAMES[release];
  }
  destroy(): void {this.abort.abort();}
}