lili 5cb73a6d82 feat(cheap-gen): 阶段一B #4 gamefeel 加 CoyoteTimer/ComboWindow 工厂方法(免 new+传 time)
附A #4:gamefeel 的 CoyoteTimer/ComboWindow 是 class、须 new 且要传受控 TimeSource——AI 拿不到
受控 time、做不出来。加工厂方法 gamefeel.createCoyoteTimer(opts)/createComboWindow(opts):
用 init 时捕获的受控时间源自动注入,AI 直接调、免 new + 免找 time。全链路同改:
- gamefeel/impl.js:init 捕获 ctx.time,加两个工厂方法(new CoyoteTimer/ComboWindow(time,opts))
- gamefeel/api.d.ts:GamefeelPlugin 接口加两个工厂声明
- skill line115:列工厂方法(免 new + 自动注入受控 time);内置 inputBuffer 改 gamefeel.inputBuffer
验证:gamefeel 18/18 + 确定性(init 后工厂可调、返带 isAvailable/hit 的实例)。prompt 不提 gamefeel 故不同改。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 01:39:05 -07:00

261 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* gamefeel/api.d.ts — 手感原语插件公开类型门面(手写,对齐 core-protocol-v0)
* owner:T1b-α lane-math | 消费方:所有 lane / agent 造游戏时读本件了解手感原语能力面
*
* 【本件定位】
* 「游戏手感原语」引擎能力的类型契约:输入缓冲 / coyote time / 缓动标准库 / 连击计时窗。
* 缓动为**纯函数**;输入缓冲/coyote/连击为**时间驱动状态机**(时间一律走受控时间源,禁 Date.now)。
*
* 【受控面用法(具名消费者)】
* 输入缓冲经受控面 `getInput`(订阅归一化输入事件)+ `time`(事件/查询时间戳)工作;
* coyote/连击窗经受控 `time` 推进。绝不直接 addEventListener / 读 Date.now / performance.now。
*
* 【受控面铁律(继承自 core)】
* 只经 PluginContext 受控面拿引擎能力,绝不直透 littlejsengine 裸对象 / 原生 DOM 事件。
*/
import type { Plugin, PluginContext, InputEventType, InputSource, TimeSource, EngineMath } from '../../core/api.d.ts';
/* ──────────────────────────────────────────────────────────────────────────
* 缓动标准库(纯函数,端点恒等 f(0)=0、f(1)=1)
* ────────────────────────────────────────────────────────────────────────── */
/**
* 缓动函数:入参 t∈[0,1](外部应自行归一化进度),返回缓动后的进度。
* 约定:所有缓动 **f(0)=0、f(1)=1**(端点恒等);越界 t 由各实现钳制到 [0,1]。
*/
export type EasingFn = (t: number) => number;
/**
* 缓动标准库命名空间。每族含 in / out / inOut 三向(linear 仅一项)。
* 全部为纯函数、确定性、无副作用、不触受控面。
*/
export interface EasingLib {
/** 线性(恒等钳制版):f(t)=clamp(t,0,1)。 */
linear: EasingFn;
/** 二次缓入 t²。 */
quadIn: EasingFn;
/** 二次缓出。 */
quadOut: EasingFn;
/** 二次缓入缓出。 */
quadInOut: EasingFn;
/** 三次缓入 t³。 */
cubicIn: EasingFn;
/** 三次缓出。 */
cubicOut: EasingFn;
/** 三次缓入缓出。 */
cubicInOut: EasingFn;
/** 弹性缓入(末段回弹超调后收敛;端点仍恒等)。 */
elasticIn: EasingFn;
/** 弹性缓出。 */
elasticOut: EasingFn;
/** 弹性缓入缓出。 */
elasticInOut: EasingFn;
/** 回弹(back)缓入:先回拉再冲过(中段越界 [0,1],端点恒等)。 */
backIn: EasingFn;
/** 回弹缓出。 */
backOut: EasingFn;
/** 回弹缓入缓出。 */
backInOut: EasingFn;
}
/**
* 缓动模块级单例(向后兼容导出,= 内置 builtinEasing fallback 实现)。
* 【2026-06-13 easing 改判】模块级无 ctx 拿不到引擎 → 此导出恒为内置纯函数 fallback(行为/签名不变);
* **门面包装引擎 Ease 经插件实例 `GamefeelPlugin.easing` getter 暴露**(init 经 ctx.getEngine().math 门面优先)。
*/
export declare const easing: EasingLib;
/**
* 缓动门面工厂(impl 内部 export-for-test):造一份「门面优先、null-engine 退内置 fallback」的缓动 family。
* 门面分支 clamp01(t) 后调引擎裸曲线(保越界钳语义);engMath 缺位/缺某曲线 → 该路退内置(backInOut/
* elasticInOut 因引擎门面刻意不暴而永久退内置,是补层非 fallback)。供单测验门面转发/null 退内置。
* @param engMath 引擎 math 门面句柄(ctx.getEngine()?.math),缺位时整体退内置 fallback
*/
export declare function makeEasingFacade(engMath: EngineMath | null | undefined): EasingLib;
/* ──────────────────────────────────────────────────────────────────────────
* 输入缓冲(可配窗口 ms,经受控面 getInput + time)
* ────────────────────────────────────────────────────────────────────────── */
/** InputBuffer 构造选项。 */
export interface InputBufferOptions {
/**
* 缓冲窗口(毫秒):一次输入在被记录后的 windowMs 内仍可被 `consume` 命中("提前按"宽容)。
* 缺省 120ms。必须 > 0。
*/
windowMs?: number;
/**
* 关注的输入事件类型集合(默认全 5 类:pointerdown/pointermove/pointerup/keydown/keyup)。
* 只缓冲这些类型的事件。
*/
types?: InputEventType[];
}
/**
* 输入缓冲:把「最近一次(按类型)的输入」记下时间戳,供逻辑帧在窗口内"补判"。
* 典型用途 = 容忍玩家略早于可执行时机的按键(窗口内仍算有效)。**无玩法语义**,只是时间窗内的输入记账。
*
* 工作方式:绑定受控 InputSource(订阅)+ 受控 TimeSource(取时间)。每条关注事件记录其 tMs;
* `consume(type, nowMs)` 若存在「nowMs - 记录时间 ≤ windowMs」的未消费记录则命中并清除(一次性消费)。
*
* **时间源铁律**:一切时间走受控 TimeSource(事件 tMs 来自受控时间源;查询 nowMs 调用方传或用 time.nowMs)。
*/
export declare class InputBuffer {
/**
* @param input 受控输入订阅面(来自 ctx.getInput())
* @param time 受控时间源(来自 ctx.time)
* @param opts 窗口/类型
*/
constructor(input: InputSource, time: TimeSource, opts?: InputBufferOptions);
/**
* 查询并消费:若该 type 有「在窗口内」的未消费输入记录,返回 true 并清除该记录(一次性)。
* @param type 输入类型
* @param nowMs 当前时刻(毫秒);缺省用受控 time.nowMs()
* @returns 是否命中缓冲
*/
consume(type: InputEventType, nowMs?: number): boolean;
/**
* 只查不消费:该 type 是否有窗口内的有效缓冲(不清除)。
* @param type @param nowMs 缺省用受控 time.nowMs()
*/
peek(type: InputEventType, nowMs?: number): boolean;
/** 清空全部缓冲记录。 */
clear(): void;
/** 注销输入订阅(释放,幂等)。插件 dispose 时调;注册器另会兜底回收。 */
dispose(): void;
}
/* ──────────────────────────────────────────────────────────────────────────
* coyote time 计时器
* ────────────────────────────────────────────────────────────────────────── */
/** CoyoteTimer 构造选项。 */
export interface CoyoteTimerOptions {
/**
* coyote 宽限窗口(毫秒):自「条件变为不满足(如离地)」起,该窗口内 `isAvailable` 仍返回 true。
* 缺省 100ms。必须 > 0。
*/
windowMs?: number;
}
/**
* coyote time 计时器:把「某条件刚结束后的一小段宽限期」记账。
* 典型用途 = 离开平台边缘后仍宽限片刻可执行某动作。**无玩法语义**,只是「条件失效后的时间宽限」记账。
*
* 工作方式:`setGrounded(true/false, nowMs)` 标记条件状态变化(true→false 时记下失效时刻);
* `isAvailable(nowMs)`:条件为 true 时恒可用;条件刚 false 但「nowMs - 失效时刻 ≤ windowMs」仍可用。
* `consume()` 用掉本次宽限(防一次宽限多次触发)。时间走受控 TimeSource。
*/
export declare class CoyoteTimer {
/**
* @param time 受控时间源
* @param opts 窗口
*/
constructor(time: TimeSource, opts?: CoyoteTimerOptions);
/**
* 标记条件状态(如"是否在地面/可执行区")。true→false 跳变时记下失效时刻(宽限期起点)。
* @param grounded 条件是否满足
* @param nowMs 当前时刻;缺省用受控 time.nowMs()
*/
setGrounded(grounded: boolean, nowMs?: number): void;
/**
* 当前是否处于可用窗口(条件满足,或条件刚失效但在宽限窗口内且未被消费)。
* @param nowMs 缺省用受控 time.nowMs()
*/
isAvailable(nowMs?: number): boolean;
/** 消费本次宽限(用掉后宽限期内 isAvailable 返回 false,直到条件再次满足复位)。 */
consume(): void;
/** 复位(清宽限与消费标记)。 */
reset(): void;
}
/* ──────────────────────────────────────────────────────────────────────────
* 连击计时窗
* ────────────────────────────────────────────────────────────────────────── */
/** ComboWindow 构造选项。 */
export interface ComboWindowOptions {
/**
* 连击窗口(毫秒):相邻两次 `hit` 间隔 ≤ windowMs 则连击数 +1,否则重置为 1。缺省 500ms。必须 > 0。
*/
windowMs?: number;
/** 连击上限(达到后不再增长,停在上限)。缺省 Infinity(不封顶)。 */
maxCount?: number;
}
/**
* 连击计时窗:按「相邻命中间隔是否在窗口内」累计连击数。**无玩法语义**,只是时间窗内的计数。
* 工作方式:`hit(nowMs)` 若距上次命中 ≤ windowMs 则 count+1,否则重置为 1;`getCount(nowMs)` 读当前连击
* (若已超窗则视为断连返回 0)。时间走受控 TimeSource。
*/
export declare class ComboWindow {
/**
* @param time 受控时间源
* @param opts 窗口/上限
*/
constructor(time: TimeSource, opts?: ComboWindowOptions);
/**
* 记一次命中:在窗口内则连击 +1(封顶 maxCount),否则重置为 1。
* @param nowMs 当前时刻;缺省用受控 time.nowMs()
* @returns 命中后的当前连击数
*/
hit(nowMs?: number): number;
/**
* 读当前连击数:若距上次命中已超窗口(断连)则返回 0,否则返回当前连击数。
* @param nowMs 缺省用受控 time.nowMs()
*/
getCount(nowMs?: number): number;
/** 复位连击(清零)。 */
reset(): void;
}
/* ──────────────────────────────────────────────────────────────────────────
* 插件工厂(把输入缓冲接上受控面;缓动经 plugin.easing getter 走 getEngine().math.easing 门面)
* ────────────────────────────────────────────────────────────────────────── */
/** gamefeel 插件可选配置。 */
export interface GamefeelPluginOptions {
/** 内置输入缓冲的窗口(毫秒,缺省 120)。 */
inputBufferMs?: number;
/** 内置输入缓冲关注的类型(缺省全 5 类)。 */
inputTypes?: InputEventType[];
}
/**
* gamefeel 插件实例 = core Plugin + 一个 init 时接上受控面的内置 InputBuffer 句柄。
* 缓动/coyote/连击为模块级导出(import 即用);插件额外托管一个「已接好 getInput+time」的 InputBuffer,
* 演示「受控面如何被正确接线」,dispose 时注销订阅。
*/
export interface GamefeelPlugin extends Plugin {
/**
* 插件内置的输入缓冲(init 后接上受控面;init 前为 null)。
* dispose 时其订阅被注销(注册器另会兜底回收)。
*/
readonly inputBuffer: InputBuffer | null;
/**
* 缓动 family(2026-06-13 easing 改判·additive 新增):门面优先——init 经 ctx.getEngine().math.easing
* 门面包装引擎 Ease(clamp01 包裹保越界钳语义),null-engine 退内置纯函数 fallback。签名 (t)=>number 不变。
* 与模块级 `easing`(恒内置 fallback)区别:此 getter 在引擎可用时走引擎 Ease(11 条),backInOut/elasticInOut
* 永久走内置(引擎门面刻意不暴此二者)。
*/
readonly easing: EasingLib;
/** #4:用插件 init 时捕获的受控时间源造一个 CoyoteTimer(免自己 new + 找 time)。init 后可用。 */
createCoyoteTimer(opts?: CoyoteTimerOptions): CoyoteTimer;
/** #4:用受控时间源造一个 ComboWindow(免自己 new + 找 time)。init 后可用。 */
createComboWindow(opts?: ComboWindowOptions): ComboWindow;
}
/**
* 创建 gamefeel 插件(可注册进 PluginRegistry)。
* init 时经 ctx.getInput()+ctx.time 接一个内置 InputBuffer(演示受控面接线);dispose 注销订阅。
* @param opts 可选配置
*/
export declare function createGamefeelPlugin(opts?: GamefeelPluginOptions): GamefeelPlugin;
export type { Plugin, PluginContext } from '../../core/api.d.ts';