mirror of
https://github.com/ad044/lainTSX.git
synced 2024-10-22 23:19:06 +00:00
phone swipe support, tweaked gate code, fixed idle, better input cooldowns
This commit is contained in:
parent
2a3f2202aa
commit
20934e2f19
19 changed files with 388 additions and 346 deletions
21
src/App.tsx
21
src/App.tsx
|
@ -14,6 +14,7 @@ import ChangeDiscScene from "./scenes/ChangeDiscScene";
|
|||
import EndScene from "./scenes/EndScene";
|
||||
import IdleMediaScene from "./scenes/IdleMediaScene";
|
||||
import InputHandler from "./components/InputHandler";
|
||||
import { Html } from "@react-three/drei";
|
||||
|
||||
const App = () => {
|
||||
const currentScene = useStore((state) => state.currentScene);
|
||||
|
@ -40,16 +41,16 @@ const App = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<div id="game-root" className="game">
|
||||
<span className="canvas">
|
||||
<Canvas concurrent>
|
||||
<InputHandler />
|
||||
<Suspense fallback={null}>
|
||||
{/*<Preloader />*/}
|
||||
{dispatchScene[currentScene as keyof typeof dispatchScene]}
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</span>
|
||||
<div className="game">
|
||||
<Canvas concurrent>
|
||||
<Suspense fallback={null}>
|
||||
{/*<Preloader />*/}
|
||||
{dispatchScene[currentScene as keyof typeof dispatchScene]}
|
||||
<Html center zIndexRange={[0, 0]}>
|
||||
<InputHandler />
|
||||
</Html>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
{["media", "idle_media", "tak", "end"].includes(currentScene) && (
|
||||
<MediaPlayer />
|
||||
)}
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import BlueZero from "./GateMiddleObject/BlueZero";
|
||||
import BlueOne from "./GateMiddleObject/BlueOne";
|
||||
import { a, useSpring, useSprings } from "@react-spring/three";
|
||||
import blue_digit_positions from "../../resources/blue_digit_positions.json";
|
||||
import Mirror from "./GateMiddleObject/Mirror";
|
||||
import BlueDigit from "./GateMiddleObject/BlueDigit";
|
||||
|
||||
type GateMiddleObjectProps = {
|
||||
intro: boolean;
|
||||
|
@ -16,14 +15,12 @@ const GateMiddleObject = (props: GateMiddleObjectProps) => {
|
|||
const [springs, set] = useSprings(44, (intIdx) => {
|
||||
const idx = intIdx.toString();
|
||||
return {
|
||||
type: blue_digit_positions[idx as keyof typeof blue_digit_positions].type,
|
||||
posX:
|
||||
blue_digit_positions[idx as keyof typeof blue_digit_positions]
|
||||
.initial_x,
|
||||
posY:
|
||||
blue_digit_positions[idx as keyof typeof blue_digit_positions]
|
||||
.initial_y,
|
||||
visibility: false,
|
||||
config: { duration: 150 },
|
||||
};
|
||||
});
|
||||
|
@ -40,7 +37,6 @@ const GateMiddleObject = (props: GateMiddleObjectProps) => {
|
|||
.final_y,
|
||||
delay:
|
||||
blue_digit_positions[idx as keyof typeof blue_digit_positions].delay,
|
||||
visibility: true,
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -62,23 +58,17 @@ const GateMiddleObject = (props: GateMiddleObjectProps) => {
|
|||
position-z={middleObjectGroupState.posZ}
|
||||
visible={props.intro}
|
||||
>
|
||||
{springs.map((item, idx) =>
|
||||
item.type.get() === 1 ? (
|
||||
<BlueOne
|
||||
posX={item.posX}
|
||||
posY={item.posY}
|
||||
key={idx}
|
||||
visibility={item.visibility}
|
||||
/>
|
||||
) : (
|
||||
<BlueZero
|
||||
posX={item.posX}
|
||||
posY={item.posY}
|
||||
key={idx}
|
||||
visibility={item.visibility}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{springs.map((item, idx) => (
|
||||
<BlueDigit
|
||||
type={
|
||||
blue_digit_positions[
|
||||
idx.toString() as keyof typeof blue_digit_positions
|
||||
].type
|
||||
}
|
||||
posX={item.posX}
|
||||
posY={item.posY}
|
||||
/>
|
||||
))}
|
||||
</a.group>
|
||||
<Mirror
|
||||
visible={props.gateLvl === 1 ? !props.intro : props.gateLvl > 0}
|
||||
|
|
|
@ -1,29 +1,41 @@
|
|||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useLoader } from "react-three-fiber";
|
||||
import * as THREE from "three";
|
||||
import gateBlueBinarySingularZero from "../../../static/sprites/gate/blue_binary_singular_zero.png";
|
||||
import gateBlueBinarySingularOne from "../../../static/sprites/gate/blue_binary_singular_one.png";
|
||||
import { a, SpringValue } from "@react-spring/three";
|
||||
import gateBlueBinarySingularZero from "../../../static/sprites/gate/blue_binary_singular_zero.png";
|
||||
|
||||
type BlueZeroProps = {
|
||||
type BlueDigitProps = {
|
||||
type: number;
|
||||
posX: SpringValue<number>;
|
||||
posY: SpringValue<number>;
|
||||
visibility: SpringValue<boolean>;
|
||||
};
|
||||
|
||||
const BlueZero = (props: BlueZeroProps) => {
|
||||
const BlueDigit = (props: BlueDigitProps) => {
|
||||
const gateBlueBinarySingularOneTex = useLoader(
|
||||
THREE.TextureLoader,
|
||||
gateBlueBinarySingularOne
|
||||
);
|
||||
const gateBlueBinarySingularZeroTex = useLoader(
|
||||
THREE.TextureLoader,
|
||||
gateBlueBinarySingularZero
|
||||
);
|
||||
|
||||
const objRef = useRef<THREE.Mesh>();
|
||||
const matRef = useRef<THREE.ShaderMaterial>();
|
||||
|
||||
const uniforms = useMemo(
|
||||
() => ({
|
||||
zeroTex: { type: "t", value: gateBlueBinarySingularZeroTex },
|
||||
tex: {
|
||||
type: "t",
|
||||
value:
|
||||
props.type === 1
|
||||
? gateBlueBinarySingularOneTex
|
||||
: gateBlueBinarySingularZeroTex,
|
||||
},
|
||||
brightnessMultiplier: { value: 1.5 },
|
||||
}),
|
||||
[gateBlueBinarySingularZeroTex]
|
||||
[gateBlueBinarySingularOneTex, gateBlueBinarySingularZeroTex, props.type]
|
||||
);
|
||||
|
||||
const vertexShader = `
|
||||
|
@ -35,14 +47,13 @@ const BlueZero = (props: BlueZeroProps) => {
|
|||
}
|
||||
`;
|
||||
|
||||
const fragmentShaderZero = `
|
||||
uniform sampler2D zeroTex;
|
||||
const fragmentShader = `
|
||||
uniform sampler2D tex;
|
||||
uniform float brightnessMultiplier;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = texture2D(zeroTex, vUv) * brightnessMultiplier;
|
||||
gl_FragColor = texture2D(tex, vUv) * brightnessMultiplier;
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -50,21 +61,26 @@ const BlueZero = (props: BlueZeroProps) => {
|
|||
setTimeout(() => {
|
||||
if (matRef.current) {
|
||||
matRef.current.uniforms.brightnessMultiplier.value = 3.5;
|
||||
matRef.current.uniformsNeedUpdate = true;
|
||||
}
|
||||
}, 1400);
|
||||
setTimeout(() => {
|
||||
if (objRef.current) objRef.current.visible = true;
|
||||
}, 150);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<a.mesh
|
||||
scale={[0.08, 0.1, 0]}
|
||||
scale={[0.04, 0.1, 0]}
|
||||
position-x={props.posX}
|
||||
position-y={props.posY}
|
||||
renderOrder={5}
|
||||
visible={props.visibility}
|
||||
visible={false}
|
||||
ref={objRef}
|
||||
>
|
||||
<planeBufferGeometry attach="geometry"></planeBufferGeometry>
|
||||
<planeBufferGeometry attach="geometry" />
|
||||
<shaderMaterial
|
||||
fragmentShader={fragmentShaderZero}
|
||||
fragmentShader={fragmentShader}
|
||||
vertexShader={vertexShader}
|
||||
uniforms={uniforms}
|
||||
attach="material"
|
||||
|
@ -76,4 +92,4 @@ const BlueZero = (props: BlueZeroProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default BlueZero;
|
||||
export default BlueDigit;
|
|
@ -1,77 +0,0 @@
|
|||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useLoader } from "react-three-fiber";
|
||||
import * as THREE from "three";
|
||||
import gateBlueBinarySingularOne from "../../../static/sprites/gate/blue_binary_singular_one.png";
|
||||
import { a, SpringValue } from "@react-spring/three";
|
||||
|
||||
type BlueOneProps = {
|
||||
posX: SpringValue<number>;
|
||||
posY: SpringValue<number>;
|
||||
visibility: SpringValue<boolean>;
|
||||
};
|
||||
|
||||
const BlueOne = (props: BlueOneProps) => {
|
||||
const gateBlueBinarySingularOneTex = useLoader(
|
||||
THREE.TextureLoader,
|
||||
gateBlueBinarySingularOne
|
||||
);
|
||||
|
||||
const matRef = useRef<THREE.ShaderMaterial>();
|
||||
|
||||
const uniforms = useMemo(
|
||||
() => ({
|
||||
oneTex: { type: "t", value: gateBlueBinarySingularOneTex },
|
||||
brightnessMultiplier: { value: 1.5 },
|
||||
}),
|
||||
[gateBlueBinarySingularOneTex]
|
||||
);
|
||||
|
||||
const vertexShader = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShaderOne = `
|
||||
uniform sampler2D oneTex;
|
||||
uniform float brightnessMultiplier;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = texture2D(oneTex, vUv) * brightnessMultiplier;
|
||||
}
|
||||
`;
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (matRef.current)
|
||||
matRef.current.uniforms.brightnessMultiplier.value = 3.5;
|
||||
}, 1400);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<a.mesh
|
||||
scale={[0.04, 0.1, 0]}
|
||||
position-x={props.posX}
|
||||
position-y={props.posY}
|
||||
renderOrder={5}
|
||||
visible={props.visibility}
|
||||
>
|
||||
<planeBufferGeometry attach="geometry"></planeBufferGeometry>
|
||||
<shaderMaterial
|
||||
fragmentShader={fragmentShaderOne}
|
||||
vertexShader={vertexShader}
|
||||
uniforms={uniforms}
|
||||
attach="material"
|
||||
transparent={true}
|
||||
depthTest={false}
|
||||
ref={matRef}
|
||||
/>
|
||||
</a.mesh>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlueOne;
|
77
src/components/IdleManager.tsx
Normal file
77
src/components/IdleManager.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { useFrame } from "react-three-fiber";
|
||||
import { playAudio, useStore } from "../store";
|
||||
import * as audio from "../static/audio/sfx";
|
||||
import {
|
||||
playIdleAudio,
|
||||
playIdleVideo,
|
||||
playLainIdleAnim,
|
||||
} from "../core/eventTemplates";
|
||||
import {
|
||||
getRandomIdleLainAnim,
|
||||
getRandomIdleMedia,
|
||||
} from "../helpers/idle-helpers";
|
||||
import handleEvent from "../core/handleEvent";
|
||||
|
||||
type IdleManagerProps = {
|
||||
lainIdleTimerRef: any;
|
||||
idleSceneTimerRef: any;
|
||||
};
|
||||
|
||||
const IdleManager = (props: IdleManagerProps) => {
|
||||
const mainSubscene = useStore((state) => state.mainSubscene);
|
||||
const scene = useStore((state) => state.currentScene);
|
||||
|
||||
useFrame(() => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
props.lainIdleTimerRef.current !== -1 &&
|
||||
props.idleSceneTimerRef.current !== -1 &&
|
||||
mainSubscene !== "pause" &&
|
||||
mainSubscene !== "level_selection" &&
|
||||
scene === "main"
|
||||
) {
|
||||
if (now > props.lainIdleTimerRef.current + 10000) {
|
||||
// after one idle animation plays, the second comes sooner than it would after a regular keypress
|
||||
props.lainIdleTimerRef.current = now - 2500;
|
||||
|
||||
const [idleLainAnim, duration] = getRandomIdleLainAnim();
|
||||
|
||||
const event = playLainIdleAnim({
|
||||
lainMoveState: idleLainAnim,
|
||||
duration: duration,
|
||||
});
|
||||
|
||||
if (event) handleEvent(event);
|
||||
}
|
||||
if (now > props.idleSceneTimerRef.current + 500000) {
|
||||
// put it on lock until the next action, since while the idle media plays, the
|
||||
// Date.now() value keeps increasing, which can result in another idle media playing right after one finishes
|
||||
// one way to work around this would be to modify the value depending on the last played idle media's duration
|
||||
// but i'm way too lazy for that
|
||||
props.idleSceneTimerRef.current = -1;
|
||||
|
||||
playAudio(audio.sound32);
|
||||
|
||||
const data = getRandomIdleMedia();
|
||||
|
||||
const { type, nodeName, images, media } = data;
|
||||
let event;
|
||||
if (type === "audio" && images && nodeName) {
|
||||
event = playIdleAudio({
|
||||
idleNodeName: nodeName,
|
||||
idleImages: images,
|
||||
idleMedia: media,
|
||||
});
|
||||
} else if (type === "video") {
|
||||
event = playIdleVideo({ idleMedia: media });
|
||||
}
|
||||
|
||||
if (event) handleEvent(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default IdleManager;
|
|
@ -1,154 +1,138 @@
|
|||
import { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
getBootSceneContext,
|
||||
getEndSceneContext,
|
||||
getMainSceneContext,
|
||||
getMediaSceneContext,
|
||||
getSsknSceneContext,
|
||||
playAudio,
|
||||
useStore,
|
||||
} from "../store";
|
||||
import { getKeyCodeAssociation } from "../utils/parseUserInput";
|
||||
import getKeyPress from "../utils/getKeyPress";
|
||||
import handleMediaSceneInput from "../core/input-handlers/handleMediaSceneInput";
|
||||
import handleSsknSceneInput from "../core/input-handlers/handleSsknSceneInput";
|
||||
import handleMainSceneInput from "../core/input-handlers/handleMainSceneInput";
|
||||
import handleBootSceneInput from "../core/input-handlers/handleBootSceneInput";
|
||||
import { useFrame } from "react-three-fiber";
|
||||
import { getRandomIdleLainAnim } from "../helpers/idle-helpers";
|
||||
import * as audio from "../static/audio/sfx";
|
||||
import handleEndSceneInput from "../core/input-handlers/handleEndSceneInput";
|
||||
import handleEvent from "../core/handleEvent";
|
||||
import { GameEvent } from "../types/types";
|
||||
import { useSwipeable } from "react-swipeable";
|
||||
import IdleManager from "./IdleManager";
|
||||
import { Canvas } from "react-three-fiber";
|
||||
|
||||
const InputHandler = () => {
|
||||
const scene = useStore((state) => state.currentScene);
|
||||
const mainSubscene = useStore((state) => state.mainSubscene);
|
||||
const inputCooldown = useStore((state) => state.inputCooldown);
|
||||
|
||||
const setLainMoveState = useStore((state) => state.setLainMoveState);
|
||||
|
||||
const timeSinceLastKeyPress = useRef(-1);
|
||||
const lainIdleCounter = useRef(-1);
|
||||
const idleSceneCounter = useRef(-1);
|
||||
|
||||
useFrame(() => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
lainIdleCounter.current > -1 &&
|
||||
idleSceneCounter.current > -1 &&
|
||||
mainSubscene !== "pause" &&
|
||||
mainSubscene !== "level_selection" &&
|
||||
scene === "main"
|
||||
) {
|
||||
if (now > lainIdleCounter.current + 10000) {
|
||||
setLainMoveState(getRandomIdleLainAnim());
|
||||
// after one idle animation plays, the second comes sooner than it would after a regular keypress
|
||||
lainIdleCounter.current = now - 2500;
|
||||
}
|
||||
if (now > idleSceneCounter.current + 30000) {
|
||||
// put it on lock until the next action, since while the idle media plays, the
|
||||
// Date.now() value keeps increasing, which can result in another idle media playing right after one finishes
|
||||
// one way to work around this would be to modify the value depending on the last played idle media's duration
|
||||
// but i'm way too lazy for that
|
||||
idleSceneCounter.current = -1;
|
||||
|
||||
// idleManager(getRandomIdleMedia());
|
||||
playAudio(audio.sound32);
|
||||
|
||||
setTimeout(() => {
|
||||
// useStore.setState({ event: "play_idle_media" });
|
||||
}, 1200);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (scene !== "main") idleSceneCounter.current = -1;
|
||||
}, [scene]);
|
||||
const lainIdleTimerRef = useRef(-1);
|
||||
const idleSceneTimerRef = useRef(-1);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(event) => {
|
||||
const { keyCode } = event;
|
||||
(keyPress: string) => {
|
||||
const now = Date.now();
|
||||
|
||||
const keyPress = getKeyCodeAssociation(keyCode);
|
||||
if (scene === "main") {
|
||||
timeSinceLastKeyPress.current = now;
|
||||
lainIdleTimerRef.current = now;
|
||||
idleSceneTimerRef.current = now;
|
||||
}
|
||||
|
||||
const sceneFns = (() => {
|
||||
switch (scene) {
|
||||
case "main":
|
||||
return {
|
||||
contextProvider: getMainSceneContext,
|
||||
keyPressHandler: handleMainSceneInput,
|
||||
};
|
||||
case "media":
|
||||
return {
|
||||
contextProvider: getMediaSceneContext,
|
||||
keyPressHandler: handleMediaSceneInput,
|
||||
};
|
||||
case "sskn":
|
||||
return {
|
||||
contextProvider: getSsknSceneContext,
|
||||
keyPressHandler: handleSsknSceneInput,
|
||||
};
|
||||
case "boot":
|
||||
return {
|
||||
contextProvider: getBootSceneContext,
|
||||
keyPressHandler: handleBootSceneInput,
|
||||
};
|
||||
case "end":
|
||||
return {
|
||||
contextProvider: getEndSceneContext,
|
||||
keyPressHandler: handleEndSceneInput,
|
||||
};
|
||||
case "gate":
|
||||
case "polytan":
|
||||
useStore.setState({ currentScene: "main" });
|
||||
break;
|
||||
case "idle_media":
|
||||
useStore.setState({
|
||||
currentScene: "main",
|
||||
idleStarting: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
})();
|
||||
|
||||
if (sceneFns) {
|
||||
const { contextProvider, keyPressHandler } = sceneFns;
|
||||
|
||||
const ctx = contextProvider();
|
||||
const event: GameEvent | undefined = keyPressHandler(
|
||||
ctx as any,
|
||||
keyPress
|
||||
);
|
||||
if (event) handleEvent(event);
|
||||
}
|
||||
},
|
||||
[scene]
|
||||
);
|
||||
|
||||
const handlers = useSwipeable({
|
||||
onSwiped: (eventData) => handleKeyPress(eventData.dir.toUpperCase()),
|
||||
onTap: () => handleKeyPress("CIRCLE"),
|
||||
});
|
||||
|
||||
const handleKeyBoardEvent = useCallback(
|
||||
(event) => {
|
||||
const key = getKeyPress(event.key);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (
|
||||
keyPress &&
|
||||
key &&
|
||||
now > timeSinceLastKeyPress.current + inputCooldown &&
|
||||
inputCooldown !== -1
|
||||
) {
|
||||
if (scene === "main") {
|
||||
lainIdleCounter.current = now;
|
||||
idleSceneCounter.current = now;
|
||||
timeSinceLastKeyPress.current = now;
|
||||
}
|
||||
|
||||
const sceneFns = (() => {
|
||||
switch (scene) {
|
||||
case "main":
|
||||
return {
|
||||
contextProvider: getMainSceneContext,
|
||||
keyPressHandler: handleMainSceneInput,
|
||||
};
|
||||
case "media":
|
||||
return {
|
||||
contextProvider: getMediaSceneContext,
|
||||
keyPressHandler: handleMediaSceneInput,
|
||||
};
|
||||
case "sskn":
|
||||
return {
|
||||
contextProvider: getSsknSceneContext,
|
||||
keyPressHandler: handleSsknSceneInput,
|
||||
};
|
||||
case "boot":
|
||||
return {
|
||||
contextProvider: getBootSceneContext,
|
||||
keyPressHandler: handleBootSceneInput,
|
||||
};
|
||||
case "end":
|
||||
return {
|
||||
contextProvider: getEndSceneContext,
|
||||
keyPressHandler: handleEndSceneInput,
|
||||
};
|
||||
case "gate":
|
||||
case "polytan":
|
||||
useStore.setState({ currentScene: "main" });
|
||||
break;
|
||||
case "idle_media":
|
||||
useStore.setState({
|
||||
currentScene: "main",
|
||||
idleStarting: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
})();
|
||||
|
||||
if (sceneFns) {
|
||||
const { contextProvider, keyPressHandler } = sceneFns;
|
||||
|
||||
const ctx = contextProvider();
|
||||
const event: GameEvent | undefined = keyPressHandler(
|
||||
ctx as any,
|
||||
keyPress
|
||||
);
|
||||
if (event) handleEvent(event);
|
||||
}
|
||||
handleKeyPress(key);
|
||||
}
|
||||
},
|
||||
[inputCooldown, scene]
|
||||
[handleKeyPress, inputCooldown]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyPress);
|
||||
window.addEventListener("keydown", handleKeyBoardEvent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyPress);
|
||||
window.removeEventListener("keydown", handleKeyBoardEvent);
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
}, [handleKeyBoardEvent]);
|
||||
|
||||
return null;
|
||||
return (
|
||||
<>
|
||||
<div {...handlers} className="swipe-handler" />
|
||||
<Canvas>
|
||||
<IdleManager
|
||||
lainIdleTimerRef={lainIdleTimerRef}
|
||||
idleSceneTimerRef={idleSceneTimerRef}
|
||||
/>
|
||||
</Canvas>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputHandler;
|
||||
|
|
|
@ -25,18 +25,6 @@ const HUD = memo(() => {
|
|||
const scene = useStore((state) => state.currentScene);
|
||||
const prevData = usePrevious({ siteRotY, activeLevel, subscene, scene });
|
||||
|
||||
const lerpObject = (
|
||||
obj: THREE.Object3D,
|
||||
posX: number,
|
||||
initialPosX: number
|
||||
) => {
|
||||
obj.position.x = lerp(
|
||||
obj.position.x,
|
||||
activeRef.current ? posX : initialPosX,
|
||||
0.12
|
||||
);
|
||||
};
|
||||
|
||||
// this part is imperative because it performs a lot better than having a toggleable spring.
|
||||
useFrame(() => {
|
||||
if (
|
||||
|
@ -46,25 +34,30 @@ const HUD = memo(() => {
|
|||
greenTextRef.current
|
||||
) {
|
||||
const hud = currentHudRef.current;
|
||||
lerpObject(
|
||||
longHudRef.current,
|
||||
hud.long.position[0],
|
||||
hud.long.initial_position[0]
|
||||
|
||||
longHudRef.current.position.x = lerp(
|
||||
longHudRef.current.position.x,
|
||||
activeRef.current ? hud.long.position[0] : hud.long.initial_position[0],
|
||||
0.12
|
||||
);
|
||||
lerpObject(
|
||||
boringHudRef.current,
|
||||
hud.boring.position[0],
|
||||
hud.boring.initial_position[0]
|
||||
boringHudRef.current.position.x = lerp(
|
||||
boringHudRef.current.position.x,
|
||||
activeRef.current
|
||||
? hud.boring.position[0]
|
||||
: hud.boring.initial_position[0],
|
||||
0.12
|
||||
);
|
||||
lerpObject(
|
||||
bigHudRef.current,
|
||||
hud.big.position[0],
|
||||
hud.big.initial_position[0]
|
||||
bigHudRef.current.position.x = lerp(
|
||||
bigHudRef.current.position.x,
|
||||
activeRef.current ? hud.big.position[0] : hud.big.initial_position[0],
|
||||
0.12
|
||||
);
|
||||
lerpObject(
|
||||
greenTextRef.current,
|
||||
hud.medium_text.position[0],
|
||||
hud.medium_text.initial_position[0]
|
||||
greenTextRef.current.position.x = lerp(
|
||||
greenTextRef.current.position.x,
|
||||
activeRef.current
|
||||
? hud.medium_text.position[0]
|
||||
: hud.medium_text.initial_position[0],
|
||||
0.12
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -204,7 +204,7 @@ export const changeSelectedLevel = (calculatedState: {
|
|||
{
|
||||
mutation: {
|
||||
selectedLevel: calculatedState.selectedLevel,
|
||||
inputCooldown: 300,
|
||||
inputCooldown: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -263,7 +263,7 @@ export const changePauseComponent = (calculatedState: {
|
|||
{
|
||||
mutation: {
|
||||
activePauseComponent: calculatedState.activePauseComponent,
|
||||
inputCooldown: 500,
|
||||
inputCooldown: 700,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -279,7 +279,7 @@ export const showPermissionDenied = {
|
|||
};
|
||||
|
||||
export const displayPrompt = {
|
||||
state: [{ mutation: { promptVisible: true, inputCooldown: 500 } }],
|
||||
state: [{ mutation: { promptVisible: true, inputCooldown: 0 } }],
|
||||
audio: [{ sfx: [audio.sound0] }],
|
||||
};
|
||||
|
||||
|
@ -311,7 +311,7 @@ export const exitPause = (calculatedState: { siteRot: number[] }) => ({
|
|||
});
|
||||
|
||||
export const exitAbout = {
|
||||
state: [{ mutation: { showingAbout: false, inputCooldown: 500 } }],
|
||||
state: [{ mutation: { showingAbout: false, inputCooldown: 0 } }],
|
||||
};
|
||||
|
||||
export const changePromptComponent = (calculatedState: {
|
||||
|
@ -321,7 +321,7 @@ export const changePromptComponent = (calculatedState: {
|
|||
{
|
||||
mutation: {
|
||||
activePromptComponent: calculatedState.activePromptComponent,
|
||||
inputCooldown: 500,
|
||||
inputCooldown: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -334,7 +334,7 @@ export const exitPrompt = {
|
|||
mutation: {
|
||||
activePromptComponent: "no",
|
||||
promptVisible: false,
|
||||
inputCooldown: 500,
|
||||
inputCooldown: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -458,7 +458,7 @@ export const changeMediaSide = (calculatedState: {
|
|||
activeMediaComponent: calculatedState.activeMediaComponent,
|
||||
lastActiveMediaComponents: calculatedState.lastActiveMediaComponents,
|
||||
currentMediaSide: calculatedState.currentMediaSide,
|
||||
inputCooldown: 500,
|
||||
inputCooldown: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -497,7 +497,7 @@ export const changeRightMediaComponent = (calculatedState: {
|
|||
mutation: {
|
||||
activeMediaComponent: calculatedState.activeComponent,
|
||||
mediaWordPosStateIdx: calculatedState.wordPosStateIdx,
|
||||
inputCooldown: 500,
|
||||
inputCooldown: 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -519,7 +519,7 @@ export const wordNotFound = {
|
|||
};
|
||||
|
||||
export const hideWordNotFound = {
|
||||
state: [{ mutation: { wordNotFound: false, inputCooldown: 300 } }],
|
||||
state: [{ mutation: { wordNotFound: false, inputCooldown: 0 } }],
|
||||
};
|
||||
|
||||
export const selectWord = (calculatedState: {
|
||||
|
@ -550,7 +550,7 @@ export const changeSsknComponent = (calculatedState: {
|
|||
{
|
||||
mutation: {
|
||||
activeSsknComponent: calculatedState.activeSsknComponent,
|
||||
inputCooldown: 500,
|
||||
inputCooldown: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -595,7 +595,7 @@ export const changeEndComponent = (calculatedState: {
|
|||
{
|
||||
mutation: {
|
||||
activeEndComponent: calculatedState.activeEndComponent,
|
||||
inputCooldown: 500,
|
||||
inputCooldown: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -615,7 +615,7 @@ export const changeMainMenuComponent = (calculatedState: {
|
|||
{
|
||||
mutation: {
|
||||
activeMainMenuComponent: calculatedState.activeMainMenuComponent,
|
||||
inputCooldown: 500,
|
||||
inputCooldown: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -702,3 +702,58 @@ export const updateAuthorizeUserLetterIdx = (calculatedState: {
|
|||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const playIdleVideo = (calculatedState: { idleMedia: string }) => ({
|
||||
state: [
|
||||
{
|
||||
mutation: {
|
||||
idleStarting: true,
|
||||
idleMedia: calculatedState.idleMedia,
|
||||
inputCooldown: -1,
|
||||
},
|
||||
},
|
||||
{ mutation: { currentScene: "idle_media" }, delay: 1200 },
|
||||
],
|
||||
});
|
||||
|
||||
export const playIdleAudio = (calculatedState: {
|
||||
idleMedia: string;
|
||||
idleImages: { "1": string; "2": string; "3": string };
|
||||
idleNodeName: string;
|
||||
}) => ({
|
||||
state: [
|
||||
{
|
||||
mutation: {
|
||||
idleStarting: true,
|
||||
inputCooldown: -1,
|
||||
idleMedia: calculatedState.idleMedia,
|
||||
idleImages: calculatedState.idleImages,
|
||||
idleNodeName: calculatedState.idleNodeName,
|
||||
},
|
||||
},
|
||||
{ mutation: { currentScene: "idle_media" }, delay: 1200 },
|
||||
],
|
||||
});
|
||||
|
||||
export const playLainIdleAnim = (calculatedState: {
|
||||
lainMoveState: string;
|
||||
duration: number;
|
||||
}) => ({
|
||||
// todo appropriate disable-move here also
|
||||
state: [
|
||||
{
|
||||
mutation: {
|
||||
lainMoveState: calculatedState.lainMoveState,
|
||||
canLainMove: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
mutation: { lainMoveState: "standing", canLainMove: true },
|
||||
delay: calculatedState.duration,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const resetInputCooldown = {
|
||||
state: [{ mutation: { inputCooldown: 0 } }],
|
||||
};
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
loadGame,
|
||||
loadGameFail,
|
||||
pauseGame,
|
||||
resetInputCooldown,
|
||||
ripNode,
|
||||
saveGame,
|
||||
selectLevel,
|
||||
|
@ -54,6 +55,7 @@ const handleMainSceneInput = (
|
|||
activePromptComponent,
|
||||
siteSaveState,
|
||||
wordNotFound,
|
||||
canLainMove,
|
||||
} = mainSceneContext;
|
||||
|
||||
if (promptVisible) {
|
||||
|
@ -137,6 +139,7 @@ const handleMainSceneInput = (
|
|||
};
|
||||
|
||||
if (nodeData.didMove) {
|
||||
if (!canLainMove) return resetInputCooldown;
|
||||
return siteMoveHorizontal({
|
||||
lainMoveAnimation: lainMoveAnimation,
|
||||
siteRot: newSiteRot,
|
||||
|
@ -179,15 +182,18 @@ const handleMainSceneInput = (
|
|||
matrixIndices: nodeData.matrixIndices,
|
||||
};
|
||||
|
||||
if (nodeData.didMove)
|
||||
if (nodeData.didMove) {
|
||||
if (!canLainMove) return resetInputCooldown;
|
||||
return siteMoveVertical({
|
||||
lainMoveAnimation: lainMoveAnimation,
|
||||
activeLevel: newLevel,
|
||||
activeNode: newNode,
|
||||
});
|
||||
else return changeNode({ activeNode: newNode });
|
||||
} else return changeNode({ activeNode: newNode });
|
||||
}
|
||||
case "CIRCLE":
|
||||
if (!canLainMove) return resetInputCooldown;
|
||||
|
||||
const eventAnimation = Math.random() < 0.4 ? throwNode : ripNode;
|
||||
|
||||
if (
|
||||
|
@ -207,6 +213,7 @@ const handleMainSceneInput = (
|
|||
case "L2":
|
||||
return enterLevelSelection({ selectedLevel: level });
|
||||
case "TRIANGLE":
|
||||
if (!canLainMove) return resetInputCooldown;
|
||||
return pauseGame({ siteRot: [Math.PI / 2, siteRotY, 0] });
|
||||
}
|
||||
break;
|
||||
|
@ -225,6 +232,8 @@ const handleMainSceneInput = (
|
|||
return exitLevelSelection;
|
||||
|
||||
case "CIRCLE":
|
||||
if (!canLainMove) return resetInputCooldown;
|
||||
|
||||
if (level === selectedLevel) return;
|
||||
|
||||
const direction = selectedLevel > level ? "up" : "down";
|
||||
|
|
|
@ -72,39 +72,39 @@ export const getRandomIdleMedia = () => {
|
|||
const nodeName = siteData[level][nodeToPlay].node_name;
|
||||
|
||||
return {
|
||||
type: "audio",
|
||||
images: images,
|
||||
media: media,
|
||||
nodeName: nodeName,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "video",
|
||||
media:
|
||||
idleNodes.video[Math.floor(Math.random() * idleNodes.video.length)],
|
||||
nodeName: undefined,
|
||||
images: undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getRandomIdleLainAnim = () => {
|
||||
const moves = [
|
||||
"prayer",
|
||||
"touch_sleeve",
|
||||
"thinking",
|
||||
"stretch_2",
|
||||
"stretch",
|
||||
"spin",
|
||||
"scratch_head",
|
||||
"blush",
|
||||
"hands_behind_head",
|
||||
"hands_on_hips",
|
||||
"hands_on_hips_2",
|
||||
"hands_together",
|
||||
"lean_forward",
|
||||
"lean_left",
|
||||
"lean_right",
|
||||
"look_around",
|
||||
"play_with_hair",
|
||||
export const getRandomIdleLainAnim = (): [string, number] => {
|
||||
const moves: [string, number][] = [
|
||||
["prayer", 3500],
|
||||
["touch_sleeve", 3000],
|
||||
["thinking", 3900],
|
||||
["stretch_2", 3900],
|
||||
["stretch", 3000],
|
||||
["spin", 3000],
|
||||
["scratch_head", 3900],
|
||||
["blush", 3000],
|
||||
["hands_behind_head", 2300],
|
||||
["hands_on_hips", 3000],
|
||||
["hands_on_hips_2", 3900],
|
||||
["hands_together", 2500],
|
||||
["lean_forward", 2700],
|
||||
["lean_left", 2700],
|
||||
["lean_right", 3500],
|
||||
["look_around", 3000],
|
||||
["play_with_hair", 2900],
|
||||
];
|
||||
|
||||
return moves[Math.floor(Math.random() * moves.length)];
|
||||
|
|
|
@ -1747,31 +1747,31 @@
|
|||
"is_viewed": 0,
|
||||
"is_visible": 1
|
||||
},
|
||||
"Sskn01": {
|
||||
"SSkn01": {
|
||||
"is_viewed": 0,
|
||||
"is_visible": 1
|
||||
},
|
||||
"Sskn02": {
|
||||
"SSkn02": {
|
||||
"is_viewed": 0,
|
||||
"is_visible": 1
|
||||
},
|
||||
"Sskn03": {
|
||||
"SSkn03": {
|
||||
"is_viewed": 0,
|
||||
"is_visible": 1
|
||||
},
|
||||
"Sskn04": {
|
||||
"SSkn04": {
|
||||
"is_viewed": 0,
|
||||
"is_visible": 1
|
||||
},
|
||||
"Sskn04#": {
|
||||
"SSkn04#": {
|
||||
"is_viewed": 0,
|
||||
"is_visible": 1
|
||||
},
|
||||
"Sskn05": {
|
||||
"SSkn05": {
|
||||
"is_viewed": 0,
|
||||
"is_visible": 1
|
||||
},
|
||||
"Sskn06": {
|
||||
"SSkn06": {
|
||||
"is_viewed": 0,
|
||||
"is_visible": 1
|
||||
},
|
||||
|
|
|
@ -1644,7 +1644,7 @@
|
|||
"3": "-1"
|
||||
},
|
||||
"media_file": "INS01.STR",
|
||||
"node_name": "Sskn01",
|
||||
"node_name": "SSkn01",
|
||||
"required_final_video_viewcount": 0,
|
||||
"site": "A",
|
||||
"title": "mT up-date App.",
|
||||
|
@ -2748,7 +2748,7 @@
|
|||
"3": "-1"
|
||||
},
|
||||
"media_file": "INS02.STR",
|
||||
"node_name": "Sskn02",
|
||||
"node_name": "SSkn02",
|
||||
"required_final_video_viewcount": 0,
|
||||
"site": "A",
|
||||
"title": "mT up-date App.",
|
||||
|
@ -4892,7 +4892,7 @@
|
|||
"3": "-1"
|
||||
},
|
||||
"media_file": "INS03.STR",
|
||||
"node_name": "Sskn03",
|
||||
"node_name": "SSkn03",
|
||||
"required_final_video_viewcount": 0,
|
||||
"site": "A",
|
||||
"title": "mT up-date App.",
|
||||
|
@ -7302,7 +7302,7 @@
|
|||
"3": "-1"
|
||||
},
|
||||
"media_file": "INS04.STR",
|
||||
"node_name": "Sskn04",
|
||||
"node_name": "SSkn04",
|
||||
"required_final_video_viewcount": 0,
|
||||
"site": "A",
|
||||
"title": "mT up-date App.",
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
"3": "-1"
|
||||
},
|
||||
"media_file": "INS05.STR",
|
||||
"node_name": "Sskn04#",
|
||||
"node_name": "SSkn04#",
|
||||
"required_final_video_viewcount": 0,
|
||||
"site": "B",
|
||||
"title": "mT up-date App.",
|
||||
|
@ -1510,7 +1510,7 @@
|
|||
"3": "-1"
|
||||
},
|
||||
"media_file": "INS06.STR",
|
||||
"node_name": "Sskn05",
|
||||
"node_name": "SSkn05",
|
||||
"required_final_video_viewcount": 0,
|
||||
"site": "B",
|
||||
"title": "mT up-date App.",
|
||||
|
@ -2968,7 +2968,7 @@
|
|||
"3": "-1"
|
||||
},
|
||||
"media_file": "INS07.STR",
|
||||
"node_name": "Sskn06",
|
||||
"node_name": "SSkn06",
|
||||
"required_final_video_viewcount": 0,
|
||||
"site": "B",
|
||||
"title": "mT up-date App.",
|
||||
|
@ -5811,4 +5811,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ const ChangeDiscScene = () => {
|
|||
const disc2Tex = useLoader(THREE.TextureLoader, disc2);
|
||||
|
||||
useEffect(() => {
|
||||
// setTimeout(() => setScene("main"), 3500);
|
||||
setTimeout(() => setScene("main"), 3500);
|
||||
}, [activeSite, setScene]);
|
||||
|
||||
return (
|
||||
|
@ -70,7 +70,10 @@ const ChangeDiscScene = () => {
|
|||
</sprite>
|
||||
|
||||
<sprite scale={[0.4, 0.7, 0]} position={[1.4, -1.9, 0]}>
|
||||
<spriteMaterial attach="material" map={disc1Tex} />
|
||||
<spriteMaterial
|
||||
attach="material"
|
||||
map={activeSite === "a" ? disc1Tex : disc2Tex}
|
||||
/>
|
||||
</sprite>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -15,6 +15,7 @@ const IdleMediaScene = () => {
|
|||
useStore.setState({
|
||||
currentScene: "main",
|
||||
idleStarting: false,
|
||||
intro: false,
|
||||
});
|
||||
}, [mediaPercentageElapsed]);
|
||||
|
||||
|
|
22
src/store.ts
22
src/store.ts
|
@ -46,32 +46,26 @@ type State = {
|
|||
activeNodeRot: number[];
|
||||
activeNodeAttributes: NodeAttributes;
|
||||
|
||||
// lain
|
||||
lainMoveState: string;
|
||||
canLainMove: boolean;
|
||||
|
||||
// site
|
||||
activeSite: ActiveSite;
|
||||
siteRot: number[];
|
||||
oldSiteRot: number[];
|
||||
|
||||
// level
|
||||
activeLevel: string;
|
||||
oldLevel: string;
|
||||
|
||||
// level selection
|
||||
selectedLevel: number;
|
||||
|
||||
// end scene
|
||||
activeEndComponent: EndComponent;
|
||||
endSceneSelectionVisible: boolean;
|
||||
|
||||
// pause
|
||||
activePauseComponent: PauseComponent;
|
||||
pauseExitAnimation: boolean;
|
||||
showingAbout: boolean;
|
||||
permissionDenied: boolean;
|
||||
|
||||
// media/media scene
|
||||
audioAnalyser: AudioAnalyser | undefined;
|
||||
mediaPercentageElapsed: number;
|
||||
currentMediaSide: MediaSide;
|
||||
|
@ -84,39 +78,30 @@ type State = {
|
|||
mediaWordPosStateIdx: number;
|
||||
wordSelected: boolean;
|
||||
|
||||
// idle scene
|
||||
idleStarting: boolean;
|
||||
idleMedia: string;
|
||||
idleImages: { "1": string; "2": string; "3": string } | undefined;
|
||||
idleNodeName: string | undefined;
|
||||
|
||||
// sskn scene
|
||||
activeSsknComponent: SsknComponent;
|
||||
ssknLoading: boolean;
|
||||
|
||||
// polytan scene
|
||||
polytanUnlockedParts: PolytanBodyParts;
|
||||
|
||||
// player name
|
||||
playerName: string;
|
||||
|
||||
// boot scene
|
||||
activeMainMenuComponent: MainMenuComponent;
|
||||
authorizeUserLetterIdx: number;
|
||||
bootSubscene: BootSubscene;
|
||||
|
||||
// prompt
|
||||
promptVisible: boolean;
|
||||
activePromptComponent: PromptComponent;
|
||||
|
||||
// status notifiers
|
||||
loadSuccessful: boolean | undefined;
|
||||
saveSuccessful: boolean | undefined;
|
||||
|
||||
// word not found notification thing
|
||||
wordNotFound: boolean;
|
||||
|
||||
// save state
|
||||
siteSaveState: SiteSaveState;
|
||||
|
||||
inputCooldown: number;
|
||||
|
@ -126,7 +111,7 @@ export const useStore = create(
|
|||
combine(
|
||||
{
|
||||
// scene data
|
||||
currentScene: "change_disc",
|
||||
currentScene: "main",
|
||||
|
||||
// game progress
|
||||
gameProgress: game_progress,
|
||||
|
@ -153,6 +138,7 @@ export const useStore = create(
|
|||
|
||||
// lain
|
||||
lainMoveState: "standing",
|
||||
canLainMove: true,
|
||||
|
||||
// site
|
||||
activeSite: "a",
|
||||
|
@ -324,6 +310,7 @@ export const useStore = create(
|
|||
gate_level: state.gameProgress.gate_level + 1,
|
||||
},
|
||||
})),
|
||||
|
||||
loadUserSaveState: (userState: UserSaveState) =>
|
||||
set(() => ({
|
||||
siteSaveState: userState.siteSaveState,
|
||||
|
@ -362,6 +349,7 @@ export const getMainSceneContext = (): MainSceneContext => {
|
|||
showingAbout: state.showingAbout,
|
||||
siteSaveState: state.siteSaveState,
|
||||
wordNotFound: state.wordNotFound,
|
||||
canLainMove: state.canLainMove,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -108,6 +108,7 @@ export interface MainSceneContext extends PromptContext {
|
|||
selectedLevel: number;
|
||||
wordNotFound: boolean;
|
||||
siteSaveState: SiteSaveState;
|
||||
canLainMove: boolean;
|
||||
}
|
||||
|
||||
export type SsknSceneContext = {
|
||||
|
|
16
src/utils/getKeyPress.ts
Normal file
16
src/utils/getKeyPress.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
const getKeyPress = (keyCode: string) => {
|
||||
const keyCodeAssocs = {
|
||||
ArrowDown: "DOWN", // down arrow
|
||||
ArrowLeft: "LEFT", // left arrow
|
||||
ArrowUp: "UP", // up arrow
|
||||
ArrowRight: "RIGHT", // right arrow
|
||||
x: "CIRCLE", // x key
|
||||
z: "X", // z key
|
||||
d: "TRIANGLE", // d key
|
||||
e: "L2", // e key
|
||||
v: "START", // v key
|
||||
};
|
||||
return keyCodeAssocs[keyCode as keyof typeof keyCodeAssocs];
|
||||
};
|
||||
|
||||
export default getKeyPress;
|
|
@ -1,15 +0,0 @@
|
|||
export const getKeyCodeAssociation = (keyCode: number) => {
|
||||
const keyCodeAssocs = {
|
||||
40: "DOWN", // down arrow
|
||||
37: "LEFT", // left arrow
|
||||
38: "UP", // up arrow
|
||||
39: "RIGHT", // right arrow
|
||||
88: "CIRCLE", // x key
|
||||
90: "X", // z key
|
||||
68: "TRIANGLE", // d key
|
||||
69: "L2", // e key
|
||||
86: "START", // v key
|
||||
32: "SPACE",
|
||||
};
|
||||
return keyCodeAssocs[keyCode as keyof typeof keyCodeAssocs];
|
||||
};
|
Loading…
Reference in a new issue