Skip to content
Merged
15 changes: 11 additions & 4 deletions packages/live2d/src/components/Live2dDevTools/Live2dDevTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,12 @@ export class Live2dDevTools extends UnoLitElement {
this._controller?.getFilterPipeline().setIntensity(id, v);
}
private _setParamValue(p: string, v: number): void {
this._controller
?.getSemanticLayer()
.setSemantic(p, v, "override", "manual", 1);
this._controller?.getSemanticLayer().holdSemantic(p, v, "manual", 1);
this.requestUpdate();
}
private _resetParamValue(p: string): void {
this._controller?.getSemanticLayer().releaseSemantic(p, "manual");
this.requestUpdate();
}

private _sectionHeader(
Expand Down Expand Up @@ -766,9 +769,13 @@ export class Live2dDevTools extends UnoLitElement {
<div class="flex items-center gap-2.5 px-2 py-1 rounded-lg bg-white/[0.015]">
<span class="w-20 text-[11px] text-gray-500 font-mono truncate" title="${p.name}">${p.name}</span>
<input class="flex-1 h-1 bg-white/[0.06] rounded-full outline-none appearance-none cursor-pointer"
type="range" min="-30" max="30" step="0.1" .value=${String(p.value ?? 0)}
type="range" min="-30" max="30" step="0.1" .value=${String(this._controller?.getSemanticLayer().getHeldSemantic(p.name, "manual") ?? p.value ?? 0)}
@input=${(e: Event) => this._setParamValue(p.name, Number((e.target as HTMLInputElement).value))}/>
<span class="w-11 text-right font-mono text-[11px] font-semibold ${valueColor(p.value ?? 0)}">${(p.value ?? 0).toFixed(2)}</span>
${this._controller?.getSemanticLayer().hasHeldSemantic(p.name, "manual")
? html`<button type="button" class="text-[11px] text-orange-300 hover:text-orange-200 cursor-pointer" title="恢复自动控制" aria-label="恢复 ${p.name} 的自动控制"
@click=${() => this._resetParamValue(p.name)}>恢复</button>`
: html``}
</div>
`,
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { Live2dDevTools } from "../Live2dDevTools";
import { Live2dRuntimeController } from "@/live2d/runtime/controller";
import { render, type TemplateResult } from "lit";

interface DevToolsPrivate {
_transitionFSM(state: string): void;
_transitionEmotion(emotion: string): void;
_applyFilter(preset: string): void;
_clearFilters(): void;
_setParamValue(name: string, value: number): void;
_resetParamValue(name: string): void;
_renderParamSection(): TemplateResult;
_setFilterIntensity(id: string, value: number): void;
_toggleVisible(): void;
_handleKeyDown(event: KeyboardEvent): void;
_visible: boolean;
_sections: Array<{ id: string; expanded: boolean }>;
_controller: Live2dRuntimeController | null;
}

Expand Down Expand Up @@ -82,11 +86,34 @@ describe("Live2dDevTools", () => {
expect(clearSpy).toHaveBeenCalled();
});

it("slider changes parameter value with manual priority", () => {
it("slider holds the parameter with manual priority", () => {
const semanticLayer = controller.getSemanticLayer();
const setSemanticSpy = vi.spyOn(semanticLayer, "setSemantic");
const holdSpy = vi.spyOn(semanticLayer, "holdSemantic");
asPrivate(devtools)._setParamValue("mouthOpen", 5.5);
expect(setSemanticSpy).toHaveBeenCalledWith("mouthOpen", 5.5, "override", "manual", 1);
expect(holdSpy).toHaveBeenCalledWith("mouthOpen", 5.5, "manual", 1);
});

it("reset releases only the manual parameter hold", () => {
const semanticLayer = controller.getSemanticLayer();
const releaseSpy = vi.spyOn(semanticLayer, "releaseSemantic");
asPrivate(devtools)._resetParamValue("mouthOpen");
expect(releaseSpy).toHaveBeenCalledWith("mouthOpen", "manual");
});

it("shows a restore control for a manually held parameter", () => {
vi.spyOn(controller, "getSemanticParameters").mockReturnValue([{ name: "angleX", value: 17 }]);
vi.spyOn(controller.getSemanticLayer(), "hasHeldSemantic").mockReturnValue(true);
vi.spyOn(controller.getSemanticLayer(), "getHeldSemantic").mockReturnValue(12);
const paramsSection = asPrivate(devtools)._sections.find((section) => section.id === "params");
expect(paramsSection).toBeDefined();
if (paramsSection) paramsSection.expanded = true;
const container = document.createElement("div");

render(asPrivate(devtools)._renderParamSection(), container);

expect(container.querySelector('button[aria-label="恢复 angleX 的自动控制"]')).not.toBeNull();
expect((container.querySelector('input[type="range"]') as HTMLInputElement).value).toBe("12");
expect(container.textContent).toContain("17.00");
});

it("filter intensity slider adjusts effect intensity", () => {
Expand All @@ -100,7 +127,7 @@ describe("Live2dDevTools", () => {
describe("visibility toggle", () => {
it("toggle switches visibility state", () => {
const initialVisible = asPrivate(devtools)._visible;
asPrivate(devtools)._toggleVisible();
asPrivate(devtools)._handleKeyDown(new KeyboardEvent("keydown", { key: "D", ctrlKey: true, shiftKey: true }));
const afterToggle = asPrivate(devtools)._visible;
expect(afterToggle).toBe(!initialVisible);
});
Expand Down
101 changes: 95 additions & 6 deletions packages/live2d/src/runtime/behavior/__tests__/behavior-fsm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { BehaviorFSM } from "../fsm";
import { mergeProfiles, buildProfile } from "../profile";
import type { BehaviorState, BehaviorProfile, BehaviorContext } from "../types";
import { SemanticParameterLayer } from "../../semantic";
import { ParameterCoordinator } from "../../controller/coordinator";

describe("BehaviorFSM", () => {
function createMockContext(): BehaviorContext {
Expand All @@ -20,6 +22,8 @@ describe("BehaviorFSM", () => {
} as unknown as NonNullable<BehaviorContext["filterPipeline"]>,
semanticLayer: {
setSemantic: vi.fn(),
holdSemantic: vi.fn(),
releaseSemantic: vi.fn(),
getSemantic: vi.fn(() => 0),
hasSemantic: vi.fn(() => true),
getCapabilityProfile: vi.fn(() => ({
Expand Down Expand Up @@ -162,6 +166,36 @@ describe("BehaviorFSM", () => {
});

describe("entry profile application", () => {
it("keeps an additive state value stable across frames and removes it on exit", () => {
const values = new Float32Array(1);
const layer = new SemanticParameterLayer();
(layer as unknown as { resolved: Map<string, { id: string; index: number }> }).resolved =
new Map([["angleX", { id: "PARAM_ANGLE_X", index: 0 }]]);
(layer as unknown as { accessor: object }).accessor = {
getValue: (index: number) => values[index],
setValue: (index: number, value: number) => { values[index] = value; },
getMin: () => -30,
getMax: () => 30,
};
const coordinator = new ParameterCoordinator(layer);
layer.setCoordinator(coordinator);
const fsm = new BehaviorFSM({ semanticLayer: layer }, { defaultDebounceMs: 0 });
fsm.registerState({ name: "happy", entryProfile: {
semanticParameters: { angleX: { value: 5, blendMode: "add" } },
} });
fsm.registerState({ name: "idle" });

fsm.transitionTo("happy");
for (let frame = 0; frame < 3; frame++) {
coordinator.flush();
expect(values[0]).toBe(5);
values[0] = 0; // engine loadParameters()
}

fsm.transitionTo("idle");
coordinator.flush();
expect(values[0]).toBe(0);
});
it("applies motion layer effects on state entry", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx);
Expand Down Expand Up @@ -215,13 +249,37 @@ describe("BehaviorFSM", () => {

fsm.transitionTo("happy");

expect(ctx.semanticLayer!.setSemantic).toHaveBeenCalledWith(
expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith(
"mouthSmile",
0.6,
"fsm",
2,
"override",
);
});

it("holds add-mode semantic parameters for the state's lifetime", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx);
fsm.registerState({
name: "happy",
entryProfile: {
semanticParameters: {
mouthSmile: { value: 0.2, blendMode: "add" },
},
},
});

fsm.transitionTo("happy");

expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith(
"mouthSmile",
0.2,
"fsm",
2,
"add",
);
expect(ctx.semanticLayer!.setSemantic).not.toHaveBeenCalled();
});

it("applies procedural overrides on state entry", () => {
Expand Down Expand Up @@ -280,7 +338,7 @@ describe("BehaviorFSM", () => {
expect(ctx.filterPipeline!.remove).toHaveBeenCalledWith("handle-happy-glow");
});

it("resets semantic parameters on state exit", () => {
it("releases held semantic parameters on state exit", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx);
fsm.registerState({
Expand All @@ -296,15 +354,46 @@ describe("BehaviorFSM", () => {
fsm.transitionTo("happy");
fsm.transitionTo("idle");

expect(ctx.semanticLayer!.setSemantic).toHaveBeenCalledWith(
expect(ctx.semanticLayer!.releaseSemantic).toHaveBeenCalledWith(
"mouthSmile",
0,
"override",
"fsm",
2,
);
});

it("releases the entry parameter even when exitProfile names other parameters", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx);
fsm.registerState({
name: "happy",
entryProfile: { semanticParameters: { mouthSmile: { value: 0.2, blendMode: "add" } } },
exitProfile: { semanticParameters: { angleX: { value: 0 } } },
});
fsm.registerState({ name: "idle" });

fsm.transitionTo("happy");
fsm.transitionTo("idle");

expect(ctx.semanticLayer!.releaseSemantic).toHaveBeenCalledWith("mouthSmile", "fsm");
expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith("angleX", 0, "fsm", 2);
});

it("keeps an explicit exit reset until a later state transition", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx, { defaultDebounceMs: 0 });
fsm.registerState({
name: "happy",
exitProfile: { semanticParameters: { browLY: { value: 0.8 } } },
});
fsm.registerState({ name: "idle" });
fsm.registerState({ name: "talking" });

fsm.transitionTo("happy");
fsm.transitionTo("idle");
expect(ctx.semanticLayer!.holdSemantic).toHaveBeenCalledWith("browLY", 0, "fsm", 2);
fsm.transitionTo("talking");
expect(ctx.semanticLayer!.releaseSemantic).toHaveBeenCalledWith("browLY", "fsm");
});

it("reverts procedural overrides on state exit", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx);
Expand Down
49 changes: 32 additions & 17 deletions packages/live2d/src/runtime/behavior/fsm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class BehaviorFSM {
// Track applied effects so they can be reversed on exit
private activeFilterHandles = new Map<EffectPreset, string>();
private activeMotionLayers = new Set<LayerName>();
private activeSemanticParameters = new Set<string>();
private proceduralModuleStates = new Map<string, boolean>();

constructor(context: BehaviorContext, config: BehaviorFSMConfig = {}) {
Expand Down Expand Up @@ -116,9 +117,12 @@ export class BehaviorFSM {
fromState.onExit(this.context);
}

// 2. Revert effects from current state
// 2. Revert effects from current state. Release entry holds first so an
// explicit exit reset can survive in the following state.
this.releaseActiveSemanticParameters();
if (fromState?.exitProfile) {
this.revertProfile(fromState.exitProfile);
this.applyExitSemanticReset(fromState.exitProfile);
} else if (fromState?.entryProfile) {
this.revertProfile(fromState.entryProfile);
}
Expand Down Expand Up @@ -209,15 +213,15 @@ export class BehaviorFSM {

if (profile.semanticParameters && semanticLayer) {
for (const [name, config] of Object.entries(profile.semanticParameters)) {
if (semanticLayer.hasSemantic(name)) {
semanticLayer.setSemantic(
name,
config.value,
config.blendMode ?? "override",
"fsm",
2,
);
}
if (!semanticLayer.hasSemantic(name)) continue;
semanticLayer.holdSemantic(
name,
config.value,
"fsm",
2,
config.blendMode ?? "override",
);
this.activeSemanticParameters.add(name);
}
}

Expand Down Expand Up @@ -248,7 +252,6 @@ export class BehaviorFSM {
const {
motionLayerSystem,
filterPipeline,
semanticLayer,
proceduralSystem,
} = this.context;

Expand All @@ -269,12 +272,6 @@ export class BehaviorFSM {
}
}

if (profile.semanticParameters && semanticLayer) {
for (const name of Object.keys(profile.semanticParameters)) {
semanticLayer.setSemantic(name, 0, "override", "fsm", 2);
}
}

if (profile.proceduralOverrides && proceduralSystem) {
for (const moduleName of Object.keys(profile.proceduralOverrides)) {
const previousState = this.proceduralModuleStates.get(moduleName);
Expand All @@ -290,4 +287,22 @@ export class BehaviorFSM {
}
}
}

private releaseActiveSemanticParameters(): void {
for (const name of this.activeSemanticParameters) {
this.context.semanticLayer?.releaseSemantic(name, "fsm");
}
this.activeSemanticParameters.clear();
Comment thread
LIlGG marked this conversation as resolved.
}

private applyExitSemanticReset(profile: BehaviorProfile): void {
if (!profile.semanticParameters || !this.context.semanticLayer) return;
for (const name of Object.keys(profile.semanticParameters)) {
if (!this.context.semanticLayer.hasSemantic(name)) continue;
// Exit profiles historically reset named parameters to zero, regardless
// of their configured value. Keep that reset across engine frames.
this.context.semanticLayer.holdSemantic(name, 0, "fsm", 2);
this.activeSemanticParameters.add(name);
}
}
}
Loading