Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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,28 @@ 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");
});

it("reverts procedural overrides on state exit", () => {
const ctx = createMockContext();
const fsm = new BehaviorFSM(ctx);
Expand Down
34 changes: 18 additions & 16 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 @@ -122,6 +123,7 @@ export class BehaviorFSM {
} else if (fromState?.entryProfile) {
this.revertProfile(fromState.entryProfile);
}
this.releaseActiveSemanticParameters();

// 3. Call enter hook on new state
if (toState.onEnter) {
Expand Down Expand Up @@ -209,15 +211,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 +250,6 @@ export class BehaviorFSM {
const {
motionLayerSystem,
filterPipeline,
semanticLayer,
proceduralSystem,
} = this.context;

Expand All @@ -269,12 +270,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 +285,11 @@ export class BehaviorFSM {
}
}
}

private releaseActiveSemanticParameters(): void {
for (const name of this.activeSemanticParameters) {
this.context.semanticLayer?.releaseSemantic(name, "fsm");
}
this.activeSemanticParameters.clear();
Comment on lines +289 to +293
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { Live2dRuntimeController } from "../controller";
import type { BehaviorFSM } from "../../behavior";
import type { ParameterCoordinator } from "../coordinator";

describe("Live2dRuntimeController", () => {
it("creates with default config", () => {
Expand Down Expand Up @@ -139,6 +140,42 @@ describe("Live2dRuntimeController", () => {
expect(history.length).toBe(10);
});

it("getSemanticParameters reports the last rendered value after engine restore", () => {
const controller = new Live2dRuntimeController();
const values = new Float32Array(1);
const coreModel = {
_model: {
parameters: {
ids: ["PARAM_ANGLE_X"],
values,
minimumValues: new Float32Array([-30]),
maximumValues: new Float32Array([30]),
defaultValues: new Float32Array(1),
},
},
getParameterValueByIndex: (index: number) => values[index] ?? 0,
setParameterValueByIndex: (index: number, value: number) => {
values[index] = value;
},
};

const layer = controller.getSemanticLayer();
layer.detectFromModel({ internalModel: { coreModel } });
layer.holdSemantic("angleX", 12, "manual", 1);
layer.setSemantic("angleX", 5, "add", "procedural", 4);
const coordinator = (controller as unknown as { coordinator: ParameterCoordinator }).coordinator;
coordinator.flush();
layer.captureRenderedValues();
values[0] = 0; // The engine restores its saved baseline after rendering.

// The hold is applied per frame and the engine restores its own value
// afterwards, so the raw parameter still reads 0.
expect(layer.getSemantic("angleX")).toBe(0);
expect(controller.getSemanticParameters()).toEqual([
{ name: "angleX", value: 17 },
]);
});

it("getSemanticParameters returns empty before detection", () => {
const controller = new Live2dRuntimeController();
expect(controller.getSemanticParameters()).toEqual([]);
Expand Down
Loading