ddd
This commit is contained in:
parent
0a343ecbc5
commit
c3a6f5ff37
80 changed files with 2512 additions and 1625 deletions
73
client/input/drag.js
Normal file
73
client/input/drag.js
Normal 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});
|
||||
});
|
||||
85
client/input/frame-scheduler.js
Normal file
85
client/input/frame-scheduler.js
Normal 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});
|
||||
});
|
||||
47
client/input/gesture-coordinator.js
Normal file
47
client/input/gesture-coordinator.js
Normal 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&¤t!==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});
|
||||
});
|
||||
52
client/input/interaction-state.js
Normal file
52
client/input/interaction-state.js
Normal 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});
|
||||
});
|
||||
8
client/styles/accessibility.css
Normal file
8
client/styles/accessibility.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
@media(prefers-reduced-motion:reduce){
|
||||
*,*::before,*::after{scroll-behavior:auto!important;transition-duration:.001ms!important}
|
||||
.board-card.revealing,.board-card.completing .path,.completion-burst,.gate-dot.frontier,.tutorial-demo *,.tutorial-demo::after,.num.turn-warning,.number-turn-warning,.pill.time-attack.active>span:first-child,.time-attack-clock.urgent,.completion-flash,.gate-connect-pulse,.number-match-orbit-holes,#viewport::before{animation:none!important}
|
||||
.completion-burst{opacity:1}
|
||||
.tutorial-path{stroke-dashoffset:0}
|
||||
.tutorial-shop-marker,.tutorial-number-pulse{transform:none}
|
||||
.tutorial-shop-score{opacity:1}
|
||||
}
|
||||
6
client/styles/base.css
Normal file
6
client/styles/base.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
*{box-sizing:border-box;user-select:none;-webkit-user-select:none}
|
||||
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-family:var(--dot-font);font-variant-ligatures:none;-webkit-font-smoothing:none;text-rendering:geometricPrecision}
|
||||
body,button,input,select,textarea{font-family:var(--dot-font)}
|
||||
button,input,select,textarea{font-size:inherit}
|
||||
button{font:inherit;color:inherit;letter-spacing:.04em}
|
||||
button:focus-visible,[tabindex]:focus-visible{outline:3px solid #fff;outline-offset:3px}
|
||||
17
client/styles/tokens.css
Normal file
17
client/styles/tokens.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@font-face{font-family:"DotGothic16Local";src:url("../../assets/fonts/DotGothic16-Regular.ttf") format("truetype");font-style:normal;font-weight:400;font-display:swap}
|
||||
:root{
|
||||
--bg:#121416;
|
||||
--panel:#1c1f22;
|
||||
--ink:#f3f4f5;
|
||||
--muted:#8b949b;
|
||||
--grid:rgba(198,210,218,.28);
|
||||
--accent:#d9f06f;
|
||||
--bad:#ff6e7a;
|
||||
--dot-font:"DotGothic16Local","DotGothic16","MS Gothic","MS ゴシック",ui-monospace,monospace;
|
||||
--emoji-font:"Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif;
|
||||
--bar-height:54px;
|
||||
--safe-top:env(safe-area-inset-top,0px);
|
||||
--safe-right:env(safe-area-inset-right,0px);
|
||||
--safe-bottom:env(safe-area-inset-bottom,0px);
|
||||
--safe-left:env(safe-area-inset-left,0px)
|
||||
}
|
||||
17
client/ui/cursor.css
Normal file
17
client/ui/cursor.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
body[data-cursor-mode="dom"],
|
||||
body[data-cursor-mode="dom"] *{cursor:none!important}
|
||||
.emoji-glyph,#customEmojiCursor{font-family:var(--emoji-font)!important;font-variant-emoji:emoji}
|
||||
.item-flag-image{display:block;width:34px;height:26px;object-fit:contain;pointer-events:none}
|
||||
#customEmojiCursor{position:fixed;z-index:300;left:0;top:0;display:none;width:32px;height:32px;place-items:center;overflow:visible;border:0;border-radius:0;background:transparent;font-size:26px;line-height:1;opacity:1;pointer-events:none;will-change:transform}
|
||||
#customEmojiCursor.visible{display:grid}
|
||||
#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden;border:2px solid #101214;border-radius:50%;background:#f4d45f;font-size:26px;opacity:1;box-shadow:0 2px 7px rgba(0,0,0,.5)}
|
||||
#customEmojiCursor.flag-cursor img{position:absolute;left:50%;top:50%;display:block;width:140%;height:110%;margin:0;transform:translate(-50%,-50%);border-radius:0;object-fit:cover;object-position:center;clip-path:none}
|
||||
body.is-drawing #viewport,
|
||||
body.is-drawing #viewport *{cursor:none!important}
|
||||
body.is-drawing #customEmojiCursor{display:none!important}
|
||||
#pickupHandleOverlay{position:fixed;z-index:299;left:0;top:0;display:none;width:15px;height:15px;border:2px solid #111820;border-radius:50%;background:var(--pickup-color,#f4d45f);box-shadow:0 0 0 2px rgba(255,255,255,.82),0 2px 7px rgba(0,0,0,.48);pointer-events:none;will-change:transform;contain:strict}
|
||||
#pickupHandleOverlay.visible{display:block}
|
||||
#pickupHandleOverlay.custom-cursor{width:32px;height:32px;place-items:center;border:0;border-radius:0;background:transparent;box-shadow:none;font:26px/1 var(--emoji-font);overflow:visible}
|
||||
#pickupHandleOverlay.custom-cursor.visible{display:grid}
|
||||
#pickupHandleOverlay.custom-cursor.flag-cursor{overflow:hidden;border:2px solid #101214;border-radius:50%;background:var(--pickup-color,#f4d45f);box-shadow:0 2px 7px rgba(0,0,0,.5)}
|
||||
#pickupHandleOverlay.custom-cursor.flag-cursor img{position:absolute;left:50%;top:50%;display:block;width:140%;height:110%;transform:translate(-50%,-50%);object-fit:cover;object-position:center;pointer-events:none}
|
||||
30
client/ui/cursor.js
Normal file
30
client/ui/cursor.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
(function(root,factory){
|
||||
const api=factory();
|
||||
if(typeof module==='object'&&module.exports)module.exports=api;
|
||||
root.BendCursorModel=api;
|
||||
})(typeof globalThis!=='undefined'?globalThis:this,function(){
|
||||
'use strict';
|
||||
|
||||
function createCursorModel(items=[]){
|
||||
const byStyle=new Map();
|
||||
for(const item of items)if(item?.cursorStyle)byStyle.set(item.cursorStyle,Object.freeze({...item}));
|
||||
const item=style=>byStyle.get(String(style||''))||null;
|
||||
const presentation=style=>{
|
||||
const selected=item(style);
|
||||
if(!selected)return Object.freeze({style:'default',mode:'default',asset:null,glyph:'',flag:false,hotspot:Object.freeze([0,0]),pickup:Object.freeze({kind:'default'})});
|
||||
const flag=Boolean(selected.flagAsset),asset=selected.flagAsset||null,glyph=flag?'':selected.cursorEmoji||'';
|
||||
return Object.freeze({
|
||||
style:selected.cursorStyle,
|
||||
mode:'dom',
|
||||
asset,
|
||||
glyph,
|
||||
flag,
|
||||
hotspot:Object.freeze([.5,.5]),
|
||||
pickup:Object.freeze({kind:flag?'flag':'glyph',asset,glyph})
|
||||
});
|
||||
};
|
||||
return Object.freeze({item,presentation,styles:()=>Object.freeze([...byStyle.keys()])});
|
||||
}
|
||||
|
||||
return Object.freeze({createCursorModel});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue