mirror of
https://github.com/ad044/lainTSX.git
synced 2024-10-22 23:19:06 +00:00
bugfixes, more stable intro implementation i think
This commit is contained in:
parent
dc545ba522
commit
a80cf992bb
9 changed files with 179 additions and 139 deletions
|
@ -40,6 +40,7 @@ type LainConstructorProps = {
|
|||
framesVertical: number;
|
||||
framesHorizontal: number;
|
||||
fps?: number;
|
||||
shouldAnimate?: boolean;
|
||||
};
|
||||
|
||||
export const LainConstructor = (props: LainConstructorProps) => {
|
||||
|
@ -58,7 +59,9 @@ export const LainConstructor = (props: LainConstructorProps) => {
|
|||
});
|
||||
|
||||
useFrame(() => {
|
||||
animator.animate();
|
||||
if (props.shouldAnimate !== false) {
|
||||
animator.animate();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -71,12 +74,14 @@ export const LainConstructor = (props: LainConstructorProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const LainIntro = () => (
|
||||
export const LainIntro = (props: { shouldAnimate: boolean }) => (
|
||||
<LainConstructor
|
||||
sprite={introSpriteSheet}
|
||||
frameCount={50}
|
||||
framesHorizontal={10}
|
||||
framesVertical={5}
|
||||
frameCount={3}
|
||||
framesHorizontal={3}
|
||||
framesVertical={1}
|
||||
fps={10}
|
||||
shouldAnimate={props.shouldAnimate}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -350,7 +355,8 @@ export const LainPlayWithHair = () => (
|
|||
);
|
||||
|
||||
type LainProps = {
|
||||
shouldIntro: boolean;
|
||||
shouldAnimate: boolean;
|
||||
introFinished: boolean;
|
||||
};
|
||||
|
||||
const Lain = (props: LainProps) => {
|
||||
|
@ -392,28 +398,17 @@ const Lain = (props: LainProps) => {
|
|||
return anims[lainMoveState as keyof typeof anims];
|
||||
}, [lainMoveState]);
|
||||
|
||||
const [introFinished, setIntroFinished] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIntroFinished(true);
|
||||
}, 3900);
|
||||
}, []);
|
||||
|
||||
const stopIntroAnim = useMemo(() => {
|
||||
return props.shouldIntro ? introFinished : true;
|
||||
}, [introFinished, props.shouldIntro]);
|
||||
|
||||
const lainRef = useRef<THREE.Sprite>();
|
||||
|
||||
const glowColor = useMemo(() => new THREE.Color(2, 2, 2), []);
|
||||
const regularColor = useMemo(() => new THREE.Color(1, 1, 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (wordSelected)
|
||||
if (wordSelected) {
|
||||
setTimeout(() => {
|
||||
if (lainRef.current) lainRef.current.material.color = glowColor;
|
||||
}, 3100);
|
||||
}
|
||||
}, [glowColor, wordSelected]);
|
||||
|
||||
useFrame(() => {
|
||||
|
@ -425,7 +420,11 @@ const Lain = (props: LainProps) => {
|
|||
return (
|
||||
<Suspense fallback={null}>
|
||||
<sprite scale={[4.5, 4.5, 4.5]} position={[0, -0.15, 0]} ref={lainRef}>
|
||||
{stopIntroAnim ? lainAnimationDispatch : <LainIntro />}
|
||||
{props.introFinished ? (
|
||||
lainAnimationDispatch
|
||||
) : (
|
||||
<LainIntro shouldAnimate={props.shouldAnimate} />
|
||||
)}
|
||||
</sprite>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
@ -43,7 +43,6 @@ export type SiteType = {
|
|||
};
|
||||
|
||||
type SiteProps = {
|
||||
shouldIntro: boolean;
|
||||
introFinished: boolean;
|
||||
};
|
||||
|
||||
|
@ -52,13 +51,17 @@ const Site = (props: SiteProps) => {
|
|||
|
||||
const [rotState, setRot] = useSpring(() => ({
|
||||
x: wordSelected ? 0 : useStore.getState().siteRot[0],
|
||||
y: wordSelected ? 0 : useStore.getState().siteRot[1],
|
||||
y: wordSelected
|
||||
? useStore.getState().oldSiteRot[1]
|
||||
: useStore.getState().siteRot[1],
|
||||
config: { duration: 1200 },
|
||||
}));
|
||||
|
||||
const [posState, setPos] = useSpring(() => ({
|
||||
y: wordSelected
|
||||
? 0
|
||||
? -level_y_values[
|
||||
useStore.getState().oldLevel as keyof typeof level_y_values
|
||||
]
|
||||
: -level_y_values[
|
||||
useStore.getState().activeLevel as keyof typeof level_y_values
|
||||
],
|
||||
|
@ -77,28 +80,6 @@ const Site = (props: SiteProps) => {
|
|||
}));
|
||||
}, [setPos, setRot]);
|
||||
|
||||
const introWrapperRef = useRef<THREE.Object3D>();
|
||||
|
||||
// imperative because having a spring here seemed to behave clunkily if that's even a word
|
||||
// the site would pop back after having done the intro anim sometimes
|
||||
useFrame(() => {
|
||||
if (introWrapperRef.current) {
|
||||
if (introWrapperRef.current.position.z < 0) {
|
||||
introWrapperRef.current.position.z += 0.05;
|
||||
}
|
||||
if (introWrapperRef.current.rotation.x > 0) {
|
||||
introWrapperRef.current.rotation.x -= 0.008;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (props.shouldIntro && introWrapperRef.current) {
|
||||
introWrapperRef.current.rotation.x = Math.PI / 2;
|
||||
introWrapperRef.current.position.z = -10;
|
||||
}
|
||||
}, [props.shouldIntro]);
|
||||
|
||||
const currentSite = useStore((state) => state.activeSite);
|
||||
const gameProgress = useStore((state) => state.gameProgress);
|
||||
|
||||
|
@ -110,17 +91,13 @@ const Site = (props: SiteProps) => {
|
|||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<a.group ref={introWrapperRef}>
|
||||
<a.group rotation-x={rotState.x}>
|
||||
<a.group rotation-y={rotState.y} position-y={posState.y}>
|
||||
<ActiveLevelNodes visibleNodes={visibleNodes} />
|
||||
<InactiveLevelNodes visibleNodes={visibleNodes} />
|
||||
<Rings
|
||||
activateAllRings={props.shouldIntro ? props.introFinished : true}
|
||||
/>
|
||||
</a.group>
|
||||
<NodeAnimations />
|
||||
<a.group rotation-x={rotState.x}>
|
||||
<a.group rotation-y={rotState.y} position-y={posState.y}>
|
||||
<ActiveLevelNodes visibleNodes={visibleNodes} />
|
||||
<InactiveLevelNodes visibleNodes={visibleNodes} />
|
||||
<Rings activateAllRings={props.introFinished} />
|
||||
</a.group>
|
||||
<NodeAnimations />
|
||||
</a.group>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
@ -3,7 +3,7 @@ import Star from "./Starfield/Star";
|
|||
|
||||
type StarfieldProps = {
|
||||
shouldIntro: boolean;
|
||||
introFinished: boolean;
|
||||
mainVisible: boolean;
|
||||
};
|
||||
|
||||
const Starfield = memo((props: StarfieldProps) => {
|
||||
|
@ -39,13 +39,9 @@ const Starfield = memo((props: StarfieldProps) => {
|
|||
])
|
||||
);
|
||||
|
||||
const [mainVisible, setMainVisible] = useState(false);
|
||||
const [introVisible, setIntroVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setMainVisible(true);
|
||||
}, 2800);
|
||||
setTimeout(() => {
|
||||
setIntroVisible(false);
|
||||
}, 3200);
|
||||
|
@ -53,10 +49,7 @@ const Starfield = memo((props: StarfieldProps) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<group
|
||||
position={[0, -1, 2]}
|
||||
visible={props.shouldIntro ? mainVisible : true}
|
||||
>
|
||||
<group position={[0, -1, 2]} visible={props.mainVisible}>
|
||||
<group rotation={[0, 0.75, Math.PI / 2]} position={[-0.7, -1, -5]}>
|
||||
{posesBlueFromLeft.map((poses, idx) => (
|
||||
<Star
|
||||
|
|
|
@ -104,6 +104,7 @@ const handleMainSceneEvent = (mainSceneContext: any) => {
|
|||
event: `${eventAnimation}_media`,
|
||||
scene: "media",
|
||||
siteRotY: siteRotY,
|
||||
level: level.toString().padStart(2, "0"),
|
||||
};
|
||||
case 6:
|
||||
if (activeNode.node_name.substr(0, 3) === "TaK") {
|
||||
|
@ -117,6 +118,7 @@ const handleMainSceneEvent = (mainSceneContext: any) => {
|
|||
event: `${eventAnimation}_media`,
|
||||
scene: "media",
|
||||
siteRotY: siteRotY,
|
||||
level: level.toString().padStart(2, "0"),
|
||||
};
|
||||
}
|
||||
case 8:
|
||||
|
|
|
@ -2,9 +2,15 @@ import { useStore } from "../../../../store";
|
|||
|
||||
const levelManager = (eventState: any) => {
|
||||
const setActiveLevel = useStore.getState().setActiveLevel;
|
||||
const setOldLevel = useStore.getState().setOldLevel;
|
||||
|
||||
const dispatchAction = (eventState: any) => {
|
||||
switch (eventState.event) {
|
||||
case "throw_node_media":
|
||||
case "rip_node_media":
|
||||
return {
|
||||
action: () => setOldLevel(eventState.level),
|
||||
};
|
||||
case "site_up":
|
||||
case "site_down":
|
||||
case "select_level_up":
|
||||
|
|
|
@ -3,9 +3,15 @@ import { useStore } from "../../../../store";
|
|||
const siteManager = (eventState: any) => {
|
||||
const setRotY = useStore.getState().setSiteRotY;
|
||||
const setRotX = useStore.getState().setSiteRotX;
|
||||
const setOldRot = useStore.getState().setOldSiteRot;
|
||||
|
||||
const dispatchAction = (eventState: any) => {
|
||||
switch (eventState.event) {
|
||||
case "throw_node_media":
|
||||
case "rip_node_media":
|
||||
return {
|
||||
action: () => setOldRot([0, eventState.siteRotY, 0]),
|
||||
};
|
||||
case "site_left":
|
||||
case "site_right":
|
||||
case "media_fstWord_select":
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { OrbitControls } from "@react-three/drei";
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { useStore } from "../store";
|
||||
import Pause from "../components/MainScene/PauseSubscene/Pause";
|
||||
import LevelSelection from "../components/MainScene/SyncedComponents/LevelSelection";
|
||||
|
@ -11,8 +11,11 @@ import GrayPlanes from "../components/MainScene/SyncedComponents/GrayPlanes";
|
|||
import Starfield from "../components/MainScene/SyncedComponents/Starfield";
|
||||
import Site from "../components/MainScene/SyncedComponents/Site";
|
||||
import Lain from "../components/MainScene/Lain";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "react-three-fiber";
|
||||
|
||||
const MainScene = () => {
|
||||
const intro = useStore((state) => state.intro);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const subscene = useStore((state) => state.mainSubscene);
|
||||
|
||||
|
@ -37,23 +40,84 @@ const MainScene = () => {
|
|||
}
|
||||
}, [setWordSelected, wordSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (intro) {
|
||||
if (introWrapperRef.current) {
|
||||
introWrapperRef.current.rotation.x = Math.PI / 2;
|
||||
introWrapperRef.current.position.z = -10;
|
||||
}
|
||||
setStarfieldIntro(false);
|
||||
setLainIntroAnim(false);
|
||||
setIntroFinished(false);
|
||||
}
|
||||
}, [intro]);
|
||||
|
||||
const [starfieldIntro, setStarfieldIntro] = useState(false);
|
||||
const [lainIntroAnim, setLainIntroAnim] = useState(false);
|
||||
const [introFinished, setIntroFinished] = useState(false);
|
||||
const introWrapperRef = useRef<THREE.Group>();
|
||||
|
||||
useFrame(() => {
|
||||
if (intro && introWrapperRef.current) {
|
||||
if (
|
||||
Math.round(introWrapperRef.current.position.z) === -3 &&
|
||||
!starfieldIntro
|
||||
) {
|
||||
setStarfieldIntro(true);
|
||||
}
|
||||
if (
|
||||
Math.round(introWrapperRef.current.position.z) === -1 &&
|
||||
!lainIntroAnim
|
||||
) {
|
||||
setLainIntroAnim(true);
|
||||
}
|
||||
|
||||
if (
|
||||
Math.round(introWrapperRef.current.position.z) === 0 &&
|
||||
Math.round(introWrapperRef.current.rotation.x) === 0 &&
|
||||
!introFinished
|
||||
) {
|
||||
setIntroFinished(true);
|
||||
}
|
||||
|
||||
if (introWrapperRef.current.position.z < 0) {
|
||||
introWrapperRef.current.position.z += 0.05;
|
||||
}
|
||||
if (introWrapperRef.current.rotation.x > 0) {
|
||||
introWrapperRef.current.rotation.x -= 0.008;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<perspectiveCamera position-z={3}>
|
||||
<Suspense fallback={null}>
|
||||
<LevelSelection />
|
||||
<Pause />
|
||||
<group visible={!paused}>
|
||||
<group visible={!wordSelected}>
|
||||
<group visible={!wordSelected && (intro ? introFinished : true)}>
|
||||
<HUD />
|
||||
<YellowTextRenderer />
|
||||
<MiddleRing />
|
||||
<GrayPlanes />
|
||||
<Lain shouldIntro={false} />
|
||||
</group>
|
||||
<YellowOrb visible={!paused} />
|
||||
<Starfield shouldIntro={false} introFinished={true} />
|
||||
<group visible={intro ? introFinished : true}>
|
||||
<YellowOrb visible={!paused} />
|
||||
</group>
|
||||
<Starfield
|
||||
shouldIntro={intro}
|
||||
mainVisible={intro ? starfieldIntro : true}
|
||||
/>
|
||||
</group>
|
||||
<group visible={!wordSelected}>
|
||||
<Lain
|
||||
shouldAnimate={lainIntroAnim}
|
||||
introFinished={intro ? introFinished : true}
|
||||
/>
|
||||
</group>
|
||||
<group ref={introWrapperRef}>
|
||||
<Site introFinished={intro ? introFinished : true} />
|
||||
</group>
|
||||
<Site shouldIntro={false} introFinished={true} />
|
||||
<OrbitControls />
|
||||
<pointLight color={0xffffff} position={[0, 0, 7]} intensity={1} />
|
||||
<pointLight color={0x7f7f7f} position={[0, 10, 0]} intensity={1.5} />
|
||||
|
|
12
src/store.ts
12
src/store.ts
|
@ -31,9 +31,11 @@ type State = {
|
|||
// site
|
||||
activeSite: "a" | "b";
|
||||
siteRot: number[];
|
||||
oldSiteRot: number[];
|
||||
|
||||
// level
|
||||
activeLevel: string;
|
||||
oldLevel: string;
|
||||
|
||||
// level selection
|
||||
selectedLevel: number;
|
||||
|
@ -96,7 +98,6 @@ type State = {
|
|||
endMediaPlayedCount: number;
|
||||
|
||||
// save state
|
||||
|
||||
siteSaveState: {
|
||||
a: {
|
||||
activeNode: NodeDataType;
|
||||
|
@ -171,9 +172,13 @@ export const useStore = create(
|
|||
// site
|
||||
activeSite: "a",
|
||||
siteRot: [0, 0, 0],
|
||||
// this one is used for word selection animation to start from the correct point
|
||||
oldSiteRot: [0, 0, 0],
|
||||
|
||||
// level
|
||||
activeLevel: "04",
|
||||
// this one is used for word selection animation to start from the correct point
|
||||
oldLevel: "04",
|
||||
|
||||
// level selection
|
||||
selectedLevel: 4,
|
||||
|
@ -301,9 +306,14 @@ export const useStore = create(
|
|||
nextRot[0] = to;
|
||||
return { siteRot: nextRot };
|
||||
}),
|
||||
setOldSiteRot: (to: number[]) =>
|
||||
set(() => ({
|
||||
oldSiteRot: to,
|
||||
})),
|
||||
|
||||
// level setters
|
||||
setActiveLevel: (to: string) => set(() => ({ activeLevel: to })),
|
||||
setOldLevel: (to: string) => set(() => ({ oldLevel: to })),
|
||||
|
||||
// level selection setters
|
||||
setSelectedLevel: (to: number) => set(() => ({ selectedLevel: to })),
|
||||
|
|
|
@ -60,29 +60,19 @@ export const getNodeHud = (nodeMatrixIndices: {
|
|||
] as keyof typeof node_huds
|
||||
];
|
||||
};
|
||||
|
||||
//visible = (global_final_viewcount > 0) && (req_final_viewcount <= global_final_viewcount + 1)
|
||||
export const isNodeVisible = (
|
||||
node: NodeDataType,
|
||||
gameProgress: typeof unlocked_nodes
|
||||
) => {
|
||||
if (node) {
|
||||
const unlockedBy = node.unlocked_by;
|
||||
|
||||
let unlocked;
|
||||
if (unlockedBy === "") unlocked = true;
|
||||
else
|
||||
unlocked =
|
||||
gameProgress[unlockedBy as keyof typeof gameProgress].is_viewed;
|
||||
|
||||
// visible = (global_final_viewcount > 0) && (req_final_viewcount <= global_final_viewcount + 1)
|
||||
|
||||
return (
|
||||
unlocked &&
|
||||
gameProgress[node.node_name as keyof typeof gameProgress].is_visible
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
) =>
|
||||
node
|
||||
? (node.unlocked_by === "" ||
|
||||
gameProgress[node.unlocked_by as keyof typeof gameProgress]
|
||||
.is_viewed) &&
|
||||
gameProgress[node.node_name as keyof typeof gameProgress].is_visible &&
|
||||
node.required_final_video_viewcount < 1
|
||||
: false;
|
||||
|
||||
export const getVisibleNodesMatrix = (
|
||||
matrixIdx: number,
|
||||
|
@ -134,99 +124,92 @@ function* nextPos_left([row, col]: [number, number]) {
|
|||
const p = RowPrecedence(row);
|
||||
|
||||
for (let c = col - 1; c > -1; c--)
|
||||
for (let r = 0; r < 3; r++)
|
||||
yield [p[r], c];
|
||||
for (let r = 0; r < 3; r++) yield [p[r], c];
|
||||
}
|
||||
|
||||
function* nextPos_right([row, col]: [number, number]) {
|
||||
const p = RowPrecedence(row);
|
||||
|
||||
for (let c = col + 1; c < 4; c++)
|
||||
for (let r = 0; r < 3; r++)
|
||||
yield [p[r], c];
|
||||
for (let c = col + 1; c < 4; c++) for (let r = 0; r < 3; r++) yield [p[r], c];
|
||||
}
|
||||
|
||||
function* nextPos_up([row, col]: [number, number]) {
|
||||
const p = ColPrecedence(col);
|
||||
|
||||
for (let r = row - 1; r > -1; r--)
|
||||
for (let c = 0; c < 4; c++)
|
||||
yield [r, p[c]];
|
||||
for (let c = 0; c < 4; c++) yield [r, p[c]];
|
||||
}
|
||||
|
||||
function* nextPos_down([row, col]: [number, number]) {
|
||||
const p = ColPrecedence(col);
|
||||
|
||||
for (let r = row + 1; r < 3; r++)
|
||||
for (let c = 0; c < 4; c++)
|
||||
yield [r, p[c]];
|
||||
for (let r = row + 1; r < 3; r++) for (let c = 0; c < 4; c++) yield [r, p[c]];
|
||||
}
|
||||
|
||||
function move(direction: string, [matrix, level]: [number, number]) {
|
||||
switch (direction) {
|
||||
case "left": matrix = matrix + 1 > 8 ? 1 : matrix + 1; break;
|
||||
case "right": matrix = matrix - 1 < 1 ? 8 : matrix - 1; break;
|
||||
case "up": level++; break;
|
||||
case "down": level--; break;
|
||||
case "left":
|
||||
matrix = matrix + 1 > 8 ? 1 : matrix + 1;
|
||||
break;
|
||||
case "right":
|
||||
matrix = matrix - 1 < 1 ? 8 : matrix - 1;
|
||||
break;
|
||||
case "up":
|
||||
level++;
|
||||
break;
|
||||
case "down":
|
||||
level--;
|
||||
break;
|
||||
}
|
||||
|
||||
return [matrix, level];
|
||||
}
|
||||
|
||||
export function findNode
|
||||
(
|
||||
export function findNode(
|
||||
direction: string,
|
||||
|
||||
{matrixIdx, rowIdx, colIdx}:
|
||||
{matrixIdx: number, rowIdx: number, colIdx: number},
|
||||
{
|
||||
matrixIdx,
|
||||
rowIdx,
|
||||
colIdx,
|
||||
}: { matrixIdx: number; rowIdx: number; colIdx: number },
|
||||
|
||||
level: number,
|
||||
currentSite: string,
|
||||
gameProgress: any
|
||||
)
|
||||
: any | undefined
|
||||
{
|
||||
): any | undefined {
|
||||
const funcs: any = {
|
||||
left: [
|
||||
nextPos_left,
|
||||
([r]: [number, number]) => nextPos_right([r, -1])
|
||||
],
|
||||
right: [
|
||||
nextPos_right,
|
||||
([r]: [number, number]) => nextPos_left([r, 4])
|
||||
],
|
||||
up: [
|
||||
nextPos_up,
|
||||
([, c]: [number, number]) => nextPos_up([3, c])
|
||||
],
|
||||
down: [
|
||||
nextPos_down,
|
||||
([, c]: [number, number]) => nextPos_down([-1, c])
|
||||
]
|
||||
left: [nextPos_left, ([r]: [number, number]) => nextPos_right([r, -1])],
|
||||
right: [nextPos_right, ([r]: [number, number]) => nextPos_left([r, 4])],
|
||||
up: [nextPos_up, ([, c]: [number, number]) => nextPos_up([3, c])],
|
||||
down: [nextPos_down, ([, c]: [number, number]) => nextPos_down([-1, c])],
|
||||
};
|
||||
|
||||
const nextPos = funcs[direction];
|
||||
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const nodes = getVisibleNodesMatrix(
|
||||
matrixIdx, level,
|
||||
currentSite, gameProgress
|
||||
matrixIdx,
|
||||
level,
|
||||
currentSite,
|
||||
gameProgress
|
||||
);
|
||||
|
||||
for (const [r, c] of nextPos[i]([rowIdx, colIdx])) {
|
||||
const node = nodes[r][c];
|
||||
|
||||
if (node) return {
|
||||
node,
|
||||
if (node)
|
||||
return {
|
||||
node,
|
||||
|
||||
matrixIndices: {
|
||||
matrixIdx,
|
||||
rowIdx: r, colIdx: c
|
||||
},
|
||||
matrixIndices: {
|
||||
matrixIdx,
|
||||
rowIdx: r,
|
||||
colIdx: c,
|
||||
},
|
||||
|
||||
didMove: Boolean(i)
|
||||
};
|
||||
didMove: Boolean(i),
|
||||
};
|
||||
}
|
||||
|
||||
[matrixIdx, level] = move(direction, [matrixIdx, level]);
|
||||
|
|
Loading…
Reference in a new issue