@vnejs/plugins.views.scenario.qte 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ import "@vnejs/contracts.scenario.qte";
2
+ import "@vnejs/contracts.views.scenario.qte";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ import "@vnejs/contracts.scenario.qte";
2
+ import "@vnejs/contracts.views.scenario.qte";
3
+ import { regPlugin } from "@vnejs/shared";
4
+ import { PARAMS, PLUGIN_NAME, SUBSCRIBE_EVENTS } from "@vnejs/contracts.views.scenario.qte";
5
+ import { QteController } from "./modules/controller.js";
6
+ import { QteView } from "./modules/view.js";
7
+ regPlugin(PLUGIN_NAME, { events: SUBSCRIBE_EVENTS, params: PARAMS }, [QteController, QteView]);
@@ -0,0 +1,31 @@
1
+ import { ModuleController } from "@vnejs/module.components";
2
+ import type { ViewActionPayload } from "@vnejs/module.components";
3
+ import type { QtePluginConstants, QtePluginEvents, QtePluginParams, QtePluginSettings } from "../types.js";
4
+ import type { QtePluginControllerState, QteVisibilityPayload } from "../utils/qte.js";
5
+ export declare class QteController extends ModuleController<QtePluginEvents, QtePluginConstants, QtePluginSettings, QtePluginParams, QtePluginControllerState> {
6
+ name: string;
7
+ updateEvent: "vne:qte_view:update";
8
+ controls: {
9
+ abstract_accept: () => undefined;
10
+ abstract_interact: () => undefined;
11
+ };
12
+ controlsIndex: number;
13
+ activeStartedAt: number | null;
14
+ isResolved: boolean;
15
+ resultTimeout: ReturnType<typeof setTimeout> | null;
16
+ phaseTimeout: ReturnType<typeof setTimeout> | null;
17
+ failTimeout: ReturnType<typeof setTimeout> | null;
18
+ subscribe: () => void;
19
+ onQteOpened: (payload?: QteVisibilityPayload) => Promise<unknown[]> | undefined;
20
+ onQteClosed: (payload?: QteVisibilityPayload) => Promise<unknown[]> | undefined;
21
+ beforeShow: ({ id, item }?: ViewActionPayload) => Promise<void>;
22
+ schedulePhases: () => void;
23
+ startActive: () => void;
24
+ scheduleFailTimeout: () => void;
25
+ afterHide: () => void;
26
+ onAction: () => void;
27
+ resolveRound: (success: boolean) => void;
28
+ clearFailTimeout: () => void;
29
+ clearTimers: () => void;
30
+ getDefaultState: () => QtePluginControllerState;
31
+ }
@@ -0,0 +1,116 @@
1
+ import { ModuleController } from "@vnejs/module.components";
2
+ import { getFailAtProgress, getProgressFromElapsed, isProgressInsideZone } from "../utils/qte.js";
3
+ export class QteController extends ModuleController {
4
+ name = "qte_view.controller";
5
+ updateEvent = this.EVENTS.QTE_VIEW.UPDATE;
6
+ controls = {
7
+ [this.CONST.CONTROLS.BUTTONS.ACCEPT]: () => void this.onAction(),
8
+ [this.CONST.CONTROLS.BUTTONS.INTERACT]: () => void this.onAction(),
9
+ };
10
+ controlsIndex = this.PARAMS.QTE_VIEW.ZINDEX;
11
+ activeStartedAt = null;
12
+ isResolved = false;
13
+ resultTimeout = null;
14
+ phaseTimeout = null;
15
+ failTimeout = null;
16
+ subscribe = () => {
17
+ this.on(this.EVENTS.QTE_VIEW.SHOW, this.onShow);
18
+ this.on(this.EVENTS.QTE_VIEW.HIDE, this.onHide);
19
+ this.on(this.EVENTS.QTE_VIEW.ACTION, this.onAction);
20
+ this.on(this.EVENTS.QTE.OPENED, this.onQteOpened);
21
+ this.on(this.EVENTS.QTE.CLOSED, this.onQteClosed);
22
+ };
23
+ onQteOpened = (payload = {}) => this.emit(this.EVENTS.QTE_VIEW.SHOW, payload);
24
+ onQteClosed = (payload = {}) => this.emit(this.EVENTS.QTE_VIEW.HIDE, payload);
25
+ beforeShow = async ({ id = "", item = null } = {}) => {
26
+ this.clearTimers();
27
+ this.isResolved = false;
28
+ this.activeStartedAt = null;
29
+ this.updateState({
30
+ id: String(id),
31
+ item: item ?? null,
32
+ phase: "initial",
33
+ result: "idle",
34
+ });
35
+ await this.emit(this.EVENTS.QTE_VIEW.SHOW_BEFORE);
36
+ this.schedulePhases();
37
+ };
38
+ schedulePhases = () => {
39
+ const { INITIAL, TRANSITION } = this.PARAMS.QTE_VIEW;
40
+ const introDuration = Math.max(TRANSITION - INITIAL, 0);
41
+ this.phaseTimeout = setTimeout(() => {
42
+ if (this.isResolved)
43
+ return;
44
+ this.updateStateAndViewFast({ phase: "intro" });
45
+ this.phaseTimeout = setTimeout(() => {
46
+ if (this.isResolved)
47
+ return;
48
+ this.startActive();
49
+ }, introDuration);
50
+ }, INITIAL);
51
+ };
52
+ startActive = () => {
53
+ if (!this.state.item)
54
+ return;
55
+ this.activeStartedAt = performance.now();
56
+ this.updateStateAndViewFast({ phase: "active" });
57
+ this.scheduleFailTimeout();
58
+ };
59
+ scheduleFailTimeout = () => {
60
+ if (!this.state.item)
61
+ return;
62
+ const { duration, start, size } = this.state.item;
63
+ const failAt = getFailAtProgress(start, size);
64
+ const failMs = (failAt / 100) * Math.max(duration, 1);
65
+ this.failTimeout = setTimeout(() => this.resolveRound(false), failMs);
66
+ };
67
+ afterHide = () => {
68
+ this.clearTimers();
69
+ this.setDefaultState();
70
+ };
71
+ onAction = () => {
72
+ if (this.state.phase !== "active" || this.isResolved || !this.state.item || this.activeStartedAt === null)
73
+ return;
74
+ const { start, size, duration } = this.state.item;
75
+ const progress = getProgressFromElapsed(performance.now() - this.activeStartedAt, duration);
76
+ const success = isProgressInsideZone(progress, start, size);
77
+ this.resolveRound(success);
78
+ };
79
+ resolveRound = (success) => {
80
+ if (this.isResolved)
81
+ return;
82
+ this.isResolved = true;
83
+ this.clearFailTimeout();
84
+ this.updateStateAndViewFast({
85
+ phase: "resolved",
86
+ result: success ? "success" : "fail",
87
+ });
88
+ this.resultTimeout = setTimeout(() => {
89
+ this.updateStateAndViewFast({ phase: "outro" });
90
+ void this.emit(this.EVENTS.QTE.RESULT, { success });
91
+ }, this.PARAMS.QTE_VIEW.RESULT_DELAY);
92
+ };
93
+ clearFailTimeout = () => {
94
+ if (this.failTimeout)
95
+ clearTimeout(this.failTimeout);
96
+ this.failTimeout = null;
97
+ };
98
+ clearTimers = () => {
99
+ this.clearFailTimeout();
100
+ if (this.phaseTimeout)
101
+ clearTimeout(this.phaseTimeout);
102
+ if (this.resultTimeout)
103
+ clearTimeout(this.resultTimeout);
104
+ this.phaseTimeout = null;
105
+ this.resultTimeout = null;
106
+ this.activeStartedAt = null;
107
+ };
108
+ getDefaultState = () => ({
109
+ isShow: false,
110
+ isForce: false,
111
+ id: "",
112
+ item: null,
113
+ phase: "idle",
114
+ result: "idle",
115
+ });
116
+ }
@@ -0,0 +1,11 @@
1
+ import { ModuleView } from "@vnejs/module.components";
2
+ import type { QtePluginConstants, QtePluginEvents, QtePluginParams, QtePluginSettings } from "../types.js";
3
+ import type { QtePluginControllerState } from "../utils/qte.js";
4
+ export declare class QteView extends ModuleView<QtePluginEvents, QtePluginConstants, QtePluginSettings, QtePluginParams, QtePluginControllerState> {
5
+ name: string;
6
+ locLabel: string;
7
+ animationTime: number;
8
+ updateEvent: "vne:qte_view:update";
9
+ renderFunc: import("@vnejs/module.components").ViewRenderFunc<QtePluginControllerState>;
10
+ updateHandler: (state?: QtePluginControllerState | undefined) => Promise<void>;
11
+ }
@@ -0,0 +1,10 @@
1
+ import { ModuleView } from "@vnejs/module.components";
2
+ import { render } from "../view/index.js";
3
+ export class QteView extends ModuleView {
4
+ name = "qte_view.view";
5
+ locLabel = this.PARAMS.QTE_VIEW.LOC_LABEL;
6
+ animationTime = this.PARAMS.QTE_VIEW.TRANSITION;
7
+ updateEvent = this.EVENTS.QTE_VIEW.UPDATE;
8
+ renderFunc = render;
9
+ updateHandler = this.onUpdateStoreComponent;
10
+ }
@@ -0,0 +1,10 @@
1
+ import type { ModuleComponentsConstants, ModuleComponentsEvents, ModuleComponentsParams, ModuleComponentsSettings } from "@vnejs/module.components";
2
+ import type { PluginName as ControlsPluginName, Constants as ControlsConstants } from "@vnejs/contracts.controls";
3
+ import type { PluginName as LogsPluginName, SubscribeEvents as LogsSubscribeEvents } from "@vnejs/contracts.core.logs";
4
+ import type { PluginName as VendorsPluginName, SubscribeEvents as VendorsSubscribeEvents } from "@vnejs/contracts.core.vendors";
5
+ import type { PluginName as QtePluginName, SubscribeEvents as QteSubscribeEvents, Params as QteParams } from "@vnejs/contracts.scenario.qte";
6
+ import type { PluginName, SubscribeEvents, Params } from "@vnejs/contracts.views.scenario.qte";
7
+ export type QtePluginEvents = ModuleComponentsEvents & Record<QtePluginName, QteSubscribeEvents> & Record<PluginName, SubscribeEvents> & Record<VendorsPluginName, VendorsSubscribeEvents> & Record<LogsPluginName, LogsSubscribeEvents>;
8
+ export type QtePluginConstants = ModuleComponentsConstants & Record<ControlsPluginName, ControlsConstants>;
9
+ export type QtePluginSettings = ModuleComponentsSettings;
10
+ export type QtePluginParams = ModuleComponentsParams & Record<QtePluginName, QteParams> & Record<PluginName, Params>;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { ModuleControllerState } from "@vnejs/module.components";
2
+ import type { QteItem } from "@vnejs/contracts.views.scenario.qte";
3
+ export type QtePhase = "idle" | "initial" | "intro" | "active" | "resolved" | "outro";
4
+ export type QteResult = "idle" | "success" | "fail";
5
+ export type QtePluginControllerState = ModuleControllerState & {
6
+ id: string;
7
+ item: QteItem | null;
8
+ phase: QtePhase;
9
+ result: QteResult;
10
+ locs?: Record<string, string>;
11
+ };
12
+ export type { QteItem, QteVisibilityPayload } from "@vnejs/contracts.views.scenario.qte";
13
+ export declare const percentToDegrees: (percent: number) => number;
14
+ export declare const getProgressFromElapsed: (elapsedMs: number, duration: number) => number;
15
+ export declare const isProgressInsideZone: (progress: number, start: number, size: number) => boolean;
16
+ export declare const getFailAtProgress: (start: number, size: number) => number;
@@ -0,0 +1,11 @@
1
+ export const percentToDegrees = (percent) => (percent / 100) * 360;
2
+ export const getProgressFromElapsed = (elapsedMs, duration) => {
3
+ if (duration <= 0)
4
+ return 100;
5
+ return Math.min(Math.max((elapsedMs / duration) * 100, 0), 100);
6
+ };
7
+ export const isProgressInsideZone = (progress, start, size) => {
8
+ const zoneEnd = Math.min(start + size, 100);
9
+ return progress >= start && progress <= zoneEnd;
10
+ };
11
+ export const getFailAtProgress = (start, size) => Math.min(start + size, 100);
@@ -0,0 +1,3 @@
1
+ import type { ViewRenderFunc } from "@vnejs/module.components";
2
+ import type { QtePluginControllerState } from "../utils/qte.js";
3
+ export declare const render: ViewRenderFunc<QtePluginControllerState>;
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { PositionBox, Screen, createRenderFunc, useCallback, useIsForceHook, useMemo, useStoreState } from "@vnejs/uis.react";
3
+ import { percentToDegrees } from "../utils/qte.js";
4
+ import { b } from "./index.styles.js";
5
+ const CIRCLE_SIZE = 1200;
6
+ const CENTER = CIRCLE_SIZE / 2;
7
+ const RADIUS = 480;
8
+ const polarToCartesian = (angle, radius) => {
9
+ const radians = ((angle - 90) * Math.PI) / 180;
10
+ return { x: CENTER + radius * Math.cos(radians), y: CENTER + radius * Math.sin(radians) };
11
+ };
12
+ const describeArc = (sectorStart, sectorWidth) => {
13
+ const start = polarToCartesian(sectorStart + sectorWidth, RADIUS);
14
+ const end = polarToCartesian(sectorStart, RADIUS);
15
+ const largeArcFlag = sectorWidth <= 180 ? "0" : "1";
16
+ return ["M", start.x, start.y, "A", RADIUS, RADIUS, 0, largeArcFlag, 0, end.x, end.y].join(" ");
17
+ };
18
+ const Qte = ({ store, onMount, emit, EVENTS, PARAMS }) => {
19
+ const { isShow = false, isForce = false, phase = "", result = "", item = null, locs = {}, } = useStoreState(store, onMount);
20
+ const isRealForce = useIsForceHook(isForce);
21
+ const propsView = PARAMS.QTE_VIEW.VIEW_PROPS;
22
+ const transition = isRealForce ? 0 : PARAMS.QTE_VIEW.TRANSITION;
23
+ const introTransition = isRealForce ? 0 : Math.max(PARAMS.QTE_VIEW.TRANSITION - PARAMS.QTE_VIEW.INITIAL, 0);
24
+ const duration = item?.duration ?? 0;
25
+ const start = item?.start ?? 0;
26
+ const size = item?.size ?? 0;
27
+ const sectorStart = percentToDegrees(start);
28
+ const sectorWidth = percentToDegrees(size);
29
+ const sectorPath = useMemo(() => describeArc(sectorStart, sectorWidth), [sectorStart, sectorWidth]);
30
+ const isPointerSpinning = phase === "active" || phase === "resolved" || phase === "outro";
31
+ const isPointerPaused = phase === "resolved" || phase === "outro";
32
+ const onDialClick = useCallback(() => void emit(EVENTS.QTE_VIEW.ACTION), [emit, EVENTS.QTE_VIEW.ACTION]);
33
+ const propsScreen = useMemo(() => ({ ...propsView.screen, isDisableAutoread: true, isIgnoreOnScreenshot: true, isShow, isForce: isRealForce, transition }), [isShow, isRealForce, propsView.screen, transition]);
34
+ return (_jsx(Screen, { ...propsScreen, children: _jsx(PositionBox, { ...propsView.position, children: _jsx("div", { className: b("shell", { phase }), style: { ["--qte-intro-transition"]: `${introTransition}ms` }, children: _jsxs("div", { className: b("arena"), children: [_jsxs("svg", { className: b("dial"), viewBox: `0 0 ${CIRCLE_SIZE} ${CIRCLE_SIZE}`, onClick: onDialClick, children: [_jsx("circle", { className: b("trackShadow"), cx: CENTER, cy: CENTER, r: RADIUS }), _jsx("circle", { className: b("track"), cx: CENTER, cy: CENTER, r: RADIUS }), _jsx("path", { className: b("sector"), d: sectorPath })] }), _jsx("div", { className: b("pointerSpin", { spinning: isPointerSpinning, paused: isPointerPaused }), style: { ["--qte-duration"]: `${duration}ms` }, children: _jsx("div", { className: b("pointer") }) }), _jsx("div", { className: b("status"), "aria-live": "polite", children: locs[result] ?? "" })] }) }) }) }));
35
+ };
36
+ export const render = createRenderFunc(Qte);
@@ -0,0 +1 @@
1
+ export declare const b: import("@bem-react/classname").ClassNameFormatter;
@@ -0,0 +1,116 @@
1
+ import { cn, getVneLength, injectStyles, sel } from "@vnejs/uis.utils";
2
+ export const b = cn("Qte");
3
+ const CSS = `
4
+ @keyframes qte-pointer-spin {
5
+ from {
6
+ transform: rotate(0deg);
7
+ }
8
+
9
+ to {
10
+ transform: rotate(360deg);
11
+ }
12
+ }
13
+
14
+ ${sel(b("shell"))} {
15
+ transform-origin: center;
16
+ transition: opacity var(--qte-intro-transition, 400ms), transform var(--qte-intro-transition, 400ms);
17
+ opacity: 0;
18
+ transform: scale(0.25);
19
+ }
20
+
21
+ ${sel(b("shell", { phase: "intro" }))},
22
+ ${sel(b("shell", { phase: "active" }))},
23
+ ${sel(b("shell", { phase: "resolved" }))} {
24
+ opacity: 1;
25
+ transform: scale(1);
26
+ }
27
+
28
+ ${sel(b("shell", { phase: "initial" }))},
29
+ ${sel(b("shell", { phase: "intro" }))},
30
+ ${sel(b("shell", { phase: "resolved" }))},
31
+ ${sel(b("shell", { phase: "outro" }))} {
32
+ pointer-events: none;
33
+ }
34
+
35
+ ${sel(b("shell", { phase: "outro" }))} {
36
+ opacity: 0;
37
+ transform: scale(1.75);
38
+ }
39
+
40
+ ${sel(b("arena"))} {
41
+ position: relative;
42
+ display: grid;
43
+ place-items: center;
44
+ width: ${getVneLength(1200)};
45
+ height: ${getVneLength(1200)};
46
+ }
47
+
48
+ ${sel(b("dial"))} {
49
+ width: 100%;
50
+ height: 100%;
51
+ overflow: visible;
52
+ cursor: pointer;
53
+ }
54
+
55
+ ${sel(b("trackShadow"))} {
56
+ fill: none;
57
+ stroke: rgba(0, 0, 0, 0.42);
58
+ stroke-width: 72;
59
+ }
60
+
61
+ ${sel(b("track"))} {
62
+ fill: rgba(15, 23, 42, 0.42);
63
+ stroke: rgba(226, 232, 240, 0.16);
64
+ stroke-width: 36;
65
+ }
66
+
67
+ ${sel(b("sector"))} {
68
+ fill: none;
69
+ stroke: rgba(255, 255, 255, 0.9);
70
+ stroke-linecap: round;
71
+ stroke-width: 36;
72
+ }
73
+
74
+ ${sel(b("pointerSpin"))} {
75
+ position: absolute;
76
+ inset: 0;
77
+ pointer-events: none;
78
+ transform-origin: center center;
79
+ }
80
+
81
+ ${sel(b("pointerSpin", { spinning: true }))} {
82
+ animation-name: qte-pointer-spin;
83
+ animation-duration: var(--qte-duration, 1800ms);
84
+ animation-timing-function: linear;
85
+ animation-fill-mode: forwards;
86
+ }
87
+
88
+ ${sel(b("pointerSpin", { paused: true }))} {
89
+ animation-play-state: paused;
90
+ }
91
+
92
+ ${sel(b("pointer"))} {
93
+ position: absolute;
94
+ left: 50%;
95
+ top: 50%;
96
+ width: ${getVneLength(96)};
97
+ height: ${getVneLength(96)};
98
+ border-radius: 50%;
99
+ background: #ffffff;
100
+ filter: drop-shadow(0 0 ${getVneLength(12)} rgba(0, 0, 0, 0.3));
101
+ transform: translate(-50%, -50%) translateY(${getVneLength(-480)});
102
+ }
103
+
104
+ ${sel(b("status"))} {
105
+ position: absolute;
106
+ display: grid;
107
+ place-items: center;
108
+ width: ${getVneLength(528)};
109
+ text-align: center;
110
+ pointer-events: none;
111
+ color: #ffffff;
112
+ font-size: ${getVneLength(72)};
113
+ font-weight: 900;
114
+ }
115
+ `;
116
+ injectStyles(CSS);
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@vnejs/plugins.views.scenario.qte",
3
+ "version": "0.1.1",
4
+ "description": "",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "require": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "tsconfig.json"
19
+ ],
20
+ "scripts": {
21
+ "test": "npx @vnejs/monorepo test",
22
+ "build": "npx @vnejs/monorepo package",
23
+ "publish:major:plugin": "npm run publish:major",
24
+ "publish:minor:plugin": "npm run publish:minor",
25
+ "publish:patch:plugin": "npm run publish:patch",
26
+ "publish:major": "npx @vnejs/monorepo publish major --access public",
27
+ "publish:minor": "npx @vnejs/monorepo publish minor --access public",
28
+ "publish:patch": "npx @vnejs/monorepo publish patch --access public"
29
+ },
30
+ "author": "",
31
+ "license": "ISC",
32
+ "dependencies": {
33
+ "@vnejs/contracts.scenario.qte": "~0.1.0",
34
+ "@vnejs/contracts.views.scenario.qte": "~0.1.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@vnejs/contracts.controls": "~0.1.0",
38
+ "@vnejs/module.components": "~0.1.0",
39
+ "@vnejs/shared": "~0.1.0",
40
+ "@vnejs/uis.react": "~0.1.0",
41
+ "@vnejs/uis.utils": "~0.1.0"
42
+ },
43
+ "devDependencies": {
44
+ "@vnejs/configs.ts-common": "~0.1.0",
45
+ "@vnejs/configs.vitest": "~0.1.0",
46
+ "@vnejs/test-utils": "~0.1.0",
47
+ "@vnejs/contracts.controls": "~0.1.0",
48
+ "@vnejs/contracts.scenario.qte": "~0.1.0",
49
+ "@vnejs/contracts.views.scenario.qte": "~0.1.0",
50
+ "@vnejs/module.components": "~0.1.0",
51
+ "@vnejs/shared": "~0.1.0",
52
+ "@vnejs/sources-set": "~0.1.0",
53
+ "@vnejs/uis.react": "~0.1.0",
54
+ "@vnejs/uis.utils": "~0.1.0"
55
+ }
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ import "@vnejs/contracts.scenario.qte";
2
+ import "@vnejs/contracts.views.scenario.qte";
3
+
4
+ import { regPlugin } from "@vnejs/shared";
5
+ import { PARAMS, PLUGIN_NAME, SUBSCRIBE_EVENTS } from "@vnejs/contracts.views.scenario.qte";
6
+
7
+ import { QteController } from "./modules/controller.js";
8
+ import { QteView } from "./modules/view.js";
9
+
10
+ regPlugin(PLUGIN_NAME, { events: SUBSCRIBE_EVENTS, params: PARAMS }, [QteController, QteView]);
@@ -0,0 +1,140 @@
1
+ import { ModuleController } from "@vnejs/module.components";
2
+
3
+ import type { ViewActionPayload } from "@vnejs/module.components";
4
+
5
+ import type { QtePluginConstants, QtePluginEvents, QtePluginParams, QtePluginSettings } from "../types.js";
6
+ import type { QtePluginControllerState, QteVisibilityPayload } from "../utils/qte.js";
7
+ import { getFailAtProgress, getProgressFromElapsed, isProgressInsideZone } from "../utils/qte.js";
8
+
9
+ export class QteController extends ModuleController<QtePluginEvents, QtePluginConstants, QtePluginSettings, QtePluginParams, QtePluginControllerState> {
10
+ name = "qte_view.controller";
11
+
12
+ updateEvent = this.EVENTS.QTE_VIEW.UPDATE;
13
+
14
+ controls = {
15
+ [this.CONST.CONTROLS.BUTTONS.ACCEPT]: () => void this.onAction(),
16
+ [this.CONST.CONTROLS.BUTTONS.INTERACT]: () => void this.onAction(),
17
+ };
18
+ controlsIndex = this.PARAMS.QTE_VIEW.ZINDEX;
19
+
20
+ activeStartedAt: number | null = null;
21
+ isResolved = false;
22
+ resultTimeout: ReturnType<typeof setTimeout> | null = null;
23
+ phaseTimeout: ReturnType<typeof setTimeout> | null = null;
24
+ failTimeout: ReturnType<typeof setTimeout> | null = null;
25
+
26
+ subscribe = () => {
27
+ this.on(this.EVENTS.QTE_VIEW.SHOW, this.onShow);
28
+ this.on(this.EVENTS.QTE_VIEW.HIDE, this.onHide);
29
+ this.on(this.EVENTS.QTE_VIEW.ACTION, this.onAction);
30
+
31
+ this.on(this.EVENTS.QTE.OPENED, this.onQteOpened);
32
+ this.on(this.EVENTS.QTE.CLOSED, this.onQteClosed);
33
+ };
34
+
35
+ onQteOpened = (payload: QteVisibilityPayload = {}) => this.emit(this.EVENTS.QTE_VIEW.SHOW, payload);
36
+ onQteClosed = (payload: QteVisibilityPayload = {}) => this.emit(this.EVENTS.QTE_VIEW.HIDE, payload);
37
+
38
+ beforeShow = async ({ id = "", item = null }: ViewActionPayload = {}) => {
39
+ this.clearTimers();
40
+ this.isResolved = false;
41
+ this.activeStartedAt = null;
42
+ this.updateState({
43
+ id: String(id),
44
+ item: (item as QtePluginControllerState["item"]) ?? null,
45
+ phase: "initial",
46
+ result: "idle",
47
+ });
48
+ await this.emit(this.EVENTS.QTE_VIEW.SHOW_BEFORE);
49
+ this.schedulePhases();
50
+ };
51
+
52
+ schedulePhases = () => {
53
+ const { INITIAL, TRANSITION } = this.PARAMS.QTE_VIEW;
54
+ const introDuration = Math.max(TRANSITION - INITIAL, 0);
55
+
56
+ this.phaseTimeout = setTimeout(() => {
57
+ if (this.isResolved) return;
58
+
59
+ this.updateStateAndViewFast({ phase: "intro" });
60
+
61
+ this.phaseTimeout = setTimeout(() => {
62
+ if (this.isResolved) return;
63
+
64
+ this.startActive();
65
+ }, introDuration);
66
+ }, INITIAL);
67
+ };
68
+
69
+ startActive = () => {
70
+ if (!this.state.item) return;
71
+
72
+ this.activeStartedAt = performance.now();
73
+ this.updateStateAndViewFast({ phase: "active" });
74
+ this.scheduleFailTimeout();
75
+ };
76
+
77
+ scheduleFailTimeout = () => {
78
+ if (!this.state.item) return;
79
+
80
+ const { duration, start, size } = this.state.item;
81
+ const failAt = getFailAtProgress(start, size);
82
+ const failMs = (failAt / 100) * Math.max(duration, 1);
83
+
84
+ this.failTimeout = setTimeout(() => this.resolveRound(false), failMs);
85
+ };
86
+
87
+ afterHide = () => {
88
+ this.clearTimers();
89
+ this.setDefaultState();
90
+ };
91
+
92
+ onAction = () => {
93
+ if (this.state.phase !== "active" || this.isResolved || !this.state.item || this.activeStartedAt === null) return;
94
+
95
+ const { start, size, duration } = this.state.item;
96
+ const progress = getProgressFromElapsed(performance.now() - this.activeStartedAt, duration);
97
+ const success = isProgressInsideZone(progress, start, size);
98
+
99
+ this.resolveRound(success);
100
+ };
101
+
102
+ resolveRound = (success: boolean) => {
103
+ if (this.isResolved) return;
104
+
105
+ this.isResolved = true;
106
+ this.clearFailTimeout();
107
+ this.updateStateAndViewFast({
108
+ phase: "resolved",
109
+ result: success ? "success" : "fail",
110
+ });
111
+
112
+ this.resultTimeout = setTimeout(() => {
113
+ this.updateStateAndViewFast({ phase: "outro" });
114
+ void this.emit(this.EVENTS.QTE.RESULT, { success });
115
+ }, this.PARAMS.QTE_VIEW.RESULT_DELAY);
116
+ };
117
+
118
+ clearFailTimeout = () => {
119
+ if (this.failTimeout) clearTimeout(this.failTimeout);
120
+ this.failTimeout = null;
121
+ };
122
+
123
+ clearTimers = () => {
124
+ this.clearFailTimeout();
125
+ if (this.phaseTimeout) clearTimeout(this.phaseTimeout);
126
+ if (this.resultTimeout) clearTimeout(this.resultTimeout);
127
+ this.phaseTimeout = null;
128
+ this.resultTimeout = null;
129
+ this.activeStartedAt = null;
130
+ };
131
+
132
+ getDefaultState = (): QtePluginControllerState => ({
133
+ isShow: false,
134
+ isForce: false,
135
+ id: "",
136
+ item: null,
137
+ phase: "idle",
138
+ result: "idle",
139
+ });
140
+ }
@@ -0,0 +1,16 @@
1
+ import { ModuleView } from "@vnejs/module.components";
2
+
3
+ import { render } from "../view/index.js";
4
+ import type { QtePluginConstants, QtePluginEvents, QtePluginParams, QtePluginSettings } from "../types.js";
5
+ import type { QtePluginControllerState } from "../utils/qte.js";
6
+
7
+ export class QteView extends ModuleView<QtePluginEvents, QtePluginConstants, QtePluginSettings, QtePluginParams, QtePluginControllerState> {
8
+ name = "qte_view.view";
9
+
10
+ locLabel = this.PARAMS.QTE_VIEW.LOC_LABEL;
11
+ animationTime = this.PARAMS.QTE_VIEW.TRANSITION;
12
+ updateEvent = this.EVENTS.QTE_VIEW.UPDATE;
13
+
14
+ renderFunc = render;
15
+ updateHandler = this.onUpdateStoreComponent;
16
+ }
@@ -0,0 +1,87 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { spyEvent } from "@vnejs/test-utils";
4
+
5
+ import { QteController } from "../modules/controller.js";
6
+ import { createQteViewTestModule, registerQteViewPluginVne, QTE_EVENTS, SUBSCRIBE_EVENTS } from "./setup.js";
7
+
8
+ describe("QteController", () => {
9
+ beforeEach(() => {
10
+ registerQteViewPluginVne();
11
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
12
+ });
13
+
14
+ it("OPENED bridge emits SHOW with payload", async () => {
15
+ const { observer } = createQteViewTestModule(QteController);
16
+ const show = spyEvent(observer, SUBSCRIBE_EVENTS.SHOW);
17
+ const payload = { id: "door_lock", item: { start: 70, size: 20, duration: 1500 } };
18
+
19
+ await observer.emit(QTE_EVENTS.OPENED, payload);
20
+
21
+ expect(show).toHaveBeenCalledExactlyOnceWith(payload);
22
+ });
23
+
24
+ it("SHOW starts in initial, then intro, then active", async () => {
25
+ const { module, observer } = createQteViewTestModule(QteController);
26
+ const item = { start: 70, size: 20, duration: 1500 };
27
+ const { INITIAL, TRANSITION } = (module as QteController).PARAMS.QTE_VIEW;
28
+
29
+ await observer.emit(SUBSCRIBE_EVENTS.SHOW, { id: "door_lock", item });
30
+
31
+ expect((module as QteController).state.id).toBe("door_lock");
32
+ expect((module as QteController).state.item).toEqual(item);
33
+ expect((module as QteController).state.phase).toBe("initial");
34
+
35
+ await vi.advanceTimersByTimeAsync(INITIAL);
36
+ expect((module as QteController).state.phase).toBe("intro");
37
+
38
+ await vi.advanceTimersByTimeAsync(TRANSITION - INITIAL);
39
+ expect((module as QteController).state.phase).toBe("active");
40
+ expect((module as QteController).activeStartedAt).not.toBeNull();
41
+ });
42
+
43
+ it("ACTION resolves success inside zone by elapsed time", async () => {
44
+ const { module, observer } = createQteViewTestModule(QteController);
45
+ const result = spyEvent(observer, QTE_EVENTS.RESULT);
46
+ const item = { start: 40, size: 20, duration: 1000 };
47
+
48
+ await observer.emit(SUBSCRIBE_EVENTS.SHOW, { id: "door_lock", item });
49
+ await vi.advanceTimersByTimeAsync((module as QteController).PARAMS.QTE_VIEW.TRANSITION);
50
+ await vi.advanceTimersByTimeAsync(500);
51
+
52
+ await observer.emit(SUBSCRIBE_EVENTS.ACTION);
53
+
54
+ expect((module as QteController).state.result).toBe("success");
55
+
56
+ await vi.advanceTimersByTimeAsync((module as QteController).PARAMS.QTE_VIEW.RESULT_DELAY);
57
+
58
+ expect(result).toHaveBeenCalledExactlyOnceWith({ success: true });
59
+ });
60
+
61
+ it("ACTION resolves fail outside zone", async () => {
62
+ const { module, observer } = createQteViewTestModule(QteController);
63
+ const item = { start: 70, size: 20, duration: 1000 };
64
+
65
+ await observer.emit(SUBSCRIBE_EVENTS.SHOW, { id: "door_lock", item });
66
+ await vi.advanceTimersByTimeAsync((module as QteController).PARAMS.QTE_VIEW.TRANSITION);
67
+ await vi.advanceTimersByTimeAsync(100);
68
+
69
+ (module as QteController).controls[(module as QteController).CONST.CONTROLS.BUTTONS.ACCEPT]();
70
+
71
+ expect((module as QteController).state.result).toBe("fail");
72
+ });
73
+
74
+ it("auto-fails when pointer leaves the zone", async () => {
75
+ const { module, observer } = createQteViewTestModule(QteController);
76
+ const result = spyEvent(observer, QTE_EVENTS.RESULT);
77
+ const item = { start: 50, size: 10, duration: 1000 };
78
+
79
+ await observer.emit(SUBSCRIBE_EVENTS.SHOW, { id: "door_lock", item });
80
+ await vi.advanceTimersByTimeAsync((module as QteController).PARAMS.QTE_VIEW.TRANSITION);
81
+ await vi.advanceTimersByTimeAsync(600);
82
+ await vi.advanceTimersByTimeAsync((module as QteController).PARAMS.QTE_VIEW.RESULT_DELAY);
83
+
84
+ expect((module as QteController).state.result).toBe("fail");
85
+ expect(result).toHaveBeenCalledExactlyOnceWith({ success: false });
86
+ });
87
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { getFailAtProgress, getProgressFromElapsed, isProgressInsideZone, percentToDegrees } from "../utils/qte.js";
4
+
5
+ describe("qte utils", () => {
6
+ it("percentToDegrees maps full track to circle", () => {
7
+ expect(percentToDegrees(0)).toBe(0);
8
+ expect(percentToDegrees(50)).toBe(180);
9
+ expect(percentToDegrees(100)).toBe(360);
10
+ });
11
+
12
+ it("getProgressFromElapsed maps time to percent", () => {
13
+ expect(getProgressFromElapsed(0, 1000)).toBe(0);
14
+ expect(getProgressFromElapsed(500, 1000)).toBe(50);
15
+ expect(getProgressFromElapsed(1500, 1000)).toBe(100);
16
+ });
17
+
18
+ it("isProgressInsideZone checks inclusive bounds", () => {
19
+ expect(isProgressInsideZone(70, 70, 20)).toBe(true);
20
+ expect(isProgressInsideZone(90, 70, 20)).toBe(true);
21
+ expect(isProgressInsideZone(69, 70, 20)).toBe(false);
22
+ expect(isProgressInsideZone(91, 70, 20)).toBe(false);
23
+ });
24
+
25
+ it("getFailAtProgress caps at 100", () => {
26
+ expect(getFailAtProgress(70, 20)).toBe(90);
27
+ expect(getFailAtProgress(90, 20)).toBe(100);
28
+ });
29
+ });
@@ -0,0 +1,36 @@
1
+ import { CONSTANTS as CONTROLS_CONST, PLUGIN_NAME as CONTROLS, SUBSCRIBE_EVENTS as CONTROLS_EVENTS } from "@vnejs/contracts.controls";
2
+ import { PARAMS as QTE_PARAMS, PLUGIN_NAME as QTE, SUBSCRIBE_EVENTS as QTE_EVENTS } from "@vnejs/contracts.scenario.qte";
3
+ import { PARAMS as QTE_VIEW_PARAMS, PLUGIN_NAME as QTE_VIEW, SUBSCRIBE_EVENTS as QTE_VIEW_EVENTS } from "@vnejs/contracts.views.scenario.qte";
4
+ import { SourcesSet } from "@vnejs/sources-set";
5
+ import { createTestModule as baseCreateTestModule, registerCoreVne, registerVnePlugin, stubSilentEvent } from "@vnejs/test-utils";
6
+
7
+ export const registerQteViewPluginVne = () => {
8
+ registerCoreVne();
9
+ registerVnePlugin(CONTROLS, { constants: CONTROLS_CONST, events: CONTROLS_EVENTS });
10
+ registerVnePlugin(QTE, { events: QTE_EVENTS, params: QTE_PARAMS });
11
+ registerVnePlugin(QTE_VIEW, { events: QTE_VIEW_EVENTS, params: QTE_VIEW_PARAMS });
12
+ };
13
+
14
+ export const createQteViewTestModule = <T extends Parameters<typeof baseCreateTestModule>[0]>(
15
+ ModuleClass: T,
16
+ options: Parameters<typeof baseCreateTestModule>[1] = {},
17
+ ) => {
18
+ const result = baseCreateTestModule(ModuleClass, {
19
+ shared: {
20
+ viewForceAnimationSources: new SourcesSet(),
21
+ ...(options.shared ?? {}),
22
+ },
23
+ ...options,
24
+ });
25
+
26
+ stubSilentEvent(result.observer, QTE_VIEW_EVENTS.SHOW);
27
+ stubSilentEvent(result.observer, QTE_VIEW_EVENTS.HIDE);
28
+ stubSilentEvent(result.observer, QTE_VIEW_EVENTS.SHOW_BEFORE);
29
+ stubSilentEvent(result.observer, QTE_VIEW_EVENTS.UPDATE);
30
+ stubSilentEvent(result.observer, CONTROLS_EVENTS.PUSH);
31
+ stubSilentEvent(result.observer, CONTROLS_EVENTS.POP);
32
+
33
+ return result;
34
+ };
35
+
36
+ export { QTE_VIEW_EVENTS as SUBSCRIBE_EVENTS, QTE_EVENTS, CONTROLS_EVENTS };
package/src/types.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { ModuleComponentsConstants, ModuleComponentsEvents, ModuleComponentsParams, ModuleComponentsSettings } from "@vnejs/module.components";
2
+
3
+ import type { PluginName as ControlsPluginName, Constants as ControlsConstants } from "@vnejs/contracts.controls";
4
+ import type { PluginName as LogsPluginName, SubscribeEvents as LogsSubscribeEvents } from "@vnejs/contracts.core.logs";
5
+ import type { PluginName as VendorsPluginName, SubscribeEvents as VendorsSubscribeEvents } from "@vnejs/contracts.core.vendors";
6
+ import type { PluginName as QtePluginName, SubscribeEvents as QteSubscribeEvents, Params as QteParams } from "@vnejs/contracts.scenario.qte";
7
+
8
+ import type { PluginName, SubscribeEvents, Params } from "@vnejs/contracts.views.scenario.qte";
9
+
10
+ export type QtePluginEvents = ModuleComponentsEvents &
11
+ Record<QtePluginName, QteSubscribeEvents> &
12
+ Record<PluginName, SubscribeEvents> &
13
+ Record<VendorsPluginName, VendorsSubscribeEvents> &
14
+ Record<LogsPluginName, LogsSubscribeEvents>;
15
+
16
+ export type QtePluginConstants = ModuleComponentsConstants & Record<ControlsPluginName, ControlsConstants>;
17
+
18
+ export type QtePluginSettings = ModuleComponentsSettings;
19
+
20
+ export type QtePluginParams = ModuleComponentsParams & Record<QtePluginName, QteParams> & Record<PluginName, Params>;
@@ -0,0 +1,31 @@
1
+ import type { ModuleControllerState } from "@vnejs/module.components";
2
+ import type { QteItem } from "@vnejs/contracts.views.scenario.qte";
3
+
4
+ export type QtePhase = "idle" | "initial" | "intro" | "active" | "resolved" | "outro";
5
+ export type QteResult = "idle" | "success" | "fail";
6
+
7
+ export type QtePluginControllerState = ModuleControllerState & {
8
+ id: string;
9
+ item: QteItem | null;
10
+ phase: QtePhase;
11
+ result: QteResult;
12
+ locs?: Record<string, string>;
13
+ };
14
+
15
+ export type { QteItem, QteVisibilityPayload } from "@vnejs/contracts.views.scenario.qte";
16
+
17
+ export const percentToDegrees = (percent: number) => (percent / 100) * 360;
18
+
19
+ export const getProgressFromElapsed = (elapsedMs: number, duration: number) => {
20
+ if (duration <= 0) return 100;
21
+
22
+ return Math.min(Math.max((elapsedMs / duration) * 100, 0), 100);
23
+ };
24
+
25
+ export const isProgressInsideZone = (progress: number, start: number, size: number) => {
26
+ const zoneEnd = Math.min(start + size, 100);
27
+
28
+ return progress >= start && progress <= zoneEnd;
29
+ };
30
+
31
+ export const getFailAtProgress = (start: number, size: number) => Math.min(start + size, 100);
@@ -0,0 +1,119 @@
1
+ import { cn, getVneLength, injectStyles, sel } from "@vnejs/uis.utils";
2
+
3
+ export const b = cn("Qte");
4
+
5
+ const CSS = `
6
+ @keyframes qte-pointer-spin {
7
+ from {
8
+ transform: rotate(0deg);
9
+ }
10
+
11
+ to {
12
+ transform: rotate(360deg);
13
+ }
14
+ }
15
+
16
+ ${sel(b("shell"))} {
17
+ transform-origin: center;
18
+ transition: opacity var(--qte-intro-transition, 400ms), transform var(--qte-intro-transition, 400ms);
19
+ opacity: 0;
20
+ transform: scale(0.25);
21
+ }
22
+
23
+ ${sel(b("shell", { phase: "intro" }))},
24
+ ${sel(b("shell", { phase: "active" }))},
25
+ ${sel(b("shell", { phase: "resolved" }))} {
26
+ opacity: 1;
27
+ transform: scale(1);
28
+ }
29
+
30
+ ${sel(b("shell", { phase: "initial" }))},
31
+ ${sel(b("shell", { phase: "intro" }))},
32
+ ${sel(b("shell", { phase: "resolved" }))},
33
+ ${sel(b("shell", { phase: "outro" }))} {
34
+ pointer-events: none;
35
+ }
36
+
37
+ ${sel(b("shell", { phase: "outro" }))} {
38
+ opacity: 0;
39
+ transform: scale(1.75);
40
+ }
41
+
42
+ ${sel(b("arena"))} {
43
+ position: relative;
44
+ display: grid;
45
+ place-items: center;
46
+ width: ${getVneLength(1200)};
47
+ height: ${getVneLength(1200)};
48
+ }
49
+
50
+ ${sel(b("dial"))} {
51
+ width: 100%;
52
+ height: 100%;
53
+ overflow: visible;
54
+ cursor: pointer;
55
+ }
56
+
57
+ ${sel(b("trackShadow"))} {
58
+ fill: none;
59
+ stroke: rgba(0, 0, 0, 0.42);
60
+ stroke-width: 72;
61
+ }
62
+
63
+ ${sel(b("track"))} {
64
+ fill: rgba(15, 23, 42, 0.42);
65
+ stroke: rgba(226, 232, 240, 0.16);
66
+ stroke-width: 36;
67
+ }
68
+
69
+ ${sel(b("sector"))} {
70
+ fill: none;
71
+ stroke: rgba(255, 255, 255, 0.9);
72
+ stroke-linecap: round;
73
+ stroke-width: 36;
74
+ }
75
+
76
+ ${sel(b("pointerSpin"))} {
77
+ position: absolute;
78
+ inset: 0;
79
+ pointer-events: none;
80
+ transform-origin: center center;
81
+ }
82
+
83
+ ${sel(b("pointerSpin", { spinning: true }))} {
84
+ animation-name: qte-pointer-spin;
85
+ animation-duration: var(--qte-duration, 1800ms);
86
+ animation-timing-function: linear;
87
+ animation-fill-mode: forwards;
88
+ }
89
+
90
+ ${sel(b("pointerSpin", { paused: true }))} {
91
+ animation-play-state: paused;
92
+ }
93
+
94
+ ${sel(b("pointer"))} {
95
+ position: absolute;
96
+ left: 50%;
97
+ top: 50%;
98
+ width: ${getVneLength(96)};
99
+ height: ${getVneLength(96)};
100
+ border-radius: 50%;
101
+ background: #ffffff;
102
+ filter: drop-shadow(0 0 ${getVneLength(12)} rgba(0, 0, 0, 0.3));
103
+ transform: translate(-50%, -50%) translateY(${getVneLength(-480)});
104
+ }
105
+
106
+ ${sel(b("status"))} {
107
+ position: absolute;
108
+ display: grid;
109
+ place-items: center;
110
+ width: ${getVneLength(528)};
111
+ text-align: center;
112
+ pointer-events: none;
113
+ color: #ffffff;
114
+ font-size: ${getVneLength(72)};
115
+ font-weight: 900;
116
+ }
117
+ `;
118
+
119
+ injectStyles(CSS);
@@ -0,0 +1,111 @@
1
+ import type { ViewRenderFunc } from "@vnejs/module.components";
2
+ import type { ReactComponentProps } from "@vnejs/uis.react";
3
+ import { PositionBox, Screen, createRenderFunc, useCallback, useIsForceHook, useMemo, useStoreState } from "@vnejs/uis.react";
4
+
5
+ import type { QtePluginConstants, QtePluginEvents, QtePluginParams, QtePluginSettings } from "../types.js";
6
+ import type { QtePluginControllerState } from "../utils/qte.js";
7
+ import { percentToDegrees } from "../utils/qte.js";
8
+ import { b } from "./index.styles.js";
9
+
10
+ const CIRCLE_SIZE = 1200;
11
+ const CENTER = CIRCLE_SIZE / 2;
12
+ const RADIUS = 480;
13
+
14
+ type QteComponentProps = ReactComponentProps<QtePluginEvents, QtePluginConstants, QtePluginSettings, QtePluginParams, QtePluginControllerState>;
15
+
16
+ const polarToCartesian = (angle: number, radius: number) => {
17
+ const radians = ((angle - 90) * Math.PI) / 180;
18
+
19
+ return { x: CENTER + radius * Math.cos(radians), y: CENTER + radius * Math.sin(radians) };
20
+ };
21
+
22
+ const describeArc = (sectorStart: number, sectorWidth: number) => {
23
+ const start = polarToCartesian(sectorStart + sectorWidth, RADIUS);
24
+ const end = polarToCartesian(sectorStart, RADIUS);
25
+ const largeArcFlag = sectorWidth <= 180 ? "0" : "1";
26
+
27
+ return ["M", start.x, start.y, "A", RADIUS, RADIUS, 0, largeArcFlag, 0, end.x, end.y].join(" ");
28
+ };
29
+
30
+ const Qte = ({ store, onMount, emit, EVENTS, PARAMS }: QteComponentProps) => {
31
+ const {
32
+ isShow = false,
33
+ isForce = false,
34
+ phase = "",
35
+ result = "",
36
+ item = null,
37
+ locs = {},
38
+ } = useStoreState<QtePluginControllerState>(store, onMount);
39
+
40
+ const isRealForce = useIsForceHook(isForce);
41
+ const propsView = PARAMS.QTE_VIEW.VIEW_PROPS;
42
+ const transition = isRealForce ? 0 : PARAMS.QTE_VIEW.TRANSITION;
43
+ const introTransition = isRealForce ? 0 : Math.max(PARAMS.QTE_VIEW.TRANSITION - PARAMS.QTE_VIEW.INITIAL, 0);
44
+ const duration = item?.duration ?? 0;
45
+
46
+ const start = item?.start ?? 0;
47
+ const size = item?.size ?? 0;
48
+ const sectorStart = percentToDegrees(start);
49
+ const sectorWidth = percentToDegrees(size);
50
+ const sectorPath = useMemo(() => describeArc(sectorStart, sectorWidth), [sectorStart, sectorWidth]);
51
+
52
+ const isPointerSpinning = phase === "active" || phase === "resolved" || phase === "outro";
53
+ const isPointerPaused = phase === "resolved" || phase === "outro";
54
+
55
+ const onDialClick = useCallback(() => void emit(EVENTS.QTE_VIEW.ACTION), [emit, EVENTS.QTE_VIEW.ACTION]);
56
+
57
+ const propsScreen = useMemo(
58
+ () => ({ ...propsView.screen, isDisableAutoread: true, isIgnoreOnScreenshot: true, isShow, isForce: isRealForce, transition }),
59
+ [isShow, isRealForce, propsView.screen, transition],
60
+ );
61
+
62
+ return (
63
+ <Screen {...propsScreen}>
64
+ <PositionBox {...propsView.position}>
65
+ <div
66
+ className={b("shell", { phase })}
67
+ style={{ ["--qte-intro-transition" as string]: `${introTransition}ms` }}
68
+ >
69
+ <div className={b("arena")}>
70
+ <svg
71
+ className={b("dial")}
72
+ viewBox={`0 0 ${CIRCLE_SIZE} ${CIRCLE_SIZE}`}
73
+ onClick={onDialClick}
74
+ >
75
+ <circle
76
+ className={b("trackShadow")}
77
+ cx={CENTER}
78
+ cy={CENTER}
79
+ r={RADIUS}
80
+ />
81
+ <circle
82
+ className={b("track")}
83
+ cx={CENTER}
84
+ cy={CENTER}
85
+ r={RADIUS}
86
+ />
87
+ <path
88
+ className={b("sector")}
89
+ d={sectorPath}
90
+ />
91
+ </svg>
92
+ <div
93
+ className={b("pointerSpin", { spinning: isPointerSpinning, paused: isPointerPaused })}
94
+ style={{ ["--qte-duration" as string]: `${duration}ms` }}
95
+ >
96
+ <div className={b("pointer")} />
97
+ </div>
98
+ <div
99
+ className={b("status")}
100
+ aria-live="polite"
101
+ >
102
+ {locs[result] ?? ""}
103
+ </div>
104
+ </div>
105
+ </div>
106
+ </PositionBox>
107
+ </Screen>
108
+ );
109
+ };
110
+
111
+ export const render: ViewRenderFunc<QtePluginControllerState> = createRenderFunc(Qte);
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "@vnejs/configs.ts-common/tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "jsx": "react-jsx"
7
+ },
8
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
9
+ "exclude": ["dist", "node_modules", "src/tests"]
10
+ }