diff --git a/packages/live2d/src/components/Live2dCanvas.tsx b/packages/live2d/src/components/Live2dCanvas.tsx
index 7fc49bf..7e79dab 100644
--- a/packages/live2d/src/components/Live2dCanvas.tsx
+++ b/packages/live2d/src/components/Live2dCanvas.tsx
@@ -10,6 +10,7 @@ import {
clearCurrentLive2dModel,
setCurrentLive2dModel,
} from "@/live2d/live2d/model-store";
+import { DESKTOP_LIVE2D_CANVAS_SIZE } from "@/live2d/utils/responsive";
import { consume } from "@lit/context";
import { type PropertyValues, type TemplateResult, html } from "lit";
import { property, query, state } from "lit/decorators.js";
@@ -22,6 +23,9 @@ export class Live2dCanvas extends UnoLitElement {
@state()
private model: Model | null = null;
+ @property({ type: Number })
+ public canvasSize = DESKTOP_LIVE2D_CANVAS_SIZE;
+
@query("#live2d")
private _live2d!: HTMLCanvasElement;
@@ -54,6 +58,13 @@ export class Live2dCanvas extends UnoLitElement {
}
}
+ protected updated(changedProperties: PropertyValues): void {
+ super.updated(changedProperties);
+ if (changedProperties.has("canvasSize") && this.model) {
+ void this.model.resize(this.canvasSize);
+ }
+ }
+
getModel(): Model | null {
return this.model;
}
@@ -66,7 +77,15 @@ export class Live2dCanvas extends UnoLitElement {
this._modelInitialized = true;
try {
- this.model = await Model.create(this._live2d, this.config);
+ const initialCanvasSize = this.canvasSize;
+ this.model = await Model.create(
+ this._live2d,
+ this.config,
+ initialCanvasSize,
+ );
+ if (initialCanvasSize !== this.canvasSize) {
+ await this.model.resize(this.canvasSize);
+ }
setCurrentLive2dModel(this.model);
window.dispatchEvent(new ModelReadyEvent({ model: this.model }));
} catch (error) {
diff --git a/packages/live2d/src/components/Live2dTips.tsx b/packages/live2d/src/components/Live2dTips.tsx
index dd3e68a..6a4f8c3 100644
--- a/packages/live2d/src/components/Live2dTips.tsx
+++ b/packages/live2d/src/components/Live2dTips.tsx
@@ -31,6 +31,9 @@ export class Live2dTips extends UnoLitElement {
@property({ attribute: false })
public config?: Live2dConfig;
+ @property({ type: Boolean })
+ public compact = false;
+
@state()
private _isShow = false;
@state()
@@ -76,7 +79,7 @@ export class Live2dTips extends UnoLitElement {
"animate-shake": true,
"animate-delay-5s": true,
"min-h-18": true,
- "w-63": true,
+ "box-border": true,
"bg-tips": true,
border: true,
"border-tips": true,
@@ -95,8 +98,15 @@ export class Live2dTips extends UnoLitElement {
"opacity-0": !this._isShow,
"select-none": true,
};
+ const width = this.compact
+ ? "min(220px, calc(100vw - 1rem))"
+ : "min(15.75rem, calc(100vw - 1rem))";
return html`
-
+
${unsafeHTML(renderedMessage)}
`;
diff --git a/packages/live2d/src/components/Live2dToggle.tsx b/packages/live2d/src/components/Live2dToggle.tsx
index d5474fb..fa04079 100644
--- a/packages/live2d/src/components/Live2dToggle.tsx
+++ b/packages/live2d/src/components/Live2dToggle.tsx
@@ -4,13 +4,14 @@ import {
configContext,
} from "@/live2d/context/config-context";
import { ToggleCanvasEvent } from "@/live2d/events/toggle-canvas";
-import { WIDGET_DRAWER_DURATION_MS } from "@/live2d/helpers/widgetDrawer";
+import { getWidgetDrawerDuration } from "@/live2d/helpers/widgetDrawer";
import {
clearWidgetDismissal,
readWidgetSuppression,
rememberWidgetDismissal,
} from "@/live2d/helpers/widgetVisibility";
import { DraggableMixin } from "@/live2d/mixins/draggable";
+import { isCompactViewport } from "@/live2d/utils/responsive";
import { consume } from "@lit/context";
import { type TemplateResult, html } from "lit";
import { property } from "lit/decorators.js";
@@ -41,7 +42,8 @@ export class Live2dToggle extends DraggableUnoLitElement {
window.addEventListener("live2d:toggle-canvas", this.handleGlobalToggle);
Promise.resolve().then(() => {
- if (readWidgetSuppression(localStorage)) {
+ const widgetSuppressed = readWidgetSuppression(localStorage);
+ if (widgetSuppressed) {
this._isShow = true;
this.requestUpdate();
return;
@@ -71,6 +73,7 @@ export class Live2dToggle extends DraggableUnoLitElement {
role="button"
tabindex="0"
aria-label="打开看板娘"
+ style="bottom: calc(4rem + env(safe-area-inset-bottom, 0px));"
@keydown=${this.handleKeydown}
>
{
this.handleModelReady(event as ModelReadyEvent);
};
render(): TemplateResult {
+ if (this._tools.length === 0) {
+ return html``;
+ }
+
+ const isExpanded = !this.compact || this._isExpanded;
+ const shellClass = [
+ "flex min-w-11 flex-col items-center overflow-hidden rounded-full",
+ "border border-[#f3d7b8]/80 p-1",
+ "transition-[gap,background-color,border-color] duration-300",
+ isExpanded ? "gap-1" : "gap-0",
+ ].join(" ");
+ const shellBackground = this.compact
+ ? "linear-gradient(145deg, rgba(255, 250, 244, 0.86), rgba(255, 255, 255, 0.68))"
+ : "linear-gradient(145deg, rgba(255, 250, 244, 0.72), rgba(255, 255, 255, 0.5))";
+ const toolsClass = [
+ "relative flex w-9 flex-col items-center gap-1 overflow-x-hidden overflow-y-auto",
+ "overscroll-contain transition-[max-height,opacity,transform] duration-300",
+ isExpanded
+ ? "pointer-events-auto translate-y-0 opacity-100"
+ : "pointer-events-none -translate-y-2 opacity-0",
+ ].join(" ");
+ const toolsMaxHeight = isExpanded
+ ? this.compact
+ ? "min(10rem, calc(42vh - 3rem))"
+ : "calc(100vh - 4rem)"
+ : "0px";
+
return html``;
}
- renderTool(tool: Tool): TemplateResult {
- return html` void tool.triggerExecute()}
+ type="button"
+ class=${buttonClass}
+ title=${tool.name()}
+ aria-label=${tool.name()}
+ tabindex=${isExpanded ? "0" : "-1"}
+ @mouseenter=${() => {
+ this._hoveredToolIndex = index;
+ }}
+ @focus=${() => {
+ this._hoveredToolIndex = index;
+ }}
+ @click=${() => this.executeTool(tool)}
>
- `;
+ `;
+ }
+
+ protected updated(changedProperties: PropertyValues): void {
+ super.updated(changedProperties);
+ if (changedProperties.has("compact") && this.compact) {
+ this._isExpanded = false;
+ this._hoveredToolIndex = null;
+ }
}
connectedCallback(): void {
@@ -74,6 +146,81 @@ export class Live2dTools extends UnoLitElement {
}
}
+ private renderDrawerToggle(): TemplateResult | undefined {
+ if (!this.compact) {
+ return;
+ }
+
+ return html``;
+ }
+
+ private readonly handleDrawerKeydown = (event: KeyboardEvent): void => {
+ if (!this.compact || event.key !== "Escape" || !this._isExpanded) {
+ return;
+ }
+ event.preventDefault();
+ this._isExpanded = false;
+ this.updateComplete.then(() => {
+ this.renderRoot
+ .querySelector("#live2d-tools-toggle")
+ ?.focus();
+ });
+ };
+
+ private renderHoverIndicator(): TemplateResult {
+ const offset = (this._hoveredToolIndex ?? 0) * 40;
+ const isVisible = this._hoveredToolIndex !== null;
+ return html``;
+ }
+
+ private readonly clearHoveredTool = (): void => {
+ this._hoveredToolIndex = null;
+ };
+
+ private readonly handleToolsFocusOut = (event: FocusEvent): void => {
+ const nextTarget = event.relatedTarget;
+ if (
+ nextTarget instanceof Node &&
+ event.currentTarget instanceof HTMLElement &&
+ event.currentTarget.contains(nextTarget)
+ ) {
+ return;
+ }
+ this._hoveredToolIndex = null;
+ };
+
+ private executeTool(tool: Tool): void {
+ this._hoveredToolIndex = null;
+ if (this.compact) {
+ this._isExpanded = false;
+ }
+ void tool.triggerExecute();
+ }
+
private initializeTools(): void {
const presetToolsList = this.getPresetTools();
const customTools = this.getCustomTools();
diff --git a/packages/live2d/src/components/Live2dWidget.tsx b/packages/live2d/src/components/Live2dWidget.tsx
index b1c26b4..1d3824d 100644
--- a/packages/live2d/src/components/Live2dWidget.tsx
+++ b/packages/live2d/src/components/Live2dWidget.tsx
@@ -13,10 +13,16 @@ import "@/live2d/components/Live2dTools";
import "@/live2d/components/Live2dChatWindow";
import type { ToggleCanvasEvent } from "@/live2d/events/toggle-canvas";
import {
- WIDGET_DRAWER_DURATION_MS,
WIDGET_DRAWER_VISIBLE_BOTTOM,
+ getWidgetDrawerDuration,
} from "@/live2d/helpers/widgetDrawer";
import { DraggableMixin } from "@/live2d/mixins/draggable";
+import {
+ COMPACT_VIEWPORT_QUERY,
+ getLive2dCanvasSize,
+ getLive2dToolsLayoutClass,
+ isCompactViewport,
+} from "@/live2d/utils/responsive";
const DraggableUnoLitElement = DraggableMixin(UnoLitElement, {
storageKey: "widget",
@@ -37,8 +43,12 @@ export class Live2dWidget extends DraggableUnoLitElement {
@state()
private _isDrawerAnimating = false;
+ @state()
+ private _isCompactViewport = false;
+
private showAnimationFrameId?: number;
private drawerAnimationTimer?: number;
+ private compactViewportQuery?: MediaQueryList;
render(): TemplateResult {
return html`
@@ -52,8 +62,13 @@ export class Live2dWidget extends DraggableUnoLitElement {
renderLive2dTools() {
if (this.config?.isTools) {
+ const layoutClass = getLive2dToolsLayoutClass(
+ this._isCompactViewport,
+ this.config.live2dLocation,
+ );
return html``;
}
}
@@ -65,6 +80,7 @@ export class Live2dWidget extends DraggableUnoLitElement {
return html``;
}
@@ -73,10 +89,9 @@ export class Live2dWidget extends DraggableUnoLitElement {
return;
}
- const positionClass =
- this.config?.live2dLocation === "right"
- ? "right-[50px] left-auto"
- : "left-0";
+ const positionClass = this.getPositionClass();
+ const canvasSize = getLive2dCanvasSize(this._isCompactViewport);
+ const drawerDuration = getWidgetDrawerDuration(this._isCompactViewport);
const visibilityClass = this._isShow
? "pointer-events-auto"
: "pointer-events-none";
@@ -92,15 +107,23 @@ export class Live2dWidget extends DraggableUnoLitElement {
>
@@ -133,6 +156,12 @@ export class Live2dWidget extends DraggableUnoLitElement {
connectedCallback(): void {
super.connectedCallback();
+ this.compactViewportQuery = window.matchMedia(COMPACT_VIEWPORT_QUERY);
+ this._isCompactViewport = isCompactViewport();
+ this.compactViewportQuery.addEventListener(
+ "change",
+ this.handleCompactViewportChange,
+ );
// 应用保存的位置
this.applySavedPosition();
// 页面加载时清除历史消息
@@ -147,6 +176,11 @@ export class Live2dWidget extends DraggableUnoLitElement {
disconnectedCallback(): void {
super.disconnectedCallback();
+ this.compactViewportQuery?.removeEventListener(
+ "change",
+ this.handleCompactViewportChange,
+ );
+ this.compactViewportQuery = undefined;
this.cancelScheduledShow();
this.cancelDrawerAnimation();
window.removeEventListener("load", this.clearChatHistory);
@@ -163,6 +197,21 @@ export class Live2dWidget extends DraggableUnoLitElement {
localStorage.removeItem("historyMessages");
}
+ private readonly handleCompactViewportChange = (
+ event: MediaQueryListEvent,
+ ): void => {
+ this._isCompactViewport = event.matches;
+ };
+
+ private getPositionClass(): string {
+ if (this.config?.live2dLocation === "right") {
+ return this._isCompactViewport
+ ? "right-2 left-auto"
+ : "right-[50px] left-auto";
+ }
+ return this._isCompactViewport ? "left-2" : "left-0";
+ }
+
private scheduleShowAfterMount(): void {
this.cancelScheduledShow();
this.showAnimationFrameId = window.requestAnimationFrame(() => {
@@ -188,7 +237,7 @@ export class Live2dWidget extends DraggableUnoLitElement {
this.drawerAnimationTimer = window.setTimeout(() => {
this.drawerAnimationTimer = undefined;
this._isDrawerAnimating = false;
- }, WIDGET_DRAWER_DURATION_MS);
+ }, getWidgetDrawerDuration(this._isCompactViewport));
}
private cancelDrawerAnimation(): void {
diff --git a/packages/live2d/src/components/__tests__/Live2dToggle.test.ts b/packages/live2d/src/components/__tests__/Live2dToggle.test.ts
new file mode 100644
index 0000000..1cb90fa
--- /dev/null
+++ b/packages/live2d/src/components/__tests__/Live2dToggle.test.ts
@@ -0,0 +1,36 @@
+import type { ToggleCanvasEvent } from "@/live2d/events/toggle-canvas";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Live2dToggle } from "../Live2dToggle";
+import "../Live2dToggle";
+
+describe("Live2dToggle mobile behavior", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: vi.fn().mockReturnValue({ matches: true }),
+ });
+ });
+
+ afterEach(() => {
+ document.body.replaceChildren();
+ vi.restoreAllMocks();
+ });
+
+ it("automatically opens the widget on mobile", async () => {
+ const openStates: boolean[] = [];
+ const onToggle = (event: Event) => {
+ openStates.push((event as ToggleCanvasEvent).detail.isShow);
+ };
+ window.addEventListener("live2d:toggle-canvas", onToggle);
+
+ const toggle = document.createElement("live2d-toggle") as Live2dToggle;
+ document.body.append(toggle);
+ await Promise.resolve();
+ await toggle.updateComplete;
+
+ expect(openStates).toEqual([true]);
+
+ window.removeEventListener("live2d:toggle-canvas", onToggle);
+ });
+});
diff --git a/packages/live2d/src/components/__tests__/Live2dTools.test.ts b/packages/live2d/src/components/__tests__/Live2dTools.test.ts
new file mode 100644
index 0000000..857d341
--- /dev/null
+++ b/packages/live2d/src/components/__tests__/Live2dTools.test.ts
@@ -0,0 +1,124 @@
+import type { Tool } from "@/live2d/live2d/tools/tools";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import "../Live2dTools";
+
+type TestableLive2dTools = HTMLElement & {
+ compact: boolean;
+ _tools: Tool[];
+ _isExpanded: boolean;
+ updateComplete: Promise;
+};
+
+const createTool = (name = "chat") => {
+ const triggerExecute = vi.fn().mockResolvedValue(undefined);
+ const tool = {
+ icon: () => "ph:chat-circle",
+ name: () => name,
+ triggerExecute,
+ } as unknown as Tool;
+ return { tool, triggerExecute };
+};
+
+describe("Live2dTools drawer", () => {
+ afterEach(() => {
+ document.body.replaceChildren();
+ vi.restoreAllMocks();
+ });
+
+ it("expands, scrolls, and collapses after using a tool on mobile", async () => {
+ const { tool, triggerExecute } = createTool();
+ const tools = document.createElement(
+ "live2d-tools",
+ ) as unknown as TestableLive2dTools;
+ tools.compact = true;
+ tools._tools = [tool];
+ document.body.append(tools);
+ await tools.updateComplete;
+
+ const toggle = tools.shadowRoot?.querySelector(
+ "#live2d-tools-toggle",
+ );
+ const toolList =
+ tools.shadowRoot?.querySelector("#live2d-tools");
+ const shell = tools.shadowRoot?.querySelector(
+ "#live2d-tools-shell",
+ );
+ expect(toggle?.getAttribute("aria-expanded")).toBe("false");
+ expect(toolList?.getAttribute("aria-hidden")).toBe("true");
+ expect(toggle?.classList.contains("border-none")).toBe(true);
+ expect(shell?.style.background).toContain("rgba(255, 250, 244, 0.86)");
+
+ toggle?.click();
+ await tools.updateComplete;
+ expect(toggle?.getAttribute("aria-expanded")).toBe("true");
+ expect(toolList?.getAttribute("aria-hidden")).toBe("false");
+ expect(toolList?.classList.contains("overflow-y-auto")).toBe(true);
+ expect(toolList?.style.maxHeight).toBe("min(10rem, calc(42vh - 3rem))");
+
+ tools.shadowRoot
+ ?.querySelector("#live2d-tool-chat")
+ ?.click();
+ await tools.updateComplete;
+ expect(triggerExecute).toHaveBeenCalledOnce();
+ expect(toggle?.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("keeps the desktop toolbar expanded without a drawer toggle", async () => {
+ const { tool } = createTool();
+ const tools = document.createElement(
+ "live2d-tools",
+ ) as unknown as TestableLive2dTools;
+ tools.compact = false;
+ tools._tools = [tool];
+ document.body.append(tools);
+ await tools.updateComplete;
+
+ expect(tools.shadowRoot?.querySelector("#live2d-tools-toggle")).toBeNull();
+ expect(
+ tools.shadowRoot
+ ?.querySelector("#live2d-tools")
+ ?.getAttribute("aria-hidden"),
+ ).toBe("false");
+ expect(
+ tools.shadowRoot?.querySelector("#live2d-tools")?.style
+ .maxHeight,
+ ).toBe("calc(100vh - 4rem)");
+ expect(
+ tools.shadowRoot?.querySelector("#live2d-tools-shell")?.style
+ .background,
+ ).toContain("rgba(255, 250, 244, 0.72)");
+ });
+
+ it("moves one shared hover indicator between tools without scaling buttons", async () => {
+ const first = createTool("chat").tool;
+ const second = createTool("photo").tool;
+ const tools = document.createElement(
+ "live2d-tools",
+ ) as unknown as TestableLive2dTools;
+ tools._tools = [first, second];
+ document.body.append(tools);
+ await tools.updateComplete;
+
+ const indicator = tools.shadowRoot?.querySelector(
+ "#live2d-tools-hover-indicator",
+ );
+ const firstButton =
+ tools.shadowRoot?.querySelector("#live2d-tool-chat");
+ const secondButton =
+ tools.shadowRoot?.querySelector("#live2d-tool-photo");
+
+ firstButton?.dispatchEvent(new MouseEvent("mouseenter"));
+ await tools.updateComplete;
+ expect(indicator?.style.opacity).toBe("1");
+ expect(indicator?.style.transform).toBe("translate3d(0, 0px, 0)");
+ expect(indicator?.style.background).toBe("rgba(255, 255, 255, 0.85)");
+
+ secondButton?.dispatchEvent(new MouseEvent("mouseenter"));
+ await tools.updateComplete;
+ expect(indicator?.style.transform).toBe("translate3d(0, 40px, 0)");
+ expect(secondButton?.classList.contains("hover:scale-105")).toBe(false);
+ expect(secondButton?.classList.contains("hover:color-[#0684bd]")).toBe(
+ true,
+ );
+ });
+});
diff --git a/packages/live2d/src/helpers/widgetDrawer.ts b/packages/live2d/src/helpers/widgetDrawer.ts
index 2a20b0b..7bc7ba9 100644
--- a/packages/live2d/src/helpers/widgetDrawer.ts
+++ b/packages/live2d/src/helpers/widgetDrawer.ts
@@ -1,3 +1,9 @@
export const WIDGET_DRAWER_HIDDEN_BOTTOM = "-500px";
export const WIDGET_DRAWER_VISIBLE_BOTTOM = "0px";
export const WIDGET_DRAWER_DURATION_MS = 4000;
+export const COMPACT_WIDGET_DRAWER_DURATION_MS = 500;
+
+export const getWidgetDrawerDuration = (compactViewport: boolean): number =>
+ compactViewport
+ ? COMPACT_WIDGET_DRAWER_DURATION_MS
+ : WIDGET_DRAWER_DURATION_MS;
diff --git a/packages/live2d/src/live2d/model.ts b/packages/live2d/src/live2d/model.ts
index 95e775e..bde1287 100644
--- a/packages/live2d/src/live2d/model.ts
+++ b/packages/live2d/src/live2d/model.ts
@@ -7,11 +7,16 @@ import { isNotEmptyString } from "@/live2d/utils/isString";
import * as PIXI from "pixi.js";
import "@/live2d/libs/live2d.min.js";
import "@/live2d/libs/live2dcubismcore.min.js";
-import { Live2DModel } from "untitled-pixi-live2d-engine";
-import { Live2dRuntimeController } from "@/live2d/runtime/controller";
-import { SemanticParameterLayer } from "@/live2d/runtime/semantic";
import type { BehaviorFSM } from "@/live2d/runtime/behavior";
+import { Live2dRuntimeController } from "@/live2d/runtime/controller";
import type { EmotionTimeline } from "@/live2d/runtime/emotion";
+import type { SemanticParameterLayer } from "@/live2d/runtime/semantic";
+import {
+ COMPACT_LIVE2D_CANVAS_SIZE,
+ DESKTOP_LIVE2D_CANVAS_SIZE,
+ getLive2dRenderResolution,
+} from "@/live2d/utils/responsive";
+import { Live2DModel } from "untitled-pixi-live2d-engine";
declare global {
interface Window {
@@ -38,7 +43,6 @@ interface EyeTrackingCleanup {
_cleanupEyeTracking?: () => void;
}
-const LIVE2D_CANVAS_SIZE = 300;
const LIVE2D_MODEL_PADDING = 1;
const LIVE2D_BOTTOM_OFFSET = 1;
const MESSAGE_TIMEOUT_MS = 4000;
@@ -67,8 +71,13 @@ class Model {
#hasLoggedConsoleStatus = false;
#loadSequence = 0;
#controller: Live2dRuntimeController;
+ #initialCanvasSize: number;
- private constructor(root: HTMLCanvasElement, config: Live2dConfig) {
+ private constructor(
+ root: HTMLCanvasElement,
+ config: Live2dConfig,
+ canvasSize: number,
+ ) {
const apiPath = config.apiPath;
if (!isNotEmptyString(apiPath)) {
throw new Error("Invalid initWidget argument!");
@@ -77,6 +86,7 @@ class Model {
this.#apiPath = apiPath.endsWith("/") ? apiPath : `${apiPath}/`;
this.#config = config;
this.#live2dRootElement = root;
+ this.#initialCanvasSize = canvasSize;
this.#controller = new Live2dRuntimeController({
behaviorFSM: config.behaviorFSM,
emotionTimeline: config.emotionTimeline,
@@ -90,21 +100,24 @@ class Model {
static async create(
root: HTMLCanvasElement,
config: Live2dConfig,
+ canvasSize = DESKTOP_LIVE2D_CANVAS_SIZE,
): Promise {
- const model = new Model(root, config);
+ const model = new Model(root, config, canvasSize);
await model._loadingModel();
return model;
}
private async initializeApplication(): Promise {
const app = new PIXI.Application();
+ const compactViewport =
+ this.#initialCanvasSize === COMPACT_LIVE2D_CANVAS_SIZE;
await app.init({
canvas: this.#live2dRootElement,
autoStart: true,
- height: LIVE2D_CANVAS_SIZE,
- width: LIVE2D_CANVAS_SIZE,
+ height: this.#initialCanvasSize,
+ width: this.#initialCanvasSize,
autoDensity: true,
- resolution: window.devicePixelRatio || 1,
+ resolution: getLive2dRenderResolution(compactViewport),
backgroundColor: 0x00000000,
backgroundAlpha: 0,
preference: "webgl",
@@ -179,45 +192,56 @@ class Model {
private async replaceModel(nextModel: Live2DModel): Promise {
const app = await this.getApp();
- const bounds = nextModel.getLocalBounds();
- const modelWidth =
- bounds.width || nextModel.internalModel.width || nextModel.width;
+ this.layoutModel(nextModel, app);
+
+ if (this.#currentModel) {
+ this.#controller.destroy(app.ticker);
+ app.stage.removeChild(this.#currentModel);
+ this.#currentModel.destroy();
+ }
+
+ app.stage.removeChildren();
+ app.stage.addChild(nextModel);
+ this.#currentModel = nextModel;
+
+ // Stop any playing motions from the engine so our runtime takes over
+ this.stopEngineMotions(nextModel);
+
+ // Initialize controller with model
+ this.#controller.initialize(nextModel, app.ticker);
+ }
+
+ private layoutModel(model: Live2DModel, app: PIXI.Application): void {
+ const bounds = model.getLocalBounds();
+ const modelWidth = bounds.width || model.internalModel.width || model.width;
const modelHeight =
- bounds.height || nextModel.internalModel.height || nextModel.height;
+ bounds.height || model.internalModel.height || model.height;
const scale = Math.min(
app.screen.width / modelWidth,
app.screen.height / modelHeight,
);
- nextModel.scale.set(scale * LIVE2D_MODEL_PADDING);
- nextModel.pivot.set(bounds.x + bounds.width / 2, bounds.y + bounds.height);
- nextModel.position.set(
+ model.scale.set(scale * LIVE2D_MODEL_PADDING);
+ model.pivot.set(bounds.x + bounds.width / 2, bounds.y + bounds.height);
+ model.position.set(
app.screen.width / 2,
app.screen.height * LIVE2D_BOTTOM_OFFSET,
);
- const modelTopY = this.getSpeechAnchorTopY(nextModel, bounds);
+ const modelTopY = this.getSpeechAnchorTopY(model, bounds);
window.dispatchEvent(
new ModelLayoutEvent({
topY: modelTopY,
canvasHeight: app.screen.height,
}),
);
+ }
+ async resize(canvasSize: number): Promise {
+ const app = await this.getApp();
+ app.renderer.resize(canvasSize, canvasSize);
if (this.#currentModel) {
- this.#controller.destroy(app.ticker);
- app.stage.removeChild(this.#currentModel);
- this.#currentModel.destroy();
+ this.layoutModel(this.#currentModel, app);
}
-
- app.stage.removeChildren();
- app.stage.addChild(nextModel);
- this.#currentModel = nextModel;
-
- // Stop any playing motions from the engine so our runtime takes over
- this.stopEngineMotions(nextModel);
-
- // Initialize controller with model
- this.#controller.initialize(nextModel, app.ticker);
}
private stopEngineMotions(model: Live2DModel): void {
@@ -256,12 +280,16 @@ class Model {
private getHeadTopY(_model: Live2DModel): number | undefined {
// Use semantic layer for hit area lookup when available
- const headIndex = this.#controller.getSemanticLayer().getHitAreaIndex(HEAD_HIT_AREA_PATTERN);
+ const headIndex = this.#controller
+ .getSemanticLayer()
+ .getHitAreaIndex(HEAD_HIT_AREA_PATTERN);
if (headIndex === undefined) {
return;
}
- const headBounds = this.#controller.getSemanticLayer().getDrawableBounds(headIndex);
+ const headBounds = this.#controller
+ .getSemanticLayer()
+ .getDrawableBounds(headIndex);
if (!headBounds) {
return;
}
@@ -330,7 +358,9 @@ class Model {
await this.replaceModel(model);
if (this.#config.consoleShowStatus) {
- const profile = this.#controller.getSemanticLayer().getCapabilityProfile();
+ const profile = this.#controller
+ .getSemanticLayer()
+ .getCapabilityProfile();
const detectedNames = Array.from(profile.detected.keys()).join(", ");
const missingNames = profile.missing.join(", ");
console.log(
@@ -448,7 +478,9 @@ class Model {
}
private setupEyeTrackingEvents(): void {
- const eyeTracking = this.#controller.getProceduralSystem()?.getEyeTrackingModule();
+ const eyeTracking = this.#controller
+ .getProceduralSystem()
+ ?.getEyeTrackingModule();
if (!eyeTracking) return;
const canvas = this.#live2dRootElement;
diff --git a/packages/live2d/src/live2d/runtime.ts b/packages/live2d/src/live2d/runtime.ts
index c7fab24..5d5f56b 100644
--- a/packages/live2d/src/live2d/runtime.ts
+++ b/packages/live2d/src/live2d/runtime.ts
@@ -17,10 +17,6 @@ export class Live2dRuntime {
private rootElement?: Live2dContext;
init(path: string, config: LegacyLive2dConfigInput = {}): void {
- if (window.screen.width < 768) {
- return;
- }
-
const rootElement = this.getOrCreateRoot();
rootElement.config = normalizeLive2dConfig(path, config);
}
diff --git a/packages/live2d/src/utils/__tests__/responsive.test.ts b/packages/live2d/src/utils/__tests__/responsive.test.ts
new file mode 100644
index 0000000..232824c
--- /dev/null
+++ b/packages/live2d/src/utils/__tests__/responsive.test.ts
@@ -0,0 +1,63 @@
+import {
+ COMPACT_WIDGET_DRAWER_DURATION_MS,
+ WIDGET_DRAWER_DURATION_MS,
+ getWidgetDrawerDuration,
+} from "@/live2d/helpers/widgetDrawer";
+import { describe, expect, it } from "vitest";
+import {
+ COMPACT_LIVE2D_CANVAS_SIZE,
+ DESKTOP_LIVE2D_CANVAS_SIZE,
+ getLive2dCanvasSize,
+ getLive2dRenderResolution,
+ getLive2dToolsLayoutClass,
+ isCompactViewport,
+} from "../responsive";
+
+describe("responsive Live2D presentation", () => {
+ it("uses matchMedia when it is available", () => {
+ expect(
+ isCompactViewport({
+ innerWidth: 1200,
+ matchMedia: () => ({ matches: true }),
+ }),
+ ).toBe(true);
+ });
+
+ it("falls back to viewport width", () => {
+ expect(isCompactViewport({ innerWidth: 767 })).toBe(true);
+ expect(isCompactViewport({ innerWidth: 768 })).toBe(false);
+ });
+
+ it("uses a smaller canvas on compact viewports", () => {
+ expect(getLive2dCanvasSize(true)).toBe(COMPACT_LIVE2D_CANVAS_SIZE);
+ expect(getLive2dCanvasSize(false)).toBe(DESKTOP_LIVE2D_CANVAS_SIZE);
+ });
+
+ it("caps high-density mobile rendering without changing desktop DPR", () => {
+ expect(getLive2dRenderResolution(true, 3)).toBe(1.5);
+ expect(getLive2dRenderResolution(true, 1)).toBe(1);
+ expect(getLive2dRenderResolution(false, 3)).toBe(3);
+ expect(getLive2dRenderResolution(true, Number.NaN)).toBe(1);
+ });
+
+ it("uses a shorter drawer animation on compact viewports", () => {
+ expect(getWidgetDrawerDuration(true)).toBe(
+ COMPACT_WIDGET_DRAWER_DURATION_MS,
+ );
+ expect(getWidgetDrawerDuration(false)).toBe(WIDGET_DRAWER_DURATION_MS);
+ });
+
+ it("shows desktop tools only while the widget is hovered or focused", () => {
+ const layoutClass = getLive2dToolsLayoutClass(false, "left");
+ expect(layoutClass).toContain("opacity-0");
+ expect(layoutClass).toContain("group-hover:opacity-100");
+ expect(layoutClass).toContain("group-focus-within:opacity-100");
+ });
+
+ it("keeps the mobile drawer trigger permanently visible", () => {
+ const layoutClass = getLive2dToolsLayoutClass(true, "right");
+ expect(layoutClass).toContain("opacity-100");
+ expect(layoutClass).not.toContain("opacity-0");
+ expect(layoutClass).not.toContain("group-hover:opacity-100");
+ });
+});
diff --git a/packages/live2d/src/utils/responsive.ts b/packages/live2d/src/utils/responsive.ts
new file mode 100644
index 0000000..2bba593
--- /dev/null
+++ b/packages/live2d/src/utils/responsive.ts
@@ -0,0 +1,47 @@
+export const COMPACT_VIEWPORT_QUERY = "(max-width: 767px)";
+export const DESKTOP_LIVE2D_CANVAS_SIZE = 300;
+export const COMPACT_LIVE2D_CANVAS_SIZE = 220;
+export const COMPACT_MAX_DEVICE_PIXEL_RATIO = 1.5;
+
+interface ViewportEnvironment {
+ innerWidth: number;
+ devicePixelRatio?: number;
+ matchMedia?: (query: string) => Pick;
+}
+
+export const isCompactViewport = (
+ viewport: ViewportEnvironment = window,
+): boolean => {
+ if (viewport.matchMedia) {
+ return viewport.matchMedia(COMPACT_VIEWPORT_QUERY).matches;
+ }
+ return viewport.innerWidth < 768;
+};
+
+export const getLive2dCanvasSize = (compactViewport: boolean): number =>
+ compactViewport ? COMPACT_LIVE2D_CANVAS_SIZE : DESKTOP_LIVE2D_CANVAS_SIZE;
+
+export const getLive2dToolsLayoutClass = (
+ compactViewport: boolean,
+ location?: string,
+): string => {
+ const edgeClass = location === "right" ? "mr-2" : "ml-2";
+ const bottomClass = compactViewport ? "mb-3" : "mb-4";
+ const visibilityClass = compactViewport
+ ? "opacity-100"
+ : "opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100";
+ return `${bottomClass} ${edgeClass} z-2 ${visibilityClass}`;
+};
+
+export const getLive2dRenderResolution = (
+ compactViewport: boolean,
+ devicePixelRatio = window.devicePixelRatio || 1,
+): number => {
+ const resolution =
+ Number.isFinite(devicePixelRatio) && devicePixelRatio > 0
+ ? devicePixelRatio
+ : 1;
+ return compactViewport
+ ? Math.min(resolution, COMPACT_MAX_DEVICE_PIXEL_RATIO)
+ : resolution;
+};