This commit is contained in:
33333-33333 2026-07-31 12:54:46 +09:00
commit c3a6f5ff37
80 changed files with 2512 additions and 1625 deletions

73
client/input/drag.js Normal file
View file

@ -0,0 +1,73 @@
(function(root,factory){
const api=factory(root.BendFrameScheduler);
if(typeof module==='object'&&module.exports)module.exports=factory(require('./frame-scheduler.js'));
root.BendDragScheduler=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(FrameScheduler){
'use strict';
const STATES=Object.freeze(['idle','armed','running','draining','settling','cancelled']);
function createDragScheduler(options={}){
if(!FrameScheduler?.createFrameScheduler)throw new TypeError('BendFrameScheduler is required');
if(typeof options.onFrame!=='function')throw new TypeError('Drag onFrame callback is required');
const maxSamples=Math.max(2,Number(options.maxSamples)||12);
const trim=typeof options.trim==='function'?options.trim:samples=>samples.splice(0,Math.max(0,samples.length-maxSamples));
let state='idle',pointerId=null,latest=null,logical=[],revision=0,visualRevision=0,logicalRevision=0;
const frame=FrameScheduler.createFrameScheduler({
interval:options.interval,
tolerance:options.tolerance,
watchdogDelay:options.watchdogDelay,
requestFrame:options.requestFrame,
cancelFrame:options.cancelFrame,
setDelay:options.setDelay,
clearDelay:options.clearDelay,
now:options.now,
onWatchdog:options.onWatchdog,
commit:(_signal,timestamp)=>options.onFrame(api,timestamp)
});
const assertState=next=>{if(!STATES.includes(next))throw new TypeError(`Unknown drag state: ${next}`);state=next};
const clearData=()=>{pointerId=null;latest=null;logical.length=0;revision=0;visualRevision=0;logicalRevision=0};
const arm=id=>{
if(id==null)throw new TypeError('Drag pointerId is required');
frame.cancel({resetCadence:true});clearData();pointerId=id;assertState('armed');return api;
};
const push=(sample,{schedule=true}={})=>{
if(!sample||sample.pointerId==null)return false;
if(state==='idle'||state==='cancelled'||state==='settling')arm(sample.pointerId);
if(sample.pointerId!==pointerId||state==='draining')return false;
latest=sample;logical.push(sample);trim(logical,maxSamples);revision++;
if(state==='armed')assertState('running');
if(schedule)frame.push(revision);
return revision;
};
const requestFrame=()=>{if(latest&&state!=='idle'&&state!=='cancelled')frame.push(revision)};
const beginDrain=sample=>{
if(sample)push(sample,{schedule:false});
if(state==='idle'||state==='cancelled')return false;
frame.cancel();assertState('draining');return true;
};
const beginSettling=()=>{if(state==='draining'||state==='running'||state==='armed')assertState('settling');frame.cancel();return state==='settling'};
const settle=()=>{frame.cancel({resetCadence:true});clearData();assertState('idle')};
const cancel=()=>{frame.cancel({resetCadence:true});clearData();assertState('cancelled')};
const takeLogical=max=>{
const count=Math.max(0,Math.min(logical.length,Number(max)||1));
return logical.splice(0,count);
};
const drainLogical=()=>logical.splice(0);
const markVisual=()=>{visualRevision=revision};
const markLogical=()=>{if(!logical.length)logicalRevision=revision};
const inspect=()=>Object.freeze({
state,pointerId,latest,logicalCount:logical.length,revision,visualRevision,logicalRevision,
freshVisual:visualRevision!==revision,freshLogical:logicalRevision!==revision
});
const api=Object.freeze({
arm,push,requestFrame,beginDrain,beginSettling,settle,cancel,takeLogical,drainLogical,
latest:()=>latest,
hasLogical:()=>logical.length>0,
markVisual,markLogical,inspect
});
return api;
}
return Object.freeze({STATES,createDragScheduler});
});

View file

@ -0,0 +1,85 @@
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
root.BendFrameScheduler=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
const DEFAULT_INTERVAL=1000/60;
function createFrameScheduler(options={}){
const requestFrame=options.requestFrame||globalThis.requestAnimationFrame?.bind(globalThis);
const cancelFrame=options.cancelFrame||globalThis.cancelAnimationFrame?.bind(globalThis);
const setDelay=options.setDelay||globalThis.setTimeout?.bind(globalThis);
const clearDelay=options.clearDelay||globalThis.clearTimeout?.bind(globalThis);
const now=options.now||(()=>globalThis.performance?.now?.()||Date.now());
const interval=Math.max(1,Number(options.interval)||DEFAULT_INTERVAL);
const tolerance=Math.max(0,Number(options.tolerance)||0);
const watchdogDelay=Math.max(interval,Number(options.watchdogDelay)||interval+2);
const commit=options.commit;
const validTiming=typeof requestFrame==='function'&&typeof cancelFrame==='function'
&&typeof setDelay==='function'&&typeof clearDelay==='function';
if(!validTiming)throw new TypeError('Frame scheduler timing functions are required');
if(typeof commit!=='function')throw new TypeError('Frame scheduler commit callback is required');
let frame=0,timer=0,lastCommitAt=0,latest=null,revision=0,committedRevision=0,disposed=false;
const clearArmed=()=>{
if(frame)cancelFrame(frame);
if(timer)clearDelay(timer);
frame=0;timer=0;
};
const arm=()=>{
if(disposed||frame||timer||latest==null)return false;
const step=timestamp=>{
if(disposed||latest==null){clearArmed();return false}
const at=Number.isFinite(timestamp)?timestamp:now();
if(lastCommitAt&&at+tolerance<lastCommitAt+interval){
if(frame)cancelFrame(frame);
frame=requestFrame(step);
return false;
}
clearArmed();
const value=latest,currentRevision=revision;
latest=null;lastCommitAt=at;committedRevision=currentRevision;
commit(value,at,currentRevision);
if(latest!=null)arm();
return true;
};
frame=requestFrame(step);
timer=setDelay(()=>{
timer=0;
if(typeof options.onWatchdog==='function')options.onWatchdog();
step(now());
},watchdogDelay);
return true;
};
const push=value=>{
if(disposed)return false;
latest=value;revision++;arm();return revision;
};
const flush=()=>{
if(disposed||latest==null)return false;
clearArmed();
const value=latest,currentRevision=revision,at=now();
latest=null;lastCommitAt=at;committedRevision=currentRevision;
commit(value,at,currentRevision);
if(latest!=null)arm();
return true;
};
const cancel=({resetCadence=false}={})=>{
clearArmed();latest=null;
if(resetCadence)lastCommitAt=0;
};
const dispose=()=>{cancel({resetCadence:true});disposed=true};
const inspect=()=>Object.freeze({
armed:Boolean(frame||timer),
pending:latest!=null,
revision,
committedRevision,
lastCommitAt
});
return Object.freeze({push,flush,cancel,dispose,inspect});
}
return Object.freeze({DEFAULT_INTERVAL,createFrameScheduler});
});

View file

@ -0,0 +1,47 @@
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
root.BendGestureCoordinator=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
const DEFAULT_PRECEDENCE=Object.freeze({
wheel:10,
pan:20,
pinch:30,
reaction:40,
minimap:50,
draw:60,
dialog:70
});
function createGestureCoordinator(precedence=DEFAULT_PRECEDENCE){
const owners=new Map(),listeners=new Set();let revision=0;
const priority=owner=>Number(precedence[owner])||0;
const snapshot=()=>Object.freeze({revision,owners:Object.freeze(Object.fromEntries(owners))});
const notify=(change)=>{revision++;const state=snapshot();for(const listener of listeners)listener(state,change)};
const claim=(pointerId,owner,{replace=false}={})=>{
if(pointerId==null||!owner)return false;const key=String(pointerId),next=String(owner),current=owners.get(key);
if(current===next)return true;
if(current&&!replace&&priority(next)<=priority(current))return false;
owners.set(key,next);notify(Object.freeze({type:current?'transfer':'claim',pointerId:key,owner:next,previous:current||null}));return true;
};
const release=(pointerId,owner=null,reason='complete')=>{
if(pointerId==null)return false;const key=String(pointerId),current=owners.get(key);
if(!current||owner!=null&&current!==String(owner))return false;
owners.delete(key);notify(Object.freeze({type:'release',pointerId:key,owner:current,reason}));return true;
};
const cancelAll=(reason='cancelled')=>{for(const[key,owner]of[...owners]){owners.delete(key);notify(Object.freeze({type:'release',pointerId:key,owner,reason}))}};
return Object.freeze({
claim,
release,
cancelAll,
owner:pointerId=>pointerId==null?null:owners.get(String(pointerId))||null,
owns:(pointerId,owner)=>owners.get(String(pointerId))===String(owner),
snapshot,
subscribe(listener){if(typeof listener!=='function')throw new TypeError('Listener must be a function');listeners.add(listener);return()=>listeners.delete(listener)}
});
}
return Object.freeze({DEFAULT_PRECEDENCE,createGestureCoordinator});
});

View file

@ -0,0 +1,52 @@
(function(root,factory){
const api=factory();
if(typeof module==='object'&&module.exports)module.exports=api;
root.BendInteractionState=api;
})(typeof globalThis!=='undefined'?globalThis:this,function(){
'use strict';
const DEFAULT_SCOPES=Object.freeze({
any:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap','dialog']),
pointer:Object.freeze(['camera','drawing','claim','reaction','minimap']),
camera:Object.freeze(['camera','wheel']),
persistence:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap']),
world:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap']),
overview:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap']),
worker:Object.freeze(['drawing','claim'])
});
function createInteractionState(scopeDefinitions=DEFAULT_SCOPES){
const owners=new Map(),listeners=new Set(),scopes=new Map(
Object.entries(scopeDefinitions).map(([name,kinds])=>[name,new Set(kinds)])
);
let revision=0;
const snapshot=()=>Object.freeze({
revision,
activeKinds:Object.freeze([...owners.keys()]),
owners:Object.freeze(Object.fromEntries(owners))
});
const notify=()=>{const state=snapshot();for(const listener of listeners)listener(state)};
const set=(kind,owner)=>{
const key=String(kind||'');if(!key)throw new TypeError('Interaction kind is required');
const next=owner==null||owner===false?null:String(owner),previous=owners.get(key)||null;
if(previous===next)return false;
if(next==null)owners.delete(key);else owners.set(key,next);
revision++;notify();return true;
};
const active=(scope='any')=>{
const kinds=scopes.get(scope);if(!kinds)throw new TypeError(`Unknown interaction scope: ${scope}`);
for(const kind of kinds)if(owners.has(kind))return true;
return false;
};
return Object.freeze({
set,
clear:kind=>set(kind,null),
owner:kind=>owners.get(String(kind||''))||null,
active,
snapshot,
subscribe(listener){if(typeof listener!=='function')throw new TypeError('Listener must be a function');listeners.add(listener);return()=>listeners.delete(listener)}
});
}
return Object.freeze({DEFAULT_SCOPES,createInteractionState});
});