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`
- ${this._tools.map(this.renderTool)} +
+ ${this.renderHoverIndicator()} + ${this._tools.map((tool, index) => + this.renderTool(tool, isExpanded, index), + )} +
+ ${this.renderDrawerToggle()}
`; } - 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 { >
- ${this.renderLive2dTips()} - +
+ ${this.renderLive2dTips()} + +
${this.renderLive2dTools()}
@@ -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; +};