Compare commits
12 Commits
3f1e099f45
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f922fd53ae | |||
| ce3f254bc1 | |||
| 7cc63dbba9 | |||
| 852e1ec963 | |||
| 930ed00c95 | |||
| adf4e4b9bf | |||
| e14b97b5e7 | |||
| 6363396f84 | |||
| 2633be60a1 | |||
| aefe01860e | |||
| 17b2ef4267 | |||
| b7e527ac6c |
@@ -21,7 +21,8 @@
|
||||
"cannon-es-debugger": "^1.0.0",
|
||||
"howler": "^2.2.4",
|
||||
"nipplejs": "^1.0.3",
|
||||
"three": "^0.184.0"
|
||||
"three": "^0.184.0",
|
||||
"three.quarks": "^0.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/howler": "^2.2.13",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ConfigUiParamsCategories } from "@24tools/ads_common";
|
||||
|
||||
export const characterSettings: ConfigUiParamsCategories[] = [
|
||||
{
|
||||
id: "character",
|
||||
name: "Character settings",
|
||||
params: [
|
||||
{
|
||||
id: "movement_speed",
|
||||
name: "Movement speed",
|
||||
type: "int",
|
||||
values: [
|
||||
0,
|
||||
10,
|
||||
3
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -5,10 +5,12 @@ import { analytics } from "./analytics";
|
||||
//@ts-ignore
|
||||
import { installBanner } from "./installBanner";
|
||||
import { globalSettings } from "./globalSettings";
|
||||
import { characterSettings } from "./characterSettings";
|
||||
|
||||
export const configUIParams: ConfigUiParamsCategories[][] = [
|
||||
analytics,
|
||||
installBanner,
|
||||
globalSettings,
|
||||
characterSettings,
|
||||
sounds,
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ConfigUiParamsCategories } from "@24tools/ads_common";
|
||||
export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
{
|
||||
id: "global",
|
||||
name: "Scene settings",
|
||||
name: "Global settings",
|
||||
params: [
|
||||
{
|
||||
id: "light_intensity",
|
||||
@@ -30,7 +30,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
values: [
|
||||
30,
|
||||
150,
|
||||
60
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -38,22 +38,21 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
name: "Camera position (portrait)",
|
||||
type: "float",
|
||||
array: true,
|
||||
visible: "position",
|
||||
values: [
|
||||
[
|
||||
-10,
|
||||
10,
|
||||
0
|
||||
-4
|
||||
],
|
||||
[
|
||||
0,
|
||||
15,
|
||||
11
|
||||
],
|
||||
[
|
||||
-10,
|
||||
10,
|
||||
1
|
||||
],
|
||||
[
|
||||
-10,
|
||||
10,
|
||||
5
|
||||
-7
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -67,7 +66,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
[
|
||||
-360,
|
||||
360,
|
||||
0
|
||||
2
|
||||
],
|
||||
[
|
||||
-360,
|
||||
@@ -88,7 +87,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
values: [
|
||||
30,
|
||||
150,
|
||||
60
|
||||
30
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -96,22 +95,21 @@ export const globalSettings: ConfigUiParamsCategories[] = [
|
||||
name: "Camera position (landscape)",
|
||||
type: "float",
|
||||
array: true,
|
||||
visible: "position",
|
||||
values: [
|
||||
[
|
||||
-10,
|
||||
10,
|
||||
0
|
||||
-4
|
||||
],
|
||||
[
|
||||
0,
|
||||
15,
|
||||
11
|
||||
],
|
||||
[
|
||||
-10,
|
||||
10,
|
||||
1
|
||||
],
|
||||
[
|
||||
-10,
|
||||
10,
|
||||
5
|
||||
-7
|
||||
]
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5,16 +5,12 @@ export class CameraC extends CameraC_internal {
|
||||
static setCamera(portraitOrientation: boolean) {
|
||||
const CATEGORY = Template.getCategory("global");
|
||||
if (this.camera !== null) {
|
||||
let position = portraitOrientation
|
||||
? Helper.returnVectorCamera(CATEGORY["camera_position_p"] as number[])
|
||||
: Helper.returnVectorCamera(CATEGORY["camera_position_l"] as number[]);
|
||||
const rotation = portraitOrientation
|
||||
? Helper.returnEulerCamera(CATEGORY["camera_rotation_p"] as number[])
|
||||
: Helper.returnEulerCamera(CATEGORY["camera_rotation_l"] as number[]);
|
||||
this.camera.rotation.x = rotation.x;
|
||||
this.camera.rotation.y = rotation.y;
|
||||
this.camera.rotation.z = rotation.z;
|
||||
this.camera.position.copy(position.clone());
|
||||
if (this.camera instanceof PerspectiveCamera) {
|
||||
this.camera.fov = portraitOrientation
|
||||
? Number(CATEGORY["camera_fov_p"])
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Delegate, Helper, Template, UpdateController } from "@24tools/playable_template";
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { ThreeC } from "../core/ThreeC";
|
||||
import { CameraC } from "./CameraC";
|
||||
|
||||
export class FollowCameraC {
|
||||
private static updateDelegate: Delegate<number>;
|
||||
static target: Object3D;
|
||||
static offset: Vector3;
|
||||
|
||||
static mainContainer: Object3D = new Object3D();
|
||||
static cameraContainer: Object3D = new Object3D();
|
||||
static cameraRotation: Object3D = new Object3D();
|
||||
|
||||
static inputDirection: Vector3 = new Vector3();
|
||||
static lookAheadAmount = 1;
|
||||
static lookAheadLerpSpeed = 0.7;
|
||||
|
||||
private static lookAheadCurrent: Vector3 = new Vector3();
|
||||
private static lookAheadTarget: Vector3 = new Vector3();
|
||||
private static basePos: Vector3 = new Vector3();
|
||||
private static targetPos: Vector3 = new Vector3();
|
||||
private static oldPos: Vector3 = new Vector3();
|
||||
|
||||
static init(target: Object3D) {
|
||||
this.target = target;
|
||||
this.offset = this.Offset;
|
||||
|
||||
this.updateDelegate = new Delegate<number>(this.update.bind(this));
|
||||
UpdateController.Instance.onUpdate.addListener(this.updateDelegate);
|
||||
|
||||
ThreeC.addToScene(this.mainContainer);
|
||||
this.mainContainer.add(this.cameraContainer);
|
||||
this.cameraContainer.add(this.cameraRotation);
|
||||
this.cameraRotation.add(CameraC.cameraContainer);
|
||||
|
||||
this.cameraRotation.rotateY(Math.PI);
|
||||
|
||||
this.mainContainer.position.copy(target.position);
|
||||
this.mainContainer.position.x += this.Offset.x;
|
||||
this.mainContainer.position.z += this.Offset.z;
|
||||
|
||||
this.cameraContainer.position.y = this.Offset.y;
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
if (!this.target.position) return;
|
||||
|
||||
this.lookAheadTarget.copy(this.inputDirection).multiplyScalar(this.lookAheadAmount);
|
||||
this.lookAheadCurrent.lerp(
|
||||
this.lookAheadTarget,
|
||||
Math.min(delta * this.lookAheadLerpSpeed, 1),
|
||||
);
|
||||
|
||||
const offset = this.Offset;
|
||||
|
||||
this.basePos.copy(this.target.position);
|
||||
this.basePos.x += offset.x;
|
||||
this.basePos.z += offset.z;
|
||||
|
||||
this.cameraContainer.position.y = offset.y;
|
||||
|
||||
this.targetPos.copy(this.basePos);
|
||||
this.targetPos.x += this.lookAheadCurrent.x;
|
||||
this.targetPos.z += this.lookAheadCurrent.z;
|
||||
|
||||
this.oldPos.copy(this.mainContainer.position);
|
||||
|
||||
this.mainContainer.position.copy(this.basePos);
|
||||
this.mainContainer.lookAt(this.target.position);
|
||||
this.cameraContainer.lookAt(this.target.position);
|
||||
|
||||
const lerpSpeed = 10;
|
||||
this.mainContainer.position.lerpVectors(
|
||||
this.oldPos,
|
||||
this.targetPos,
|
||||
Math.min(delta * lerpSpeed, 1),
|
||||
);
|
||||
}
|
||||
|
||||
static get RotationCorrection() {
|
||||
return this.mainContainer.rotation.clone();
|
||||
}
|
||||
|
||||
static get Offset() {
|
||||
const portrait = window.screenSize.portrait;
|
||||
const values = portrait
|
||||
? Template.getValue<number[]>("global", "camera_position_p")
|
||||
: Template.getValue<number[]>("global", "camera_position_l");
|
||||
return Helper.returnVectorCamera(values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum BaseAnimation {
|
||||
|
||||
Nan = -1,
|
||||
|
||||
Idle = 0,
|
||||
|
||||
Run = 1,
|
||||
|
||||
WeaponTakeOut = 2,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum MeshType {
|
||||
Character = "character",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum PropType {
|
||||
Box = "box",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum ResourceType {
|
||||
Wood = "wood",
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ResourcesType {
|
||||
Mesh = "mesh",
|
||||
VFX = "vfx",
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum VFXType {
|
||||
LootableHit = "lootable_hit",
|
||||
LootableDestroy = "lootable_destroy",
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { EasyEvent, UpdateController } from "@24tools/playable_template";
|
||||
import { Box3, Object3D, Vector3 } from "three";
|
||||
import { Player } from "../Player/Player";
|
||||
import {
|
||||
findInteractiveZoneFromMap,
|
||||
INTERACTIVE_ZONE_NAME,
|
||||
isInsideInteractiveZoneXZ,
|
||||
refreshInteractiveZoneBounds,
|
||||
} from "./MapInteractiveZone";
|
||||
|
||||
export class DepositZoneC {
|
||||
static readonly onPlayerEnter = new EasyEvent<{}>();
|
||||
static readonly onPlayerExit = new EasyEvent<{}>();
|
||||
|
||||
private static inited = false;
|
||||
private static zone: Object3D | null = null;
|
||||
private static bounds = new Box3();
|
||||
private static playerInside = false;
|
||||
private static playerPosition = new Vector3();
|
||||
|
||||
static init(mapObject: Object3D) {
|
||||
if (this.inited) return;
|
||||
|
||||
this.zone = findInteractiveZoneFromMap(mapObject);
|
||||
if (!this.zone) {
|
||||
console.warn(`Deposit zone not found: ${INTERACTIVE_ZONE_NAME}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.inited = true;
|
||||
refreshInteractiveZoneBounds(this.zone, this.bounds);
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
|
||||
static get isPlayerInside() {
|
||||
return this.playerInside;
|
||||
}
|
||||
|
||||
private static update() {
|
||||
// Межі рахуються один раз у init(): зона статична, а пульсація її масштабу
|
||||
// (під час депозиту) не повинна впливати на детекцію входу.
|
||||
this.getPlayerPosition(this.playerPosition);
|
||||
this.updatePlayerInside(this.playerPosition);
|
||||
}
|
||||
|
||||
private static getPlayerPosition(out: Vector3) {
|
||||
const body = Player.physics?.getPhysicsBody();
|
||||
if (body) {
|
||||
return out.set(body.position.x, body.position.y, body.position.z);
|
||||
}
|
||||
|
||||
return Player.getWorldPosition().copy(out);
|
||||
}
|
||||
|
||||
private static updatePlayerInside(playerPosition: Vector3) {
|
||||
const inside = isInsideInteractiveZoneXZ(this.bounds, playerPosition);
|
||||
if (inside === this.playerInside) return;
|
||||
|
||||
this.playerInside = inside;
|
||||
if (inside) {
|
||||
// console.log(`[DepositZoneC] enter deposit zone: ${INTERACTIVE_ZONE_NAME}`);
|
||||
this.onPlayerEnter.Invoke({});
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log(`[DepositZoneC] exit deposit zone: ${INTERACTIVE_ZONE_NAME}`);
|
||||
this.onPlayerExit.Invoke({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { EasyEvent, JoystickC, UpdateController } from "@24tools/playable_template";
|
||||
import { Vector3 } from "three";
|
||||
import { PhysicsTriggerC } from "./PhysicsTriggerC";
|
||||
import { PropC, PropRegistry } from "../Props/PropC";
|
||||
import { Player } from "../Player/Player";
|
||||
|
||||
export class GatherC {
|
||||
static readonly onCombatIdle = new EasyEvent<{}>();
|
||||
|
||||
private static activeProps = new Set<PropC>();
|
||||
private static pendingAutoAttack = false;
|
||||
private static combatIdleNotified = false;
|
||||
|
||||
static init() {
|
||||
PropC.onBroken.addDelegate(() => this.onPropDestroyed());
|
||||
|
||||
PhysicsTriggerC.onTriggerEnter.addDelegate((payload) => {
|
||||
const prop = PropRegistry.get(payload.lootableObject);
|
||||
if (!prop || prop.isBroken) return;
|
||||
|
||||
// console.log(`[GatherC] enter gather zone: ${payload.lootableObject.name}`);
|
||||
this.activeProps.add(prop);
|
||||
this.scheduleAutoAttackCheck();
|
||||
});
|
||||
|
||||
PhysicsTriggerC.onTriggerExit.addDelegate((payload) => {
|
||||
// console.log(`[GatherC] exit gather zone: ${payload.lootableObject.name}`);
|
||||
|
||||
const prop = PropRegistry.get(payload.lootableObject);
|
||||
if (prop) this.activeProps.delete(prop);
|
||||
|
||||
this.removeBrokenProps();
|
||||
this.resyncActivePropsFromPhysics();
|
||||
this.handleRemainingTargets();
|
||||
});
|
||||
|
||||
JoystickC.onJoysticEnd.addDelegate(() => {
|
||||
// Joystick release is a fresh signal (unlike the 1-frame-stale movement
|
||||
// vector), so committing here lets RunState skip the Idle frame before Loot.
|
||||
if (this.getAttackableProps().length > 0) Player.markCombatPending(true);
|
||||
this.scheduleAutoAttackCheck();
|
||||
});
|
||||
|
||||
JoystickC.onJoysticMove.addDelegate(() => {
|
||||
if (!Player.isMoving()) {
|
||||
this.scheduleAutoAttackCheck();
|
||||
}
|
||||
});
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
if (this.pendingAutoAttack) {
|
||||
this.pendingAutoAttack = false;
|
||||
this.tryStartAutoAttack();
|
||||
return;
|
||||
}
|
||||
|
||||
this.tryResumeAutoAttack();
|
||||
this.notifyCombatIdleIfNeeded();
|
||||
});
|
||||
}
|
||||
|
||||
private static onPropDestroyed() {
|
||||
this.removeBrokenProps();
|
||||
this.resyncActivePropsFromPhysics();
|
||||
this.handleRemainingTargets();
|
||||
}
|
||||
|
||||
private static scheduleAutoAttackCheck() {
|
||||
this.pendingAutoAttack = true;
|
||||
}
|
||||
|
||||
private static onAttackStrike() {
|
||||
for (const prop of this.getAttackableProps()) {
|
||||
prop.takeDamage(1);
|
||||
}
|
||||
|
||||
this.removeBrokenProps();
|
||||
this.resyncActivePropsFromPhysics();
|
||||
this.handleRemainingTargets();
|
||||
}
|
||||
|
||||
private static tryResumeAutoAttack() {
|
||||
if (this.getAttackableProps().length === 0) return;
|
||||
if (Player.isAutoAttackActive() || Player.isCombatBusy()) return;
|
||||
if (Player.isMoving()) return;
|
||||
this.tryStartAutoAttack();
|
||||
}
|
||||
|
||||
private static handleRemainingTargets() {
|
||||
const props = this.getAttackableProps();
|
||||
if (props.length === 0) {
|
||||
// No target left -> release the pending-entry hold so RunState can fall
|
||||
// back to Idle normally instead of freezing the current animation.
|
||||
Player.markCombatPending(false);
|
||||
if (Player.isAutoAttackActive() || Player.isCombatBusy()) {
|
||||
Player.stopAutoAttack();
|
||||
}
|
||||
this.notifyCombatIdleIfNeeded();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Player.isMoving()) {
|
||||
this.scheduleAutoAttackCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPosition = this.getTargetPosition(props);
|
||||
|
||||
if (Player.isAutoAttackActive()) {
|
||||
Player.retargetAutoAttack(targetPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Player.isCombatBusy()) {
|
||||
this.scheduleAutoAttackCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
this.tryStartAutoAttack();
|
||||
}
|
||||
|
||||
private static tryStartAutoAttack() {
|
||||
const props = this.getAttackableProps();
|
||||
if (props.length === 0) return;
|
||||
if (Player.isMoving() || Player.isCombatBusy()) return;
|
||||
|
||||
const targetPosition = this.getTargetPosition(props);
|
||||
Player.startAutoAttack(
|
||||
() => this.onAttackStrike(),
|
||||
() => {},
|
||||
targetPosition,
|
||||
);
|
||||
}
|
||||
|
||||
private static resyncActivePropsFromPhysics() {
|
||||
this.activeProps.clear();
|
||||
|
||||
for (const lootableObject of PhysicsTriggerC.getActiveLootableObjects()) {
|
||||
const prop = PropRegistry.get(lootableObject);
|
||||
if (prop && !prop.isBroken) {
|
||||
this.activeProps.add(prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static getTargetPosition(props: PropC[]): Vector3 {
|
||||
const playerPosition = Player.getWorldPosition();
|
||||
const target = new Vector3();
|
||||
let closestDistanceSq = Infinity;
|
||||
|
||||
for (const prop of props) {
|
||||
const propPosition = prop.object.getWorldPosition(new Vector3());
|
||||
const distanceSq = playerPosition.distanceToSquared(propPosition);
|
||||
if (distanceSq >= closestDistanceSq) continue;
|
||||
|
||||
closestDistanceSq = distanceSq;
|
||||
target.copy(propPosition);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private static getAttackableProps() {
|
||||
return [...this.activeProps].filter((prop) => !prop.isBroken);
|
||||
}
|
||||
|
||||
private static removeBrokenProps() {
|
||||
for (const prop of this.activeProps) {
|
||||
if (prop.isBroken) this.activeProps.delete(prop);
|
||||
}
|
||||
}
|
||||
|
||||
private static notifyCombatIdleIfNeeded() {
|
||||
if (this.getAttackableProps().length > 0) {
|
||||
this.combatIdleNotified = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Player.isAutoAttackActive() || Player.isCombatBusy()) {
|
||||
this.combatIdleNotified = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.combatIdleNotified) return;
|
||||
|
||||
this.combatIdleNotified = true;
|
||||
this.onCombatIdle.Invoke({});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { UpdateController } from "@24tools/playable_template";
|
||||
import { Color, Material, Mesh, Object3D, Vector3 } from "three";
|
||||
import { ResourceScreenFly } from "../Resources/ResourceScreenFly";
|
||||
import {
|
||||
findInteractiveZoneFromMap,
|
||||
getInteractiveZoneCenter,
|
||||
INTERACTIVE_ZONE_NAME,
|
||||
} from "./MapInteractiveZone";
|
||||
|
||||
const FILL_TARGET = 10;
|
||||
const FILL_COLOR = new Color(0x2db83a);
|
||||
|
||||
/** Амплітуда «поглинаючого» імпульсу (частка від базового масштабу). 0.05 = 5%. */
|
||||
const PULSE_AMPLITUDE = 0.05;
|
||||
/** Тривалість одного імпульсу, сек: базовий розмір → пік → базовий. */
|
||||
const PULSE_DURATION = 0.18;
|
||||
/** Значення таймера, коли імпульс неактивний. */
|
||||
const PULSE_IDLE = Infinity;
|
||||
|
||||
export class InteractiveZoneC {
|
||||
private static zoneRoot: Object3D | null = null;
|
||||
private static deposited = 0;
|
||||
private static baseColors = new WeakMap<Material, Color>();
|
||||
private static baseScale = new Vector3(1, 1, 1);
|
||||
private static pulseTime = PULSE_IDLE;
|
||||
|
||||
static init(mapObject: Object3D) {
|
||||
const zone = findInteractiveZoneFromMap(mapObject);
|
||||
if (!zone) {
|
||||
console.warn(`Interactive zone not found: ${INTERACTIVE_ZONE_NAME}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.zoneRoot = zone;
|
||||
this.baseScale.copy(zone.scale);
|
||||
this.setupMaterials(zone);
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta: number) => {
|
||||
this.updatePulse(delta);
|
||||
});
|
||||
}
|
||||
|
||||
/** Один «поглинаючий» імпульс зони — викликати на приліт кожного ресурсу. */
|
||||
static pulse() {
|
||||
this.pulseTime = 0;
|
||||
}
|
||||
|
||||
/** Повертає зону до базового розміру і гасить імпульс (teardown/зупинка). */
|
||||
static resetPulse() {
|
||||
this.pulseTime = PULSE_IDLE;
|
||||
if (this.zoneRoot) this.zoneRoot.scale.copy(this.baseScale);
|
||||
}
|
||||
|
||||
private static updatePulse(delta: number) {
|
||||
if (!this.zoneRoot || this.pulseTime === PULSE_IDLE) return;
|
||||
|
||||
this.pulseTime += delta;
|
||||
if (this.pulseTime >= PULSE_DURATION) {
|
||||
this.resetPulse();
|
||||
return;
|
||||
}
|
||||
|
||||
const env = Math.sin(Math.PI * (this.pulseTime / PULSE_DURATION));
|
||||
const k = 1 + PULSE_AMPLITUDE * env;
|
||||
this.zoneRoot.scale.set(
|
||||
this.baseScale.x * k,
|
||||
this.baseScale.y * k,
|
||||
this.baseScale.z * k,
|
||||
);
|
||||
}
|
||||
|
||||
static getWorldCenter(out = new Vector3()) {
|
||||
if (!this.zoneRoot) return out.set(0, 0, 0);
|
||||
return getInteractiveZoneCenter(this.zoneRoot, out);
|
||||
}
|
||||
|
||||
static getScreenCenter() {
|
||||
return ResourceScreenFly.worldToScreen(this.getWorldCenter());
|
||||
}
|
||||
|
||||
static addDeposit(amount = 1) {
|
||||
this.deposited += amount;
|
||||
this.refreshFillVisual();
|
||||
}
|
||||
|
||||
/** Звільняє клоновані матеріали зони (виклик на teardown playable). */
|
||||
static dispose() {
|
||||
if (!this.zoneRoot) return;
|
||||
|
||||
this.resetPulse();
|
||||
|
||||
this.zoneRoot.traverse((child) => {
|
||||
const mesh = child as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
|
||||
const material = mesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((entry) => entry.dispose());
|
||||
} else if (material) {
|
||||
material.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
this.baseColors = new WeakMap<Material, Color>();
|
||||
}
|
||||
|
||||
static getFillRatio() {
|
||||
return Math.min(this.deposited / FILL_TARGET, 1);
|
||||
}
|
||||
|
||||
private static setupMaterials(zone: Object3D) {
|
||||
zone.traverse((child) => {
|
||||
if (!(child as Mesh).isMesh) return;
|
||||
|
||||
const mesh = child as Mesh;
|
||||
const sourceMaterial = mesh.material;
|
||||
if (Array.isArray(sourceMaterial)) return;
|
||||
|
||||
if (!sourceMaterial) return;
|
||||
|
||||
const cloned = sourceMaterial.clone();
|
||||
if ("color" in cloned) {
|
||||
this.baseColors.set(cloned, (cloned.color as Color).clone());
|
||||
}
|
||||
cloned.depthWrite = true;
|
||||
cloned.polygonOffset = true;
|
||||
cloned.polygonOffsetFactor = 2;
|
||||
cloned.polygonOffsetUnits = 2;
|
||||
mesh.material = cloned;
|
||||
mesh.renderOrder = 0;
|
||||
});
|
||||
|
||||
this.refreshFillVisual();
|
||||
}
|
||||
|
||||
private static refreshFillVisual() {
|
||||
if (!this.zoneRoot) return;
|
||||
|
||||
const ratio = this.getFillRatio();
|
||||
|
||||
this.zoneRoot.traverse((child) => {
|
||||
if (!(child as Mesh).isMesh) return;
|
||||
|
||||
const material = (child as Mesh).material;
|
||||
if (Array.isArray(material) || !material) return;
|
||||
|
||||
const baseColor = this.baseColors.get(material);
|
||||
if (!baseColor || !("color" in material)) return;
|
||||
|
||||
(material.color as Color).copy(baseColor).lerp(FILL_COLOR, ratio);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Box3, BoxGeometry, Mesh, Object3D, Vector3 } from "three";
|
||||
import { ThreeC } from "../core/ThreeC";
|
||||
import { PhysicsBody, PhysicsLayer } from "../core/PhysicsC";
|
||||
import { PhysicsTriggerC } from "./PhysicsTriggerC";
|
||||
import { PropC } from "../Props/PropC";
|
||||
import { findDamageStateLayers } from "../Props/PropDamageLayers";
|
||||
import { resolvePropType } from "../Props/PropDropTable";
|
||||
|
||||
import { InteractiveZoneC } from "./InteractiveZoneC";
|
||||
import { ToolZoneC } from "./ToolZoneC";
|
||||
|
||||
const MAP_PHYSICS_LAYERS = ["Colliders", "Lootable"];
|
||||
const GATHER_TRIGGER_PADDING = 0.15;
|
||||
|
||||
export class Map {
|
||||
static init() {
|
||||
const mapObject = ThreeC.getObject("map");
|
||||
if (!mapObject) {
|
||||
console.warn("Map model resource not found: map");
|
||||
return;
|
||||
}
|
||||
|
||||
mapObject.position.set(0, 0, 0);
|
||||
ThreeC.setShadowsStateForChildren(mapObject, true, true);
|
||||
ThreeC.addToScene(mapObject);
|
||||
this.hideReferenceHpUi(mapObject);
|
||||
|
||||
PhysicsTriggerC.init();
|
||||
this.buildPhysics(mapObject);
|
||||
InteractiveZoneC.init(mapObject);
|
||||
ToolZoneC.init(mapObject);
|
||||
}
|
||||
|
||||
private static hideReferenceHpUi(mapObject: Object3D) {
|
||||
const ui = mapObject.getObjectByName("UI");
|
||||
if (ui) ui.visible = false;
|
||||
|
||||
const woodUi = mapObject.getObjectByName("UI_Wood001");
|
||||
if (woodUi) woodUi.visible = false;
|
||||
}
|
||||
|
||||
private static buildPhysics(mapObject: Object3D) {
|
||||
for (const layerName of MAP_PHYSICS_LAYERS) {
|
||||
const layer = mapObject.getObjectByName(layerName);
|
||||
if (!layer) {
|
||||
console.warn(`Map physics layer not found: ${layerName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (layerName === "Colliders") {
|
||||
// Floor-only layer (BoxCollider): hide in scene, no physics needed.
|
||||
layer.visible = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
layer.children.forEach((lootableObject) => {
|
||||
this.setupLootableObject(lootableObject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static setupLootableObject(lootableObject: Object3D) {
|
||||
const propType = resolvePropType(lootableObject.name);
|
||||
const damageLayers = findDamageStateLayers(lootableObject);
|
||||
const maxHealth = damageLayers.length > 0 ? damageLayers.length : 1;
|
||||
const prop = new PropC(lootableObject, propType, maxHealth);
|
||||
prop.initDamageLayers(damageLayers);
|
||||
const colliderMesh = this.findMesh(lootableObject, (name) => name.includes("collider"));
|
||||
const visualMeshes = this.getVisualMeshes(lootableObject);
|
||||
|
||||
if (colliderMesh) {
|
||||
colliderMesh.visible = false;
|
||||
prop.addWallBody(
|
||||
new PhysicsBody(colliderMesh, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player),
|
||||
);
|
||||
} else {
|
||||
visualMeshes.forEach((mesh) => {
|
||||
prop.addWallBody(
|
||||
new PhysicsBody(mesh, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
PhysicsTriggerC.register(this.createGatherTrigger(lootableObject), lootableObject);
|
||||
}
|
||||
|
||||
private static findMesh(
|
||||
root: Object3D,
|
||||
matcher: (name: string) => boolean,
|
||||
): Mesh | null {
|
||||
let found: Mesh | null = null;
|
||||
|
||||
root.traverse((child) => {
|
||||
if (found) return;
|
||||
if ((child as Mesh).isMesh && matcher(child.name.toLowerCase())) {
|
||||
found = child as Mesh;
|
||||
}
|
||||
});
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private static getVisualMeshes(root: Object3D): Mesh[] {
|
||||
const visualMeshes: Mesh[] = [];
|
||||
|
||||
root.traverse((child) => {
|
||||
if (!(child as Mesh).isMesh) return;
|
||||
|
||||
const name = child.name.toLowerCase();
|
||||
if (name.includes("collider") || name.includes("trigger") || name.includes("gathertrigger")) return;
|
||||
|
||||
visualMeshes.push(child as Mesh);
|
||||
});
|
||||
|
||||
return visualMeshes;
|
||||
}
|
||||
|
||||
private static createGatherTrigger(lootableObject: Object3D): Mesh {
|
||||
lootableObject.updateWorldMatrix(true, true);
|
||||
|
||||
const size = new Vector3();
|
||||
new Box3().setFromObject(lootableObject).getSize(size);
|
||||
|
||||
const triggerMesh = new Mesh(
|
||||
new BoxGeometry(
|
||||
size.x + GATHER_TRIGGER_PADDING * 2,
|
||||
size.y + GATHER_TRIGGER_PADDING * 2,
|
||||
size.z + GATHER_TRIGGER_PADDING * 2,
|
||||
),
|
||||
);
|
||||
triggerMesh.visible = false;
|
||||
triggerMesh.name = "GatherTrigger";
|
||||
lootableObject.add(triggerMesh);
|
||||
|
||||
return triggerMesh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Box3, Object3D, Vector3 } from "three";
|
||||
|
||||
export const INTERACTIVE_ZONE_NAME = "UI_Interactive_Zone_02";
|
||||
const ZONE_XZ_PADDING = 0.35;
|
||||
|
||||
export function findInteractiveZone(root: Object3D): Object3D | null {
|
||||
let found: Object3D | null = null;
|
||||
|
||||
root.traverse((child) => {
|
||||
if (!found && child.name === INTERACTIVE_ZONE_NAME) {
|
||||
found = child;
|
||||
}
|
||||
});
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
export function findInteractiveZoneFromMap(mapObject: Object3D): Object3D | null {
|
||||
let sceneRoot: Object3D = mapObject;
|
||||
while (sceneRoot.parent) {
|
||||
sceneRoot = sceneRoot.parent;
|
||||
}
|
||||
|
||||
return findInteractiveZone(sceneRoot);
|
||||
}
|
||||
|
||||
export function refreshInteractiveZoneBounds(zone: Object3D, out = new Box3()) {
|
||||
zone.updateWorldMatrix(true, true);
|
||||
out.setFromObject(zone);
|
||||
out.min.x -= ZONE_XZ_PADDING;
|
||||
out.max.x += ZONE_XZ_PADDING;
|
||||
out.min.z -= ZONE_XZ_PADDING;
|
||||
out.max.z += ZONE_XZ_PADDING;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isInsideInteractiveZoneXZ(bounds: Box3, position: Vector3) {
|
||||
return position.x >= bounds.min.x
|
||||
&& position.x <= bounds.max.x
|
||||
&& position.z >= bounds.min.z
|
||||
&& position.z <= bounds.max.z;
|
||||
}
|
||||
|
||||
export function getInteractiveZoneCenter(zone: Object3D, out = new Vector3()) {
|
||||
const bounds = refreshInteractiveZoneBounds(zone);
|
||||
return bounds.getCenter(out);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
EasyEvent,
|
||||
UpdateController,
|
||||
} from "@24tools/playable_template";
|
||||
import { Box3, Object3D, Vector3 } from "three";
|
||||
import { Body } from "cannon-es";
|
||||
import { PLAYER_COLLIDER_RADIUS } from "../core/PhysicsC";
|
||||
|
||||
const PLAYER_RADIUS = PLAYER_COLLIDER_RADIUS;
|
||||
|
||||
export type TriggerEventPayload = {
|
||||
lootableObject: Object3D;
|
||||
triggerObject: Object3D;
|
||||
playerBody: Body;
|
||||
};
|
||||
|
||||
type TriggerRecord = {
|
||||
lootableObject: Object3D;
|
||||
triggerObject: Object3D;
|
||||
center: Vector3;
|
||||
radius: number;
|
||||
};
|
||||
|
||||
export class PhysicsTriggerC {
|
||||
private static inited = false;
|
||||
private static triggers: TriggerRecord[] = [];
|
||||
private static activeTriggers = new Set<TriggerRecord>();
|
||||
private static playerBody: Body | null = null;
|
||||
|
||||
static onTriggerEnter = new EasyEvent<TriggerEventPayload>();
|
||||
static onTriggerExit = new EasyEvent<TriggerEventPayload>();
|
||||
|
||||
static init() {
|
||||
if (this.inited) return;
|
||||
this.inited = true;
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => this.update());
|
||||
}
|
||||
|
||||
static setPlayerBody(body: Body) {
|
||||
this.playerBody = body;
|
||||
}
|
||||
|
||||
static register(triggerObject: Object3D, lootableObject: Object3D) {
|
||||
triggerObject.updateWorldMatrix(true, true);
|
||||
|
||||
const center = new Vector3();
|
||||
const size = new Vector3();
|
||||
const bounds = new Box3().setFromObject(triggerObject);
|
||||
bounds.getCenter(center);
|
||||
bounds.getSize(size);
|
||||
|
||||
this.triggers.push({
|
||||
lootableObject,
|
||||
triggerObject,
|
||||
center,
|
||||
radius: Math.max(size.x, size.z) / 2,
|
||||
});
|
||||
}
|
||||
|
||||
static getActiveLootableObjects(): Object3D[] {
|
||||
const lootables: Object3D[] = [];
|
||||
for (const record of this.activeTriggers) {
|
||||
lootables.push(record.lootableObject);
|
||||
}
|
||||
return lootables;
|
||||
}
|
||||
|
||||
static unregister(lootableObject: Object3D) {
|
||||
const playerBody = this.playerBody;
|
||||
|
||||
this.triggers = this.triggers.filter((record) => {
|
||||
if (record.lootableObject !== lootableObject) return true;
|
||||
|
||||
if (this.activeTriggers.has(record) && playerBody) {
|
||||
this.onTriggerExit.Invoke({
|
||||
lootableObject: record.lootableObject,
|
||||
triggerObject: record.triggerObject,
|
||||
playerBody,
|
||||
});
|
||||
}
|
||||
|
||||
this.activeTriggers.delete(record);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private static update() {
|
||||
const playerBody = this.playerBody;
|
||||
if (!playerBody) return;
|
||||
|
||||
for (const trigger of this.triggers) {
|
||||
const dx = playerBody.position.x - trigger.center.x;
|
||||
const dz = playerBody.position.z - trigger.center.z;
|
||||
const distanceSq = dx * dx + dz * dz;
|
||||
const isInside = distanceSq <= (trigger.radius + PLAYER_RADIUS) ** 2;
|
||||
const wasInside = this.activeTriggers.has(trigger);
|
||||
|
||||
if (isInside === wasInside) continue;
|
||||
|
||||
const payload: TriggerEventPayload = {
|
||||
lootableObject: trigger.lootableObject,
|
||||
triggerObject: trigger.triggerObject,
|
||||
playerBody,
|
||||
};
|
||||
|
||||
if (isInside) {
|
||||
this.activeTriggers.add(trigger);
|
||||
this.onTriggerEnter.Invoke(payload);
|
||||
} else {
|
||||
this.activeTriggers.delete(trigger);
|
||||
this.onTriggerExit.Invoke(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { ResourceScreenFly, ScreenPoint } from "../Resources/ResourceScreenFly";
|
||||
|
||||
const TOOL_ZONE_NAME = "UI_Tool_Zone";
|
||||
|
||||
export class ToolZoneC {
|
||||
private static zone: Object3D | null = null;
|
||||
/** Світова позиція зони статична (рухається лише камера), тож кешуємо її один раз. */
|
||||
private static worldPos = new Vector3();
|
||||
|
||||
static init(mapObject: Object3D) {
|
||||
this.zone = this.findZone(mapObject);
|
||||
if (!this.zone) {
|
||||
console.warn(`Tool zone not found: ${TOOL_ZONE_NAME}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.zone.updateWorldMatrix(true, false);
|
||||
this.zone.getWorldPosition(this.worldPos);
|
||||
}
|
||||
|
||||
/** Жива екранна точка зони: світова позиція стала, проєкція оновлюється під рух камери. */
|
||||
static getScreenPoint(): ScreenPoint {
|
||||
return ResourceScreenFly.worldToScreen(this.worldPos);
|
||||
}
|
||||
|
||||
static dispose() {
|
||||
this.zone = null;
|
||||
}
|
||||
|
||||
private static findZone(mapObject: Object3D): Object3D | null {
|
||||
let sceneRoot: Object3D = mapObject;
|
||||
while (sceneRoot.parent) {
|
||||
sceneRoot = sceneRoot.parent;
|
||||
}
|
||||
|
||||
let found: Object3D | null = null;
|
||||
sceneRoot.traverse((child) => {
|
||||
if (!found && child.name === TOOL_ZONE_NAME) {
|
||||
found = child;
|
||||
}
|
||||
});
|
||||
|
||||
return found;
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import {
|
||||
Delegate,
|
||||
Physics_internal,
|
||||
UpdateController,
|
||||
} from "@24tools/playable_template";
|
||||
import { Box3, Object3D, Vector3 } from "three";
|
||||
import { Body, Box, Quaternion, Sphere, Vec3 } from "cannon-es";
|
||||
|
||||
export enum PhysicsLayer {
|
||||
Player = 1,
|
||||
Wall = 2,
|
||||
Trigger = 4,
|
||||
Enemy = 8,
|
||||
}
|
||||
|
||||
export class PhysicsBody {
|
||||
private body: Body;
|
||||
private pair: PhysicsObjPair | null = null;
|
||||
|
||||
constructor(
|
||||
threeObj: Object3D,
|
||||
trigger: boolean,
|
||||
mass: number,
|
||||
col_group: PhysicsLayer,
|
||||
col_mask: PhysicsLayer,
|
||||
player_sphere: number = 0.3
|
||||
) {
|
||||
let isPlayer = col_group === PhysicsLayer.Player;
|
||||
|
||||
let oldQuaternion = threeObj.quaternion.clone();
|
||||
|
||||
let nullQuaternion = new Quaternion();
|
||||
threeObj.quaternion.copy(nullQuaternion);
|
||||
|
||||
let bbox = new Box3().setFromObject(threeObj);
|
||||
|
||||
let size = new Vector3();
|
||||
bbox.getSize(size);
|
||||
|
||||
// if you need custom size
|
||||
// if (col_group === PhysicsLayer.wall) {
|
||||
// size.x = size.z = 1;
|
||||
// size.y = 1;
|
||||
// }
|
||||
|
||||
threeObj.quaternion.copy(oldQuaternion);
|
||||
|
||||
this.body = new Body({
|
||||
isTrigger: trigger,
|
||||
mass: mass,
|
||||
//shape: shape,
|
||||
shape: isPlayer
|
||||
? new Sphere(player_sphere)
|
||||
: new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2)),
|
||||
collisionFilterGroup: col_group,
|
||||
collisionFilterMask: col_mask,
|
||||
});
|
||||
|
||||
let worldPos = threeObj.getWorldPosition(new Vector3());
|
||||
|
||||
this.body.position.set(worldPos.x, worldPos.y, worldPos.z);
|
||||
|
||||
this.body.quaternion.setFromEuler(
|
||||
threeObj.rotation.x,
|
||||
threeObj.rotation.y,
|
||||
threeObj.rotation.z,
|
||||
"XYZ"
|
||||
);
|
||||
|
||||
// if you need sync three obj and physics body
|
||||
// if (isEnemy) {
|
||||
// let pair = new PhysicsObjPair(threeObj, this.body);
|
||||
// PhysicsC_Instance.addPhysicsPair(pair);
|
||||
|
||||
// this.pair = pair;
|
||||
// }
|
||||
|
||||
Physics_internal.physicsWorld &&
|
||||
Physics_internal.physicsWorld.addBody(this.body);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
disablePhysicsPair() {
|
||||
if (this.pair) {
|
||||
this.pair.destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
getPhysicsBody() {
|
||||
return this.body;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!Physics_internal.physicsWorld) return;
|
||||
|
||||
Physics_internal.physicsWorld.removeBody(this.body);
|
||||
|
||||
(this.body as any) = null;
|
||||
}
|
||||
}
|
||||
|
||||
export class PhysicsObjPair {
|
||||
threeObj: Object3D;
|
||||
physicsObj: Body;
|
||||
destroyed: boolean;
|
||||
delegateId: null | Delegate<number>;
|
||||
|
||||
constructor(threeObj: Object3D, physicsObj: Body) {
|
||||
this.threeObj = threeObj;
|
||||
this.physicsObj = physicsObj;
|
||||
this.destroyed = false;
|
||||
|
||||
this.delegateId = UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
this.update();
|
||||
});
|
||||
}
|
||||
|
||||
update() {
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (this.threeObj && this.physicsObj) {
|
||||
// @ts-ignore
|
||||
this.threeObj.position.copy(this.physicsObj.position);
|
||||
// @ts-ignore
|
||||
this.threeObj.quaternion.copy(this.physicsObj.quaternion);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { EasyEvent, UpdateController } from "@24tools/playable_template";
|
||||
import { AnimationAction, AnimationClip, AnimationMixer, LoopOnce, LoopRepeat, Object3D, Vector3 } from "three";
|
||||
import { clone } from "three/examples/jsm/utils/SkeletonUtils";
|
||||
import { ThreeC } from "../../core/ThreeC";
|
||||
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
|
||||
import { Easing, Group, Tween } from "@tweenjs/tween.js";
|
||||
|
||||
/** Duration of the bat pop-in / pop-out scale animation. */
|
||||
const WEAPON_SWAP_MS = 140;
|
||||
/** Near-zero scale used instead of exact 0 to keep normals/shadows valid. */
|
||||
const HIDDEN_SCALE = 0.001;
|
||||
|
||||
export class Character {
|
||||
tObj: Object3D;
|
||||
animMixer: AnimationMixer;
|
||||
animationList: AnimationClip[] = [];
|
||||
|
||||
curClipAction: null | AnimationAction = null;
|
||||
|
||||
onAnimLoop: EasyEvent<{}> = new EasyEvent<{}>();
|
||||
onAnimFinish: EasyEvent<{}> = new EasyEvent<{}>();
|
||||
|
||||
private weaponTweens = new Group();
|
||||
private tool1?: Object3D;
|
||||
private tool2?: Object3D;
|
||||
private tool1FullScale = new Vector3(1, 1, 1);
|
||||
private tool2FullScale = new Vector3(1, 1, 1);
|
||||
private tool1Tween: Tween<Vector3> | null = null;
|
||||
private tool2Tween: Tween<Vector3> | null = null;
|
||||
|
||||
constructor(prefab: GLTF, start_position = new Vector3()) {
|
||||
const tObj = clone(prefab.scene);
|
||||
|
||||
tObj.castShadow = true;
|
||||
const animMixer = new AnimationMixer(tObj);
|
||||
|
||||
animMixer.addEventListener("loop", () => {
|
||||
this.onAnimLoop.Invoke({});
|
||||
});
|
||||
animMixer.addEventListener("finished", () => {
|
||||
this.onAnimFinish.Invoke({});
|
||||
});
|
||||
|
||||
if (start_position) tObj.position.copy(start_position);
|
||||
|
||||
this.tObj = tObj;
|
||||
this.animMixer = animMixer;
|
||||
this.animationList = prefab.animations;
|
||||
|
||||
ThreeC.addAnimMixer(animMixer);
|
||||
|
||||
this.cacheWeapons();
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
this.weaponTweens.update(performance.now());
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private cacheWeapons() {
|
||||
this.tObj.traverse((child) => {
|
||||
if (child.name === "Tool_1") {
|
||||
this.tool1 = child;
|
||||
this.tool1FullScale.copy(child.scale);
|
||||
}
|
||||
if (child.name === "Tool_2") {
|
||||
this.tool2 = child;
|
||||
this.tool2FullScale.copy(child.scale);
|
||||
}
|
||||
if (child.name === "Bullet") {
|
||||
child.visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Resting state: bat sits on the back (Tool_2), hand tool (Tool_1) hidden.
|
||||
// Snapping here avoids a startup pop when setDefaultWeapons runs on init.
|
||||
if (this.tool1) {
|
||||
this.tool1.scale.set(HIDDEN_SCALE, HIDDEN_SCALE, HIDDEN_SCALE);
|
||||
this.tool1.visible = false;
|
||||
}
|
||||
if (this.tool2) {
|
||||
this.tool2.scale.copy(this.tool2FullScale);
|
||||
this.tool2.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
setObjectVisible(name: string, visible: boolean) {
|
||||
this.tObj.traverse((child) => {
|
||||
if (child.name === name) {
|
||||
child.visible = visible;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setDefaultWeapons() {
|
||||
this.setObjectVisible("Character_Pistol", false);
|
||||
// Bat goes back to the spine: shrink the hand tool, grow the back tool.
|
||||
this.swapTool(false);
|
||||
}
|
||||
|
||||
setBatEquipped(equipped: boolean) {
|
||||
this.setObjectVisible("Character_Pistol", false);
|
||||
this.swapTool(equipped);
|
||||
}
|
||||
|
||||
/** equipped=true -> bat appears in hands (Tool_1) and vanishes from back (Tool_2). */
|
||||
private swapTool(equipped: boolean) {
|
||||
this.tool1Tween = this.animateToolScale(
|
||||
this.tool1,
|
||||
this.tool1FullScale,
|
||||
equipped,
|
||||
this.tool1Tween,
|
||||
);
|
||||
this.tool2Tween = this.animateToolScale(
|
||||
this.tool2,
|
||||
this.tool2FullScale,
|
||||
!equipped,
|
||||
this.tool2Tween,
|
||||
);
|
||||
}
|
||||
|
||||
private animateToolScale(
|
||||
tool: Object3D | undefined,
|
||||
fullScale: Vector3,
|
||||
appear: boolean,
|
||||
activeTween: Tween<Vector3> | null,
|
||||
): Tween<Vector3> | null {
|
||||
if (!tool) return null;
|
||||
|
||||
// Interruption: kill any in-flight tween for this node before retargeting,
|
||||
// otherwise the scale can get stuck mid-animation on rapid combat toggles.
|
||||
if (activeTween) {
|
||||
this.weaponTweens.remove(activeTween);
|
||||
activeTween.stop();
|
||||
}
|
||||
|
||||
const target = appear
|
||||
? { x: fullScale.x, y: fullScale.y, z: fullScale.z }
|
||||
: { x: HIDDEN_SCALE, y: HIDDEN_SCALE, z: HIDDEN_SCALE };
|
||||
|
||||
if (appear) {
|
||||
tool.visible = true;
|
||||
}
|
||||
|
||||
const tween = new Tween(tool.scale, this.weaponTweens)
|
||||
.to(target, WEAPON_SWAP_MS)
|
||||
.easing(appear ? Easing.Quadratic.Out : Easing.Quadratic.In)
|
||||
.onComplete(() => {
|
||||
if (!appear) {
|
||||
tool.visible = false;
|
||||
}
|
||||
})
|
||||
.start(performance.now());
|
||||
|
||||
return tween;
|
||||
}
|
||||
|
||||
setCurrentTimeScale(scale: number) {
|
||||
if (this.curClipAction) {
|
||||
this.curClipAction.timeScale = scale;
|
||||
}
|
||||
}
|
||||
|
||||
isPlayingAnimation(anim_id: number) {
|
||||
if (!this.curClipAction || anim_id < 0 || anim_id >= this.animationList.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.curClipAction.getClip() === this.animationList[anim_id];
|
||||
}
|
||||
|
||||
playAnimation(anim_id: number, one_time = false, fade = 0.25, randomStart = false) {
|
||||
const oldClipAction = this.curClipAction;
|
||||
const clipAction = this.animMixer.clipAction(this.animationList[anim_id]);
|
||||
|
||||
if (one_time) {
|
||||
clipAction.clampWhenFinished = true;
|
||||
clipAction.setLoop(LoopOnce, 1);
|
||||
} else {
|
||||
clipAction.clampWhenFinished = false;
|
||||
clipAction.setLoop(LoopRepeat, Infinity);
|
||||
}
|
||||
clipAction.timeScale = 1;
|
||||
|
||||
if (oldClipAction) {
|
||||
oldClipAction.fadeOut(fade);
|
||||
}
|
||||
|
||||
clipAction.reset();
|
||||
if (randomStart) {
|
||||
clipAction.time = Math.random() * this.animationList[anim_id].duration;
|
||||
}
|
||||
clipAction.play();
|
||||
clipAction.fadeIn(fade);
|
||||
this.curClipAction = clipAction;
|
||||
}
|
||||
|
||||
/** Blends from the current pose instead of snapping the new clip to frame 0. */
|
||||
crossFadeToAnimation(anim_id: number, one_time = false, fade = 0.5) {
|
||||
const oldClipAction = this.curClipAction;
|
||||
const clipAction = this.animMixer.clipAction(this.animationList[anim_id]);
|
||||
|
||||
if (one_time) {
|
||||
clipAction.clampWhenFinished = true;
|
||||
clipAction.setLoop(LoopOnce, 1);
|
||||
} else {
|
||||
clipAction.clampWhenFinished = false;
|
||||
clipAction.setLoop(LoopRepeat, Infinity);
|
||||
}
|
||||
clipAction.timeScale = 1;
|
||||
|
||||
if (oldClipAction && oldClipAction !== clipAction) {
|
||||
clipAction.reset();
|
||||
clipAction.play();
|
||||
oldClipAction.crossFadeTo(clipAction, fade, false);
|
||||
} else {
|
||||
this.playAnimation(anim_id, one_time, fade);
|
||||
return;
|
||||
}
|
||||
|
||||
this.curClipAction = clipAction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Vector3 } from "three";
|
||||
|
||||
export interface IMoveInput {
|
||||
get CurrentDirection(): Vector3;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Delegate, JoystickC } from "@24tools/playable_template";
|
||||
import { Vector3 } from "three";
|
||||
import { IMoveInput } from "./MoveInput";
|
||||
import { FollowCameraC } from "../../Camera/FollowCamera";
|
||||
|
||||
export class PlayerInput implements IMoveInput {
|
||||
private static threshold = 0.25;
|
||||
protected currentDirection = new Vector3();
|
||||
private moveDelegate: Delegate<any>;
|
||||
private stopDelegate: Delegate<any>;
|
||||
private startDelegate: Delegate<any>;
|
||||
inputActive = false;
|
||||
|
||||
constructor() {
|
||||
this.moveDelegate = JoystickC.onJoysticMove.addDelegate(this.onTouchMove.bind(this));
|
||||
JoystickC.onJoysticDown.addListener(this.moveDelegate);
|
||||
this.stopDelegate = JoystickC.onJoysticEnd.addDelegate(this.onTouchUp.bind(this));
|
||||
this.startDelegate = JoystickC.onJoysticStart.addDelegate(this.onTouchDown.bind(this));
|
||||
}
|
||||
|
||||
get CurrentDirection() {
|
||||
return this.currentDirection;
|
||||
}
|
||||
|
||||
get IsActive() {
|
||||
return this.inputActive;
|
||||
}
|
||||
|
||||
public static initJoystick() {
|
||||
const screenSize = window.screenSize;
|
||||
const minSize = Math.min(screenSize.width, screenSize.height);
|
||||
const joystickSizeAspect = 0.2;
|
||||
const options = {
|
||||
zone: document.getElementById("joystick_zone") as HTMLElement,
|
||||
size: minSize * joystickSizeAspect,
|
||||
restJoystick: true,
|
||||
dynamicPage: true,
|
||||
catchDistance: minSize * joystickSizeAspect / 2,
|
||||
fadeTime: 200,
|
||||
};
|
||||
JoystickC.init(options);
|
||||
}
|
||||
|
||||
onTouchMove(payload: any) {
|
||||
this.getDirection(payload, this.currentDirection);
|
||||
if (this.currentDirection.length() <= PlayerInput.threshold) {
|
||||
this.currentDirection.multiplyScalar(0);
|
||||
}
|
||||
}
|
||||
|
||||
onTouchDown(_event: any) {
|
||||
if (this.inputActive) return;
|
||||
this.inputActive = true;
|
||||
}
|
||||
|
||||
onTouchUp() {
|
||||
if (!this.inputActive) return;
|
||||
this.inputActive = false;
|
||||
this.currentDirection.multiplyScalar(0);
|
||||
}
|
||||
|
||||
private getDirection(payload: any, out: Vector3) {
|
||||
const normalizedData =
|
||||
payload?.data ??
|
||||
payload?.event?.data ??
|
||||
payload?.event;
|
||||
const vector = normalizedData?.vector;
|
||||
if (!vector) {
|
||||
out.set(0, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
out.set(-vector.x, 0, vector.y);
|
||||
out.applyEuler(FollowCameraC.RotationCorrection);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IMoveInput } from "../Input/MoveInput";
|
||||
import { Vector3 } from "three";
|
||||
|
||||
export class MoveC {
|
||||
private readonly input: IMoveInput;
|
||||
private speed: number;
|
||||
private readonly moveDirection = new Vector3();
|
||||
|
||||
constructor(input: IMoveInput, speed = 5) {
|
||||
this.input = input;
|
||||
this.speed = speed;
|
||||
}
|
||||
|
||||
get Direction() {
|
||||
return this.moveDirection;
|
||||
}
|
||||
|
||||
get Weight() {
|
||||
return this.moveDirection.length() / this.speed;
|
||||
}
|
||||
|
||||
setSpeed(speed: number) {
|
||||
this.speed = speed;
|
||||
}
|
||||
|
||||
|
||||
update(_delta: number) {
|
||||
this.moveDirection.copy(this.input.CurrentDirection).multiplyScalar(this.speed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Object3D, Quaternion, Vector3 } from "three";
|
||||
|
||||
export class RotationC {
|
||||
private static readonly completeAngle = 0.01;
|
||||
|
||||
private readonly target: Object3D;
|
||||
private readonly speed: number;
|
||||
private readonly targetDirection = new Vector3(0, 0, 1);
|
||||
private readonly worldPosition = new Vector3();
|
||||
private readonly currentQ = new Quaternion();
|
||||
private readonly targetQ = new Quaternion();
|
||||
private readonly lookAtPoint = new Vector3();
|
||||
|
||||
constructor(target: Object3D, speed: number = 5) {
|
||||
this.target = target;
|
||||
this.speed = speed;
|
||||
this.currentQ.copy(target.quaternion);
|
||||
this.targetQ.copy(target.quaternion);
|
||||
target.getWorldDirection(this.targetDirection);
|
||||
this.targetDirection.y = 0;
|
||||
if (this.targetDirection.lengthSq() > 0) {
|
||||
this.targetDirection.normalize();
|
||||
} else {
|
||||
this.targetDirection.set(0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
setTargetDirection(direction: Vector3) {
|
||||
if (direction.lengthSq() === 0) return;
|
||||
this.targetDirection.copy(direction).normalize();
|
||||
}
|
||||
|
||||
setTargetWorldPosition(worldPosition: Vector3) {
|
||||
this.target.getWorldPosition(this.worldPosition);
|
||||
this.targetDirection.set(
|
||||
worldPosition.x - this.worldPosition.x,
|
||||
0,
|
||||
worldPosition.z - this.worldPosition.z,
|
||||
);
|
||||
if (this.targetDirection.lengthSq() === 0) return;
|
||||
this.targetDirection.normalize();
|
||||
}
|
||||
|
||||
syncToCurrentFacing() {
|
||||
this.target.getWorldDirection(this.targetDirection);
|
||||
this.targetDirection.y = 0;
|
||||
if (this.targetDirection.lengthSq() === 0) return;
|
||||
this.targetDirection.normalize();
|
||||
}
|
||||
|
||||
isComplete() {
|
||||
this.computeTargetQuaternion(this.targetQ);
|
||||
return this.currentQ.angleTo(this.targetQ) < RotationC.completeAngle;
|
||||
}
|
||||
|
||||
update(delta: number) {
|
||||
this.computeTargetQuaternion(this.targetQ);
|
||||
|
||||
if (this.currentQ.angleTo(this.targetQ) < RotationC.completeAngle) {
|
||||
this.target.quaternion.copy(this.targetQ);
|
||||
return;
|
||||
}
|
||||
|
||||
this.target.quaternion.slerp(this.targetQ, delta * this.speed);
|
||||
this.currentQ.copy(this.target.quaternion);
|
||||
}
|
||||
|
||||
private computeTargetQuaternion(out: Quaternion) {
|
||||
this.currentQ.copy(this.target.quaternion);
|
||||
|
||||
this.lookAtPoint.copy(this.target.position).add(this.targetDirection);
|
||||
this.target.lookAt(this.lookAtPoint);
|
||||
out.copy(this.target.quaternion);
|
||||
this.target.quaternion.copy(this.currentQ);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Delegate, ResourcesC, Template, UpdateController } from "@24tools/playable_template";
|
||||
import { Character } from "./Character/Character";
|
||||
import { ResourcesType } from "../Enums/ResourcesType";
|
||||
import { MeshType } from "../Enums/MeshType";
|
||||
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
|
||||
import { PhysicsBody, PhysicsLayer, PLAYER_COLLIDER_RADIUS } from "../core/PhysicsC";
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { ThreeC } from "../core/ThreeC";
|
||||
import { PlayerInput } from "./Input/PlayerInput";
|
||||
import { MoveC } from "./Movement/MoveC";
|
||||
import { Vector3CToT } from "../utils/Helper";
|
||||
import { RotationC } from "./Movement/RotationC";
|
||||
import { FollowCameraC } from "../Camera/FollowCamera";
|
||||
import { PhysicsTriggerC } from "../Map/PhysicsTriggerC";
|
||||
import { PropHpUIC } from "../Props/PropHpUIC";
|
||||
import { PlayerContext } from "./PlayerState/PlayerContext";
|
||||
import { PlayerStateMachine } from "./PlayerState/PlayerStateMachine";
|
||||
import { PlayerStateType } from "./PlayerState/PlayerStateType";
|
||||
import { IdleState } from "./PlayerState/states/IdleState";
|
||||
import { RunState } from "./PlayerState/states/RunState";
|
||||
import { TurnToTargetState } from "./PlayerState/states/TurnToTargetState";
|
||||
import { LootState } from "./PlayerState/states/LootState";
|
||||
|
||||
export class Player {
|
||||
private static inited = false;
|
||||
|
||||
private static updateDelegate: Delegate<number>;
|
||||
private static context: PlayerContext;
|
||||
private static stateMachine: PlayerStateMachine;
|
||||
|
||||
private static container: Object3D = new Object3D();
|
||||
private static input: PlayerInput;
|
||||
private static movement: MoveC;
|
||||
private static rotation: RotationC;
|
||||
private static spawnPosition = new Vector3(0, 0, -4);
|
||||
|
||||
static character: Character;
|
||||
static physics: PhysicsBody;
|
||||
|
||||
static init() {
|
||||
if (this.inited) return;
|
||||
this.inited = true;
|
||||
|
||||
const asset = ResourcesC.getResource<GLTF>(ResourcesType.Mesh, MeshType.Character);
|
||||
this.character = new Character(asset);
|
||||
|
||||
this.container.position.copy(this.spawnPosition);
|
||||
this.character.setDefaultWeapons();
|
||||
this.container.add(this.character.tObj);
|
||||
ThreeC.addToScene(this.container);
|
||||
|
||||
this.initPhysics();
|
||||
PhysicsTriggerC.setPlayerBody(this.physics.getPhysicsBody());
|
||||
|
||||
this.input = new PlayerInput();
|
||||
const moveSpeed = Template.getValue<number>("character", "movement_speed");
|
||||
const rotationSpeed = 8;
|
||||
this.movement = new MoveC(this.input, moveSpeed);
|
||||
this.rotation = new RotationC(this.container, rotationSpeed);
|
||||
|
||||
this.context = new PlayerContext({
|
||||
character: this.character,
|
||||
movement: this.movement,
|
||||
rotation: this.rotation,
|
||||
physics: this.physics,
|
||||
container: this.container,
|
||||
});
|
||||
|
||||
this.stateMachine = new PlayerStateMachine(
|
||||
this.context,
|
||||
new Map<PlayerStateType, IdleState | RunState | TurnToTargetState | LootState>([
|
||||
[PlayerStateType.Idle, new IdleState()],
|
||||
[PlayerStateType.Run, new RunState()],
|
||||
[PlayerStateType.TurnToTarget, new TurnToTargetState()],
|
||||
[PlayerStateType.Loot, new LootState()],
|
||||
]),
|
||||
);
|
||||
this.context.stateMachine = this.stateMachine;
|
||||
|
||||
this.updateDelegate = new Delegate<number>((delta) => this.update(delta));
|
||||
UpdateController.Instance.onUpdate.addListener(this.updateDelegate);
|
||||
|
||||
FollowCameraC.init(this.container);
|
||||
}
|
||||
|
||||
static get currentState(): PlayerStateType {
|
||||
return this.stateMachine.currentState;
|
||||
}
|
||||
|
||||
static getWorldPosition() {
|
||||
return this.container.getWorldPosition(new Vector3());
|
||||
}
|
||||
|
||||
static setMoveSpeed(speed: number) {
|
||||
this.movement?.setSpeed(speed);
|
||||
}
|
||||
|
||||
static isMoving() {
|
||||
return this.movement.Direction.lengthSq() > 0;
|
||||
}
|
||||
|
||||
static isInCombat() {
|
||||
const state = this.currentState;
|
||||
return state === PlayerStateType.TurnToTarget || state === PlayerStateType.Loot;
|
||||
}
|
||||
|
||||
static isCombatBusy() {
|
||||
return this.isInCombat() || this.context.pendingCombatExit;
|
||||
}
|
||||
|
||||
static isAutoAttackActive() {
|
||||
return this.context.isAutoAttacking;
|
||||
}
|
||||
|
||||
static markCombatPending(pending: boolean) {
|
||||
this.context.pendingCombatEntry = pending;
|
||||
}
|
||||
|
||||
static startAutoAttack(
|
||||
onStrike: () => void,
|
||||
_onComplete: () => void,
|
||||
targetWorldPosition: Vector3,
|
||||
) {
|
||||
if (this.context.isAutoAttacking) return;
|
||||
|
||||
this.context.pendingCombatEntry = false;
|
||||
this.context.isAutoAttacking = true;
|
||||
this.context.onStrike = onStrike;
|
||||
this.context.combatTargetWorldPosition.copy(targetWorldPosition);
|
||||
this.rotation.setTargetWorldPosition(targetWorldPosition);
|
||||
this.character.setBatEquipped(true);
|
||||
this.stateMachine.setState(PlayerStateType.Loot);
|
||||
}
|
||||
|
||||
static stopAutoAttack() {
|
||||
const needsExit = this.context.isAutoAttacking
|
||||
|| this.currentState === PlayerStateType.TurnToTarget
|
||||
|| this.currentState === PlayerStateType.Loot
|
||||
|| this.context.pendingCombatExit;
|
||||
|
||||
this.context.isAutoAttacking = false;
|
||||
this.context.onStrike = null;
|
||||
this.context.pendingCombatExit = false;
|
||||
this.context.pendingCombatEntry = false;
|
||||
|
||||
if (needsExit) {
|
||||
this.context.finishCombatExit();
|
||||
}
|
||||
|
||||
PropHpUIC.hideAll();
|
||||
}
|
||||
|
||||
static retargetAutoAttack(targetWorldPosition: Vector3) {
|
||||
if (!this.context.isAutoAttacking) return;
|
||||
|
||||
this.context.combatTargetWorldPosition.copy(targetWorldPosition);
|
||||
this.context.zeroVelocity();
|
||||
this.rotation.setTargetWorldPosition(targetWorldPosition);
|
||||
}
|
||||
|
||||
static playAttack(onStrike: () => void) {
|
||||
this.context.onStrike = onStrike;
|
||||
this.stateMachine.setState(PlayerStateType.Loot);
|
||||
}
|
||||
|
||||
private static initPhysics() {
|
||||
const playerColliderRadius = PLAYER_COLLIDER_RADIUS;
|
||||
this.physics = new PhysicsBody(
|
||||
this.container,
|
||||
false,
|
||||
1,
|
||||
PhysicsLayer.Player,
|
||||
PhysicsLayer.Wall | PhysicsLayer.Trigger,
|
||||
playerColliderRadius,
|
||||
);
|
||||
|
||||
// Top-down movement: the player never moves vertically. Locking the Y
|
||||
// translation makes sphere-vs-box contacts resolve purely horizontally
|
||||
// (slide along the crate) instead of popping the player up onto it -
|
||||
// there's no gravity to bring it back down, so any vertical nudge would
|
||||
// otherwise stick. Lets us use a normal collider radius safely.
|
||||
this.physics.getPhysicsBody().linearFactor.set(1, 0, 1);
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
this.movement.update(delta);
|
||||
|
||||
if (this.isMoving() && (this.context.isAutoAttacking || this.currentState === PlayerStateType.TurnToTarget)) {
|
||||
this.stopAutoAttack();
|
||||
}
|
||||
|
||||
this.stateMachine.update(delta);
|
||||
this.rotation.update(delta);
|
||||
this.syncVisual(delta);
|
||||
}
|
||||
|
||||
private static syncVisual(delta: number) {
|
||||
const lerpSpeed = 10;
|
||||
// Below this gap we snap instead of lerping, otherwise the exponential
|
||||
// ease keeps creeping for a few frames after the body stops -> visible
|
||||
// slide when the player releases the joystick against a wall.
|
||||
const snapDistance = 0.01;
|
||||
const targetPos = Vector3CToT(this.physics.getPhysicsBody().position);
|
||||
|
||||
if (this.container.position.distanceToSquared(targetPos) <= snapDistance * snapDistance) {
|
||||
this.container.position.copy(targetPos);
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.position.lerp(targetPos, delta * lerpSpeed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PlayerContext } from "./PlayerContext";
|
||||
|
||||
export interface IPlayerState {
|
||||
enter(context: PlayerContext): void;
|
||||
exit(context: PlayerContext): void;
|
||||
update(context: PlayerContext, deltaTime: number): void;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { Character } from "../Character/Character";
|
||||
import { MoveC } from "../Movement/MoveC";
|
||||
import { RotationC } from "../Movement/RotationC";
|
||||
import { PhysicsBody } from "../../core/PhysicsC";
|
||||
import { PlayerStateMachine } from "./PlayerStateMachine";
|
||||
import { PlayerStateType } from "./PlayerStateType";
|
||||
import { BaseAnimation } from "../../Enums/BaseAnimation";
|
||||
import { COMBAT_EXIT_FADE } from "./combatConstants";
|
||||
|
||||
export class PlayerContext {
|
||||
stateMachine!: PlayerStateMachine;
|
||||
|
||||
readonly character: Character;
|
||||
readonly movement: MoveC;
|
||||
readonly rotation: RotationC;
|
||||
readonly physics: PhysicsBody;
|
||||
readonly container: Object3D;
|
||||
|
||||
isAutoAttacking = false;
|
||||
pendingCombatExit = false;
|
||||
/**
|
||||
* Set when the player releases the joystick next to an attackable prop. Tells
|
||||
* RunState to hold its current animation instead of dropping to Idle for a
|
||||
* frame, so the upcoming Run -> Loot crossfade stays clean (no Idle blink).
|
||||
*/
|
||||
pendingCombatEntry = false;
|
||||
skipStateEnterAnimation = false;
|
||||
|
||||
onStrike: (() => void) | null = null;
|
||||
readonly combatTargetWorldPosition = new Vector3();
|
||||
|
||||
constructor(opts: {
|
||||
character: Character;
|
||||
movement: MoveC;
|
||||
rotation: RotationC;
|
||||
physics: PhysicsBody;
|
||||
container: Object3D;
|
||||
}) {
|
||||
this.character = opts.character;
|
||||
this.movement = opts.movement;
|
||||
this.rotation = opts.rotation;
|
||||
this.physics = opts.physics;
|
||||
this.container = opts.container;
|
||||
}
|
||||
|
||||
isMoving() {
|
||||
return this.movement.Direction.lengthSq() > 0;
|
||||
}
|
||||
|
||||
zeroVelocity() {
|
||||
this.physics.getPhysicsBody().velocity.set(0, 0, 0);
|
||||
}
|
||||
|
||||
syncRotation() {
|
||||
this.rotation.syncToCurrentFacing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Спільний вихід з бою: скидає бойові прапорці, ховає биту і переходить
|
||||
* у Run (якщо гравець рухається) або Idle. Використовується і `Player`,
|
||||
* і `LootState`, щоб логіка не дублювалась.
|
||||
*/
|
||||
finishCombatExit() {
|
||||
this.pendingCombatExit = false;
|
||||
this.isAutoAttacking = false;
|
||||
this.onStrike = null;
|
||||
this.character.setDefaultWeapons();
|
||||
this.syncRotation();
|
||||
|
||||
if (this.isMoving()) {
|
||||
this.stateMachine.setState(PlayerStateType.Run);
|
||||
return;
|
||||
}
|
||||
|
||||
this.character.crossFadeToAnimation(BaseAnimation.Idle, false, COMBAT_EXIT_FADE);
|
||||
this.skipStateEnterAnimation = true;
|
||||
this.stateMachine.setState(PlayerStateType.Idle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PlayerContext } from "./PlayerContext";
|
||||
import { IPlayerState } from "./IPlayerState";
|
||||
import { PlayerStateType } from "./PlayerStateType";
|
||||
|
||||
export class PlayerStateMachine {
|
||||
private activeState: IPlayerState;
|
||||
private currentStateType: PlayerStateType;
|
||||
private readonly states: Map<PlayerStateType, IPlayerState>;
|
||||
|
||||
constructor(
|
||||
private readonly context: PlayerContext,
|
||||
states: Map<PlayerStateType, IPlayerState>,
|
||||
initialState: PlayerStateType = PlayerStateType.Idle,
|
||||
) {
|
||||
this.states = states;
|
||||
this.activeState = states.get(initialState)!;
|
||||
this.currentStateType = initialState;
|
||||
this.activeState.enter(this.context);
|
||||
}
|
||||
|
||||
get currentState(): PlayerStateType {
|
||||
return this.currentStateType;
|
||||
}
|
||||
|
||||
setState(stateType: PlayerStateType) {
|
||||
if (this.currentStateType === stateType) return;
|
||||
|
||||
// console.log(`[PlayerState] ${this.currentStateType} -> ${stateType}`);
|
||||
|
||||
this.activeState.exit(this.context);
|
||||
this.activeState = this.states.get(stateType)!;
|
||||
this.currentStateType = stateType;
|
||||
this.activeState.enter(this.context);
|
||||
}
|
||||
|
||||
update(deltaTime: number) {
|
||||
this.activeState.update(this.context, deltaTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum PlayerStateType {
|
||||
Idle = "idle",
|
||||
Run = "run",
|
||||
TurnToTarget = "turn_to_target",
|
||||
Loot = "loot",
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** idle/run -> turn (blend into facing pose before the swing). */
|
||||
export const TURN_IDLE_FADE = 0.1;
|
||||
/** turn -> loot (windup into the bat swing). */
|
||||
export const LOOT_ENTER_FADE = 0.3;
|
||||
/** loot/combat -> idle (settle back after combat). */
|
||||
export const COMBAT_EXIT_FADE = 0.3;
|
||||
/** any -> run (start moving). */
|
||||
export const RUN_ENTER_FADE = 0.2;
|
||||
|
||||
/** Normalized clip time (0–1) when the bat crosses the target: R→L, then L→R. */
|
||||
export const ATTACK_STRIKE_MARKS = [0.48, 0.72];
|
||||
/** After 1st hit (R→L) — safe exit if combat ends before 2nd hit (L→R). */
|
||||
export const LOOT_EXIT_AFTER_FIRST_STRIKE = 0.5;
|
||||
/** After 2nd hit (L→R) — follow-through before idle. */
|
||||
export const LOOT_EXIT_AFTER_SECOND_STRIKE = 0.78;
|
||||
/** End of clip when exiting before any strike (e.g. left the zone). */
|
||||
export const LOOT_EXIT_END_OF_CYCLE = 0.95;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BaseAnimation } from "../../../Enums/BaseAnimation";
|
||||
import { IPlayerState } from "../IPlayerState";
|
||||
import { PlayerContext } from "../PlayerContext";
|
||||
import { PlayerStateType } from "../PlayerStateType";
|
||||
|
||||
export class IdleState implements IPlayerState {
|
||||
enter(context: PlayerContext): void {
|
||||
if (context.skipStateEnterAnimation) {
|
||||
context.skipStateEnterAnimation = false;
|
||||
return;
|
||||
}
|
||||
|
||||
context.character.playAnimation(BaseAnimation.Idle);
|
||||
}
|
||||
|
||||
exit(_context: PlayerContext): void {}
|
||||
|
||||
update(context: PlayerContext, _deltaTime: number): void {
|
||||
if (context.isMoving()) {
|
||||
context.stateMachine.setState(PlayerStateType.Run);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = context.physics.getPhysicsBody();
|
||||
body.velocity.set(0, 0, 0);
|
||||
body.wakeUp();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BaseAnimation } from "../../../Enums/BaseAnimation";
|
||||
import {
|
||||
ATTACK_STRIKE_MARKS,
|
||||
LOOT_ENTER_FADE,
|
||||
LOOT_EXIT_AFTER_FIRST_STRIKE,
|
||||
LOOT_EXIT_AFTER_SECOND_STRIKE,
|
||||
LOOT_EXIT_END_OF_CYCLE,
|
||||
} from "../combatConstants";
|
||||
import { IPlayerState } from "../IPlayerState";
|
||||
import { PlayerContext } from "../PlayerContext";
|
||||
|
||||
export class LootState implements IPlayerState {
|
||||
private strikeMarkIndex = 0;
|
||||
private prevStrikeNormTime = 0;
|
||||
|
||||
enter(context: PlayerContext): void {
|
||||
context.zeroVelocity();
|
||||
|
||||
context.character.setBatEquipped(true);
|
||||
this.strikeMarkIndex = 0;
|
||||
this.prevStrikeNormTime = 0;
|
||||
|
||||
const animId = this.getBatAttackAnimation(context);
|
||||
context.character.crossFadeToAnimation(animId, false, LOOT_ENTER_FADE);
|
||||
}
|
||||
|
||||
exit(_context: PlayerContext): void {}
|
||||
|
||||
update(context: PlayerContext, _deltaTime: number): void {
|
||||
context.zeroVelocity();
|
||||
|
||||
context.rotation.setTargetWorldPosition(context.combatTargetWorldPosition);
|
||||
|
||||
const action = context.character.curClipAction;
|
||||
if (!action) return;
|
||||
|
||||
const clipDuration = action.getClip().duration;
|
||||
if (clipDuration <= 0) return;
|
||||
|
||||
const normalizedTime = action.time / clipDuration;
|
||||
|
||||
if (normalizedTime < this.prevStrikeNormTime) {
|
||||
if (context.pendingCombatExit) {
|
||||
context.finishCombatExit();
|
||||
return;
|
||||
}
|
||||
this.strikeMarkIndex = 0;
|
||||
}
|
||||
this.prevStrikeNormTime = normalizedTime;
|
||||
|
||||
while (
|
||||
this.strikeMarkIndex < ATTACK_STRIKE_MARKS.length &&
|
||||
normalizedTime >= ATTACK_STRIKE_MARKS[this.strikeMarkIndex]
|
||||
) {
|
||||
context.onStrike?.();
|
||||
this.strikeMarkIndex++;
|
||||
}
|
||||
|
||||
if (context.pendingCombatExit && this.canExitLootNow(normalizedTime)) {
|
||||
context.finishCombatExit();
|
||||
}
|
||||
}
|
||||
|
||||
private canExitLootNow(normalizedTime: number) {
|
||||
if (this.strikeMarkIndex >= 2) {
|
||||
return normalizedTime >= LOOT_EXIT_AFTER_SECOND_STRIKE;
|
||||
}
|
||||
|
||||
if (this.strikeMarkIndex >= 1) {
|
||||
return normalizedTime >= LOOT_EXIT_AFTER_FIRST_STRIKE;
|
||||
}
|
||||
|
||||
return normalizedTime >= LOOT_EXIT_END_OF_CYCLE;
|
||||
}
|
||||
|
||||
private getBatAttackAnimation(context: PlayerContext) {
|
||||
if (context.character.animationList.length > BaseAnimation.Loot) {
|
||||
return BaseAnimation.Loot;
|
||||
}
|
||||
|
||||
return BaseAnimation.Idle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Vector3 } from "three";
|
||||
import { BaseAnimation } from "../../../Enums/BaseAnimation";
|
||||
import { Vector3CToT, Vector3TToC } from "../../../utils/Helper";
|
||||
import { FollowCameraC } from "../../../Camera/FollowCamera";
|
||||
import { RUN_ENTER_FADE } from "../combatConstants";
|
||||
import { IPlayerState } from "../IPlayerState";
|
||||
import { PlayerContext } from "../PlayerContext";
|
||||
import { PlayerStateType } from "../PlayerStateType";
|
||||
|
||||
/** Anim playback never drops below this, so legs don't freeze at tiny joystick tilts. */
|
||||
const MIN_RUN_TIME_SCALE = 0.3;
|
||||
/** How fast the run anim speed catches up to the joystick tilt. */
|
||||
const RUN_TIME_SCALE_LERP = 10;
|
||||
|
||||
/**
|
||||
* Stuck detection works on a time window of NET displacement instead of per-frame
|
||||
* speed: cannon's penetration recovery jitters the body back and forth against a
|
||||
* wall, so an instantaneous speed check randomly spikes above any threshold and
|
||||
* misses the stuck case. Net travel over a window averages that jitter out.
|
||||
*/
|
||||
const STUCK_WINDOW = 0.15;
|
||||
/** Min real travel over a window; below this (while pushing) = stuck against a wall. */
|
||||
const STUCK_MIN_DISTANCE = 0.04;
|
||||
/** Fade for the run<->idle swap while staying in the Run state. */
|
||||
const STUCK_ANIM_FADE = 0.15;
|
||||
|
||||
export class RunState implements IPlayerState {
|
||||
private timeScale = MIN_RUN_TIME_SCALE;
|
||||
|
||||
private readonly windowStartPos = new Vector3();
|
||||
private readonly curPos = new Vector3();
|
||||
private windowTimer = 0;
|
||||
private blocked = false;
|
||||
|
||||
enter(context: PlayerContext): void {
|
||||
this.timeScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
|
||||
this.blocked = false;
|
||||
this.windowTimer = 0;
|
||||
this.windowStartPos.copy(Vector3CToT(context.physics.getPhysicsBody().position));
|
||||
|
||||
if (context.character.isPlayingAnimation(BaseAnimation.Run)) {
|
||||
context.character.setCurrentTimeScale(this.timeScale);
|
||||
return;
|
||||
}
|
||||
context.character.crossFadeToAnimation(BaseAnimation.Run, false, RUN_ENTER_FADE);
|
||||
context.character.setCurrentTimeScale(this.timeScale);
|
||||
}
|
||||
|
||||
exit(context: PlayerContext): void {
|
||||
context.character.setCurrentTimeScale(1);
|
||||
}
|
||||
|
||||
update(context: PlayerContext, deltaTime: number): void {
|
||||
const direction = context.movement.Direction;
|
||||
|
||||
if (direction.lengthSq() === 0) {
|
||||
// An auto-attack is about to start (stopped next to a prop): hold the
|
||||
// current animation and let GatherC switch us straight to Loot. Dropping
|
||||
// to Idle for a frame here is what produced the stop->attack blink.
|
||||
if (context.pendingCombatEntry) {
|
||||
context.zeroVelocity();
|
||||
return;
|
||||
}
|
||||
// While blocked the Idle clip is already playing, so let IdleState skip its
|
||||
// own playAnimation(Idle) - re-triggering it resets the same clip to frame 0
|
||||
// and causes a 1-frame pop right before the auto-attack crossfade.
|
||||
if (this.blocked) context.skipStateEnterAnimation = true;
|
||||
context.stateMachine.setState(PlayerStateType.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
FollowCameraC.inputDirection.copy(direction).normalize();
|
||||
context.rotation.setTargetDirection(direction);
|
||||
|
||||
const body = context.physics.getPhysicsBody();
|
||||
// Keep driving velocity every frame even while blocked, so sliding along the
|
||||
// wall (or the wall clearing) resumes movement on its own.
|
||||
body.velocity.copy(Vector3TToC(direction));
|
||||
body.wakeUp();
|
||||
|
||||
this.updateStuckState(context, deltaTime);
|
||||
if (this.blocked) return;
|
||||
|
||||
const targetScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
|
||||
const t = Math.min(deltaTime * RUN_TIME_SCALE_LERP, 1);
|
||||
this.timeScale += (targetScale - this.timeScale) * t;
|
||||
context.character.setCurrentTimeScale(this.timeScale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples net body travel over {@link STUCK_WINDOW}. If the player keeps pushing
|
||||
* but barely moves, we swap the run animation to Idle (staying in Run state) so
|
||||
* it resumes instantly once the body slides free.
|
||||
*/
|
||||
private updateStuckState(context: PlayerContext, deltaTime: number): void {
|
||||
this.windowTimer += deltaTime;
|
||||
if (this.windowTimer < STUCK_WINDOW) return;
|
||||
|
||||
this.curPos.copy(Vector3CToT(context.physics.getPhysicsBody().position));
|
||||
const moved = this.curPos.distanceTo(this.windowStartPos);
|
||||
const isStuck = moved < STUCK_MIN_DISTANCE;
|
||||
|
||||
this.windowStartPos.copy(this.curPos);
|
||||
this.windowTimer = 0;
|
||||
|
||||
if (isStuck === this.blocked) return;
|
||||
this.blocked = isStuck;
|
||||
|
||||
if (isStuck) {
|
||||
context.character.crossFadeToAnimation(BaseAnimation.Idle, false, STUCK_ANIM_FADE);
|
||||
} else {
|
||||
this.timeScale = Math.max(context.movement.Weight, MIN_RUN_TIME_SCALE);
|
||||
context.character.crossFadeToAnimation(BaseAnimation.Run, false, STUCK_ANIM_FADE);
|
||||
context.character.setCurrentTimeScale(this.timeScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseAnimation } from "../../../Enums/BaseAnimation";
|
||||
import { TURN_IDLE_FADE } from "../combatConstants";
|
||||
import { IPlayerState } from "../IPlayerState";
|
||||
import { PlayerContext } from "../PlayerContext";
|
||||
import { PlayerStateType } from "../PlayerStateType";
|
||||
|
||||
export class TurnToTargetState implements IPlayerState {
|
||||
enter(context: PlayerContext): void {
|
||||
context.rotation.setTargetWorldPosition(context.combatTargetWorldPosition);
|
||||
context.zeroVelocity();
|
||||
context.character.playAnimation(BaseAnimation.Idle, false, TURN_IDLE_FADE);
|
||||
}
|
||||
|
||||
exit(_context: PlayerContext): void {}
|
||||
|
||||
update(context: PlayerContext, _deltaTime: number): void {
|
||||
context.zeroVelocity();
|
||||
|
||||
if (!context.rotation.isComplete()) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.syncRotation();
|
||||
context.stateMachine.setState(PlayerStateType.Loot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { EasyEvent, UpdateController } from "@24tools/playable_template";
|
||||
import { Euler, Material, Mesh, Object3D, Vector3 } from "three";
|
||||
import { PhysicsBody } from "../core/PhysicsC";
|
||||
import { PropType } from "../Enums/PropType";
|
||||
import { PhysicsTriggerC } from "../Map/PhysicsTriggerC";
|
||||
import { PROP_DROPS } from "./PropDropTable";
|
||||
import { createDropPlan, PropDropPlan } from "./PropDropPlanner";
|
||||
import { ResourceSpawnC } from "../Resources/ResourceSpawnC";
|
||||
import { PropHpUIC } from "./PropHpUIC";
|
||||
import { PropHpBar } from "./PropHpBar";
|
||||
import { PropVfxC } from "./PropVfxC";
|
||||
import { VFXType } from "../Enums/VFXType";
|
||||
|
||||
/** Тривалість тряски після удару (секунди). */
|
||||
const SHAKE_DURATION = 0.22;
|
||||
/** Амплітуда зміщення моделі по X/Z під час тряски (world units). */
|
||||
const SHAKE_POSITION_AMP = 0.045;
|
||||
/** Амплітуда нахилу моделі під час тряски (радіани). */
|
||||
const SHAKE_ROTATION_AMP = 0.1;
|
||||
/** Частота коливань тряски (кількість «хитань» за секунду). */
|
||||
const SHAKE_FREQUENCY = 2;
|
||||
|
||||
export class PropRegistry {
|
||||
private static props = new Map<Object3D, PropC>();
|
||||
|
||||
static register(prop: PropC) {
|
||||
this.props.set(prop.object, prop);
|
||||
}
|
||||
|
||||
static get(object: Object3D) {
|
||||
return this.props.get(object);
|
||||
}
|
||||
|
||||
static unregister(object: Object3D) {
|
||||
this.props.delete(object);
|
||||
}
|
||||
}
|
||||
|
||||
export class PropC {
|
||||
static readonly onBroken = new EasyEvent<PropC>();
|
||||
|
||||
readonly object: Object3D;
|
||||
readonly propType: PropType;
|
||||
health: number;
|
||||
readonly maxHealth: number;
|
||||
isBroken = false;
|
||||
|
||||
private wallBodies: PhysicsBody[] = [];
|
||||
private dropPlan: PropDropPlan | null = null;
|
||||
private hpBar: PropHpBar | null = null;
|
||||
private damageLayers: Object3D[] = [];
|
||||
|
||||
private readonly restPosition = new Vector3();
|
||||
private readonly restRotation = new Euler();
|
||||
private shakeTimeLeft = 0;
|
||||
private shakeElapsed = 0;
|
||||
private onShakeComplete: (() => void) | null = null;
|
||||
|
||||
private static shakingProps = new Set<PropC>();
|
||||
private static shakeSystemInited = false;
|
||||
|
||||
constructor(object: Object3D, propType: PropType, maxHealth = 1) {
|
||||
this.object = object;
|
||||
this.propType = propType;
|
||||
this.maxHealth = maxHealth;
|
||||
this.health = maxHealth;
|
||||
this.restPosition.copy(object.position);
|
||||
this.restRotation.copy(object.rotation);
|
||||
PropRegistry.register(this);
|
||||
this.hpBar = PropHpUIC.createBar(this) ?? null;
|
||||
PropC.ensureShakeSystem();
|
||||
}
|
||||
|
||||
private static ensureShakeSystem() {
|
||||
if (this.shakeSystemInited) return;
|
||||
this.shakeSystemInited = true;
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => {
|
||||
for (const prop of [...this.shakingProps]) {
|
||||
prop.updateShake(delta);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addWallBody(physicsBody: PhysicsBody) {
|
||||
this.wallBodies.push(physicsBody);
|
||||
}
|
||||
|
||||
initDamageLayers(layers: Object3D[]) {
|
||||
this.damageLayers = layers;
|
||||
}
|
||||
|
||||
takeDamage(amount: number): boolean {
|
||||
if (this.isBroken) return false;
|
||||
|
||||
const hitIndex = this.maxHealth - this.health;
|
||||
this.ensureDropPlan();
|
||||
|
||||
this.applyDamageVisual(hitIndex);
|
||||
this.health -= amount;
|
||||
this.spawnForHit(hitIndex);
|
||||
this.hpBar?.onDamage();
|
||||
PropVfxC.Play(VFXType.LootableHit, null, this.object.getWorldPosition(new Vector3()));
|
||||
|
||||
if (this.health > 0) {
|
||||
this.playHitShake();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.playHitShake(() => this.break());
|
||||
return true;
|
||||
}
|
||||
|
||||
private applyDamageVisual(hitIndex: number) {
|
||||
const layer = this.damageLayers[hitIndex];
|
||||
if (!layer) return;
|
||||
|
||||
layer.visible = false;
|
||||
}
|
||||
|
||||
private playHitShake(onComplete?: () => void) {
|
||||
if (this.isBroken) return;
|
||||
|
||||
if (onComplete) {
|
||||
this.onShakeComplete = onComplete;
|
||||
}
|
||||
|
||||
this.shakeTimeLeft = SHAKE_DURATION;
|
||||
this.shakeElapsed = 0;
|
||||
PropC.shakingProps.add(this);
|
||||
}
|
||||
|
||||
private updateShake(delta: number) {
|
||||
if (this.shakeTimeLeft <= 0) {
|
||||
this.finishShake();
|
||||
return;
|
||||
}
|
||||
|
||||
this.shakeTimeLeft -= delta;
|
||||
this.shakeElapsed += delta;
|
||||
|
||||
const progress = Math.max(this.shakeTimeLeft / SHAKE_DURATION, 0);
|
||||
const intensity = progress * progress;
|
||||
const phase = this.shakeElapsed * SHAKE_FREQUENCY * Math.PI * 2;
|
||||
const wobble = Math.sin(phase);
|
||||
|
||||
this.object.position.set(
|
||||
this.restPosition.x + wobble * SHAKE_POSITION_AMP * intensity,
|
||||
this.restPosition.y,
|
||||
this.restPosition.z + Math.cos(phase) * SHAKE_POSITION_AMP * intensity * 0.75,
|
||||
);
|
||||
|
||||
this.object.rotation.set(
|
||||
this.restRotation.x + wobble * SHAKE_ROTATION_AMP * intensity * 0.35,
|
||||
this.restRotation.y,
|
||||
this.restRotation.z + Math.cos(phase) * SHAKE_ROTATION_AMP * intensity,
|
||||
);
|
||||
|
||||
if (this.shakeTimeLeft <= 0) {
|
||||
this.finishShake();
|
||||
}
|
||||
}
|
||||
|
||||
private finishShake() {
|
||||
this.stopShake();
|
||||
|
||||
const callback = this.onShakeComplete;
|
||||
this.onShakeComplete = null;
|
||||
callback?.();
|
||||
}
|
||||
|
||||
private stopShake() {
|
||||
this.object.position.copy(this.restPosition);
|
||||
this.object.rotation.copy(this.restRotation);
|
||||
this.shakeTimeLeft = 0;
|
||||
PropC.shakingProps.delete(this);
|
||||
}
|
||||
|
||||
break() {
|
||||
if (this.isBroken) return;
|
||||
|
||||
this.isBroken = true;
|
||||
this.onShakeComplete = null;
|
||||
this.stopShake();
|
||||
|
||||
PropHpUIC.removeBar(this);
|
||||
this.hpBar = null;
|
||||
|
||||
this.wallBodies.forEach((body) => body.destroy());
|
||||
this.wallBodies = [];
|
||||
|
||||
PhysicsTriggerC.unregister(this.object);
|
||||
PropC.onBroken.Invoke(this);
|
||||
PropRegistry.unregister(this.object);
|
||||
|
||||
PropVfxC.Play(VFXType.LootableDestroy, null, this.object.getWorldPosition(new Vector3()));
|
||||
|
||||
this.disposeProceduralResources();
|
||||
this.object.parent?.remove(this.object);
|
||||
}
|
||||
|
||||
private disposeProceduralResources() {
|
||||
const trigger = this.object.getObjectByName("GatherTrigger");
|
||||
if (!trigger || !(trigger as Mesh).isMesh) return;
|
||||
|
||||
const mesh = trigger as Mesh;
|
||||
mesh.geometry.dispose();
|
||||
|
||||
const material = mesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((entry) => entry.dispose());
|
||||
} else if (material) {
|
||||
(material as Material).dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private ensureDropPlan() {
|
||||
if (this.dropPlan) return;
|
||||
|
||||
const rules = PROP_DROPS[this.propType];
|
||||
if (!rules.length) return;
|
||||
|
||||
this.dropPlan = createDropPlan(this.maxHealth, rules[0]);
|
||||
}
|
||||
|
||||
private spawnForHit(hitIndex: number) {
|
||||
if (!this.dropPlan) return;
|
||||
|
||||
let spawnCount = this.dropPlan.hitAmounts[hitIndex] ?? 0;
|
||||
|
||||
if (this.health <= 0) {
|
||||
const remaining = this.dropPlan.totalDrop - this.dropPlan.spawnedDrop - spawnCount;
|
||||
if (remaining > 0) {
|
||||
spawnCount += remaining;
|
||||
}
|
||||
}
|
||||
|
||||
if (spawnCount <= 0) return;
|
||||
|
||||
ResourceSpawnC.spawnFromProp(this, this.dropPlan.resource, spawnCount);
|
||||
this.dropPlan.spawnedDrop += spawnCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Object3D } from "three";
|
||||
|
||||
const STATE_LAYER_PATTERN = /_S(\d+)$/i;
|
||||
|
||||
function getStateLayerNumber(name: string): number | null {
|
||||
const match = name.match(STATE_LAYER_PATTERN);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/** Повертає damage-шари з контейнера *_States*, відсортовані за номером _S*. */
|
||||
export function findDamageStateLayers(root: Object3D): Object3D[] {
|
||||
const containers: Object3D[] = [];
|
||||
|
||||
root.traverse((child) => {
|
||||
if (/states/i.test(child.name)) {
|
||||
containers.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
const statesContainer = containers[0];
|
||||
if (!statesContainer) return [];
|
||||
|
||||
return statesContainer.children
|
||||
.filter((child) => getStateLayerNumber(child.name) !== null)
|
||||
.sort((a, b) => getStateLayerNumber(a.name)! - getStateLayerNumber(b.name)!);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ResourceType } from "../Enums/ResourceType";
|
||||
|
||||
function randomInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[]) {
|
||||
const result = [...items];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function splitRandom(total: number, parts: number) {
|
||||
if (parts <= 0) return [];
|
||||
if (parts === 1) return [total];
|
||||
|
||||
const result = new Array(parts).fill(1);
|
||||
let remaining = total - parts;
|
||||
|
||||
while (remaining > 0) {
|
||||
result[Math.floor(Math.random() * parts)]++;
|
||||
remaining--;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export type PropDropPlan = {
|
||||
resource: ResourceType;
|
||||
totalDrop: number;
|
||||
spawnedDrop: number;
|
||||
hitAmounts: number[];
|
||||
};
|
||||
|
||||
export function createDropPlan(
|
||||
maxHealth: number,
|
||||
rule: {
|
||||
resource: ResourceType;
|
||||
minTotal: number;
|
||||
maxTotal: number;
|
||||
minSpawnHits: number;
|
||||
maxSpawnHits: number;
|
||||
},
|
||||
): PropDropPlan {
|
||||
const totalDrop = randomInt(rule.minTotal, rule.maxTotal);
|
||||
const spawnEvents = randomInt(rule.minSpawnHits, rule.maxSpawnHits);
|
||||
const hitAmounts = new Array(maxHealth).fill(0);
|
||||
|
||||
const hitIndexes = shuffle([...Array(maxHealth).keys()]).slice(0, spawnEvents);
|
||||
const amounts = splitRandom(totalDrop, spawnEvents);
|
||||
|
||||
hitIndexes.forEach((hitIndex, index) => {
|
||||
hitAmounts[hitIndex] = amounts[index];
|
||||
});
|
||||
|
||||
return {
|
||||
resource: rule.resource,
|
||||
totalDrop,
|
||||
spawnedDrop: 0,
|
||||
hitAmounts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PropType } from "../Enums/PropType";
|
||||
import { ResourceType } from "../Enums/ResourceType";
|
||||
|
||||
export type DropRule = {
|
||||
resource: ResourceType;
|
||||
minTotal: number;
|
||||
maxTotal: number;
|
||||
minSpawnHits: number;
|
||||
maxSpawnHits: number;
|
||||
};
|
||||
|
||||
export const PROP_DROPS: Record<PropType, DropRule[]> = {
|
||||
[PropType.Box]: [
|
||||
{
|
||||
resource: ResourceType.Wood,
|
||||
minTotal: 1,
|
||||
maxTotal: 4,
|
||||
minSpawnHits: 1,
|
||||
maxSpawnHits: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function resolvePropType(objectName: string): PropType {
|
||||
const name = objectName.toLowerCase();
|
||||
if (name.includes("box") || name.includes("crate") || name.includes("wood")) {
|
||||
return PropType.Box;
|
||||
}
|
||||
|
||||
console.warn(`Unknown prop type for "${objectName}", falling back to Box`);
|
||||
return PropType.Box;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
Box3,
|
||||
Color,
|
||||
DoubleSide,
|
||||
Material,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
MeshStandardMaterial,
|
||||
Object3D,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { ResourcesC } from "@24tools/playable_template";
|
||||
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
|
||||
import { ThreeC } from "../core/ThreeC";
|
||||
import { ResourcesType } from "../Enums/ResourcesType";
|
||||
import { PropC } from "./PropC";
|
||||
|
||||
const HP_BAR_MESH = "hpBar";
|
||||
const NODE_UI = "UI";
|
||||
const NODE_BACKGROUND = "UI_Background";
|
||||
const NODE_FOREGROUND = "UI_Foreground";
|
||||
const NODE_MIDDLEGROUND = "UI_Middleground";
|
||||
|
||||
const BAR_Y_OFFSET = 0.55;
|
||||
const BAR_X_OFFSET = -0.2;
|
||||
const CATCHUP_SPEED = 2.5;
|
||||
const BAR_WORLD_SCALE = 1;
|
||||
|
||||
/** Transparent pass + renderOrder above all map decals. */
|
||||
export const OVERLAY_RENDER_ORDER = 10000;
|
||||
const RENDER_ORDER_BACKGROUND = OVERLAY_RENDER_ORDER;
|
||||
const RENDER_ORDER_MIDDLEGROUND = OVERLAY_RENDER_ORDER + 1;
|
||||
const RENDER_ORDER_FOREGROUND = OVERLAY_RENDER_ORDER + 2;
|
||||
|
||||
type FillPart = {
|
||||
node: Object3D;
|
||||
baseScaleX: number;
|
||||
basePosX: number;
|
||||
/** Local mesh max.x; keeps right edge fixed when scaling. */
|
||||
anchorMaxX: number;
|
||||
};
|
||||
|
||||
export class PropHpBar {
|
||||
static readonly OVERLAY_RENDER_ORDER = OVERLAY_RENDER_ORDER;
|
||||
private static readonly identityMatrix = new Matrix4();
|
||||
private static readonly tempMatrix = new Matrix4();
|
||||
private static readonly tempPosition = new Vector3();
|
||||
private static readonly tempQuaternion = new Quaternion();
|
||||
private static readonly tempScale = new Vector3();
|
||||
private static readonly worldAnchor = new Vector3();
|
||||
|
||||
private readonly prop: PropC;
|
||||
private readonly root: Object3D;
|
||||
private readonly foreground: FillPart | null;
|
||||
private readonly middleground: FillPart | null;
|
||||
private readonly bounds = new Box3();
|
||||
|
||||
private displayedHealth: number;
|
||||
private visible = false;
|
||||
private hasTakenDamage = false;
|
||||
|
||||
constructor(prop: PropC, parent: Object3D) {
|
||||
const template = PropHpBar.getPrefab();
|
||||
if (!template) {
|
||||
throw new Error(`HP bar mesh resource not found: ${HP_BAR_MESH}`);
|
||||
}
|
||||
|
||||
const sourceUi = template.getObjectByName(NODE_UI);
|
||||
if (!sourceUi) {
|
||||
throw new Error(`HP bar node not found: ${NODE_UI}`);
|
||||
}
|
||||
|
||||
this.prop = prop;
|
||||
this.displayedHealth = prop.health;
|
||||
|
||||
this.root = new Object3D();
|
||||
this.root.visible = false;
|
||||
this.root.scale.setScalar(BAR_WORLD_SCALE);
|
||||
this.root.frustumCulled = false;
|
||||
this.root.renderOrder = RENDER_ORDER_FOREGROUND;
|
||||
|
||||
const ui = sourceUi.clone(true);
|
||||
this.bakeMatrices(ui);
|
||||
this.centerGroup(ui);
|
||||
this.prepareMeshes(ui);
|
||||
|
||||
this.root.add(ui);
|
||||
parent.add(this.root);
|
||||
|
||||
const fgNode = ui.getObjectByName(NODE_FOREGROUND);
|
||||
const mgNode = ui.getObjectByName(NODE_MIDDLEGROUND);
|
||||
this.foreground = fgNode ? this.createFillPart(fgNode) : null;
|
||||
this.middleground = mgNode ? this.createFillPart(mgNode) : null;
|
||||
|
||||
const background = ui.getObjectByName(NODE_BACKGROUND);
|
||||
if (background) background.visible = true;
|
||||
|
||||
this.applyWidths();
|
||||
}
|
||||
|
||||
static getPrefab(): Object3D | null {
|
||||
const fromScene = ThreeC.getObject(HP_BAR_MESH);
|
||||
if (fromScene) return fromScene;
|
||||
|
||||
const gltf = ResourcesC.getResource<GLTF>(ResourcesType.Mesh, HP_BAR_MESH);
|
||||
return gltf?.scene ?? null;
|
||||
}
|
||||
|
||||
onDamage() {
|
||||
this.hasTakenDamage = true;
|
||||
|
||||
if (!this.visible) {
|
||||
this.setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyWidths();
|
||||
}
|
||||
|
||||
update(delta: number) {
|
||||
if (this.prop.isBroken) return;
|
||||
|
||||
const { health, maxHealth } = this.prop;
|
||||
if (this.displayedHealth > health) {
|
||||
this.displayedHealth = Math.max(
|
||||
health,
|
||||
this.displayedHealth - CATCHUP_SPEED * delta * maxHealth,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.middleground) {
|
||||
this.applyFill(this.middleground, this.getHealthRatio(this.displayedHealth));
|
||||
}
|
||||
|
||||
if (!this.visible) return;
|
||||
|
||||
this.updateTransform();
|
||||
}
|
||||
|
||||
setVisible(value: boolean) {
|
||||
// Once the prop has been hit, keep its bar visible until it breaks.
|
||||
if (!value && this.hasTakenDamage) return;
|
||||
|
||||
this.visible = value;
|
||||
this.root.visible = value;
|
||||
|
||||
if (value) {
|
||||
this.displayedHealth = this.prop.health;
|
||||
this.applyWidths();
|
||||
this.updateTransform();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Матеріали створюються в prepareMeshes/toVisibleMaterial для кожного бару,
|
||||
// тож звільняємо їх при руйнуванні пропа. Геометрія спільна з prefab.
|
||||
this.root.traverse((child) => {
|
||||
const mesh = child as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
|
||||
const material = mesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((entry) => entry.dispose());
|
||||
} else if (material) {
|
||||
(material as Material).dispose();
|
||||
}
|
||||
});
|
||||
|
||||
this.root.removeFromParent();
|
||||
}
|
||||
|
||||
private applyWidths() {
|
||||
if (this.foreground) {
|
||||
this.applyFill(this.foreground, this.getHealthRatio(this.prop.health));
|
||||
}
|
||||
if (this.middleground) {
|
||||
this.applyFill(this.middleground, this.getHealthRatio(this.displayedHealth));
|
||||
}
|
||||
}
|
||||
|
||||
private applyFill(part: FillPart, ratio: number) {
|
||||
const clamped = Math.max(0, Math.min(1, ratio));
|
||||
part.node.scale.x = part.baseScaleX * clamped;
|
||||
part.node.position.x =
|
||||
part.basePosX + part.anchorMaxX * part.baseScaleX * (1 - clamped);
|
||||
}
|
||||
|
||||
private createFillPart(node: Object3D): FillPart {
|
||||
const isForeground = node.name.includes(NODE_FOREGROUND);
|
||||
|
||||
node.renderOrder = isForeground
|
||||
? RENDER_ORDER_FOREGROUND
|
||||
: RENDER_ORDER_MIDDLEGROUND;
|
||||
|
||||
return {
|
||||
node,
|
||||
baseScaleX: node.scale.x,
|
||||
basePosX: node.position.x,
|
||||
anchorMaxX: this.getMeshMaxX(node),
|
||||
};
|
||||
}
|
||||
|
||||
private getMeshMaxX(node: Object3D) {
|
||||
const mesh = node as Mesh;
|
||||
if (!mesh.isMesh || !mesh.geometry) return 0;
|
||||
|
||||
mesh.geometry.computeBoundingBox();
|
||||
return mesh.geometry.boundingBox?.max.x ?? 0;
|
||||
}
|
||||
|
||||
private getHealthRatio(health: number) {
|
||||
if (this.prop.maxHealth <= 0) return 0;
|
||||
return Math.max(health, 0) / this.prop.maxHealth;
|
||||
}
|
||||
|
||||
private updateTransform() {
|
||||
if (!this.computeVisualBounds()) {
|
||||
this.root.visible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
PropHpBar.worldAnchor.set(
|
||||
(this.bounds.min.x + this.bounds.max.x) * 0.5 + BAR_X_OFFSET,
|
||||
this.bounds.max.y + BAR_Y_OFFSET,
|
||||
(this.bounds.min.z + this.bounds.max.z) * 0.5,
|
||||
);
|
||||
|
||||
this.root.position.copy(PropHpBar.worldAnchor);
|
||||
this.root.quaternion.set(0, 0, 0, 1);
|
||||
this.root.visible = this.visible;
|
||||
}
|
||||
|
||||
private computeVisualBounds() {
|
||||
this.prop.object.updateWorldMatrix(true, true);
|
||||
this.bounds.makeEmpty();
|
||||
|
||||
this.prop.object.traverse((child) => {
|
||||
if (!(child as Mesh).isMesh) return;
|
||||
|
||||
const name = child.name.toLowerCase();
|
||||
if (name.includes("collider") || name.includes("trigger") || name.includes("gathertrigger")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bounds.expandByObject(child);
|
||||
});
|
||||
|
||||
if (!this.bounds.isEmpty()) return true;
|
||||
|
||||
this.bounds.setFromObject(this.prop.object);
|
||||
return !this.bounds.isEmpty();
|
||||
}
|
||||
|
||||
private bakeMatrices(ui: Object3D) {
|
||||
this.decomposeNodeMatrix(ui);
|
||||
ui.traverse((child) => {
|
||||
if (child === ui) return;
|
||||
this.decomposeNodeMatrix(child);
|
||||
});
|
||||
}
|
||||
|
||||
private decomposeNodeMatrix(node: Object3D) {
|
||||
node.updateMatrix();
|
||||
PropHpBar.tempMatrix.copy(node.matrix);
|
||||
if (PropHpBar.tempMatrix.equals(PropHpBar.identityMatrix)) return;
|
||||
|
||||
PropHpBar.tempMatrix.decompose(
|
||||
PropHpBar.tempPosition,
|
||||
PropHpBar.tempQuaternion,
|
||||
PropHpBar.tempScale,
|
||||
);
|
||||
|
||||
node.matrix.identity();
|
||||
node.position.copy(PropHpBar.tempPosition);
|
||||
node.quaternion.copy(PropHpBar.tempQuaternion);
|
||||
node.scale.copy(PropHpBar.tempScale);
|
||||
node.matrixAutoUpdate = true;
|
||||
}
|
||||
|
||||
private centerGroup(group: Object3D) {
|
||||
const box = new Box3().setFromObject(group);
|
||||
const center = new Vector3();
|
||||
box.getCenter(center);
|
||||
group.position.sub(center);
|
||||
}
|
||||
|
||||
private prepareMeshes(root: Object3D) {
|
||||
root.traverse((child) => {
|
||||
child.frustumCulled = false;
|
||||
|
||||
if (!(child as Mesh).isMesh) return;
|
||||
|
||||
const mesh = child as Mesh;
|
||||
mesh.castShadow = false;
|
||||
mesh.receiveShadow = false;
|
||||
|
||||
if (child.name.includes(NODE_BACKGROUND)) {
|
||||
child.renderOrder = RENDER_ORDER_BACKGROUND;
|
||||
} else if (child.name.includes(NODE_MIDDLEGROUND)) {
|
||||
child.renderOrder = RENDER_ORDER_MIDDLEGROUND;
|
||||
} else if (child.name.includes(NODE_FOREGROUND)) {
|
||||
child.renderOrder = RENDER_ORDER_FOREGROUND;
|
||||
}
|
||||
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material = mesh.material.map((material) => this.toVisibleMaterial(material));
|
||||
} else {
|
||||
mesh.material = this.toVisibleMaterial(mesh.material);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Keeps GLB map/color; unlit basic material renders reliably in playable. */
|
||||
private toVisibleMaterial(source: Material) {
|
||||
if (source instanceof MeshBasicMaterial) {
|
||||
const cloned = source.clone();
|
||||
cloned.side = DoubleSide;
|
||||
cloned.depthTest = false;
|
||||
cloned.depthWrite = false;
|
||||
cloned.toneMapped = false;
|
||||
cloned.transparent = true;
|
||||
return cloned;
|
||||
}
|
||||
|
||||
const src = source as MeshStandardMaterial;
|
||||
const material = new MeshBasicMaterial({
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
opacity: src.opacity ?? 1,
|
||||
});
|
||||
|
||||
if (src.map) {
|
||||
material.map = src.map;
|
||||
}
|
||||
if (src.color) {
|
||||
material.color.copy(src.color);
|
||||
} else {
|
||||
material.color = new Color(0xffffff);
|
||||
}
|
||||
if (src.alphaTest) {
|
||||
material.alphaTest = src.alphaTest;
|
||||
material.transparent = true;
|
||||
}
|
||||
|
||||
return material;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Object3D } from "three";
|
||||
import { UpdateController } from "@24tools/playable_template";
|
||||
import { ThreeC } from "../core/ThreeC";
|
||||
import { PropC } from "./PropC";
|
||||
import { PropHpBar } from "./PropHpBar";
|
||||
|
||||
/** TODO: set false after HP bar position tuning */
|
||||
const DEBUG_ALWAYS_VISIBLE_HP_BARS = false;
|
||||
|
||||
export class PropHpUIC {
|
||||
private static inited = false;
|
||||
private static overlayLayer: Object3D | null = null;
|
||||
private static bars = new Map<PropC, PropHpBar>();
|
||||
|
||||
static init() {
|
||||
if (this.inited) return;
|
||||
this.inited = true;
|
||||
|
||||
this.hideSceneTemplate();
|
||||
this.ensureOverlayLayer();
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => {
|
||||
for (const bar of this.bars.values()) {
|
||||
bar.update(delta);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Call after map/scene objects are added so overlay renders last. */
|
||||
static bringOverlayToFront() {
|
||||
this.ensureOverlayLayer();
|
||||
const layer = this.overlayLayer;
|
||||
if (!layer) return;
|
||||
|
||||
let parent = layer.parent;
|
||||
if (!parent) {
|
||||
ThreeC.addToScene(layer);
|
||||
parent = layer.parent;
|
||||
if (!parent) return;
|
||||
}
|
||||
|
||||
parent.remove(layer);
|
||||
parent.add(layer);
|
||||
}
|
||||
|
||||
static createBar(prop: PropC): PropHpBar | undefined {
|
||||
if (!PropHpBar.getPrefab()) {
|
||||
console.warn("HP bar prefab not loaded");
|
||||
return undefined;
|
||||
}
|
||||
if (this.bars.has(prop)) return this.bars.get(prop);
|
||||
|
||||
const bar = new PropHpBar(prop, this.getOverlayLayer());
|
||||
this.bars.set(prop, bar);
|
||||
|
||||
if (DEBUG_ALWAYS_VISIBLE_HP_BARS) {
|
||||
bar.setVisible(true);
|
||||
}
|
||||
|
||||
return bar;
|
||||
}
|
||||
|
||||
static showFor(props: PropC[]) {
|
||||
if (DEBUG_ALWAYS_VISIBLE_HP_BARS) {
|
||||
this.showAll();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const bar of this.bars.values()) {
|
||||
bar.setVisible(false);
|
||||
}
|
||||
|
||||
for (const prop of props) {
|
||||
if (prop.isBroken) continue;
|
||||
this.bars.get(prop)?.setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
static hideAll() {
|
||||
if (DEBUG_ALWAYS_VISIBLE_HP_BARS) return;
|
||||
|
||||
for (const bar of this.bars.values()) {
|
||||
bar.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static showAll() {
|
||||
for (const [prop, bar] of this.bars) {
|
||||
bar.setVisible(!prop.isBroken);
|
||||
}
|
||||
}
|
||||
|
||||
static removeBar(prop: PropC) {
|
||||
const bar = this.bars.get(prop);
|
||||
if (!bar) return;
|
||||
|
||||
bar.destroy();
|
||||
this.bars.delete(prop);
|
||||
}
|
||||
|
||||
private static hideSceneTemplate() {
|
||||
const template = PropHpBar.getPrefab();
|
||||
if (!template || !template.parent) return;
|
||||
|
||||
template.visible = false;
|
||||
template.removeFromParent();
|
||||
}
|
||||
|
||||
private static ensureOverlayLayer() {
|
||||
if (!this.overlayLayer) {
|
||||
this.overlayLayer = new Object3D();
|
||||
this.overlayLayer.name = "prop-hp-overlay";
|
||||
this.overlayLayer.frustumCulled = false;
|
||||
this.overlayLayer.renderOrder = PropHpBar.OVERLAY_RENDER_ORDER;
|
||||
ThreeC.addToScene(this.overlayLayer);
|
||||
}
|
||||
}
|
||||
|
||||
private static getOverlayLayer() {
|
||||
this.ensureOverlayLayer();
|
||||
return this.overlayLayer!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BatchedRenderer, QuarksLoader, QuarksUtil } from "three.quarks";
|
||||
import { Object3D, Euler, Vector3 } from "three";
|
||||
import { ResourcesC, UpdateController } from "@24tools/playable_template";
|
||||
import { ThreeC } from "../core/ThreeC";
|
||||
import { TimeC } from "../Timers/TimeC";
|
||||
import { VFXType } from "../Enums/VFXType";
|
||||
import { ResourcesType } from "../Enums/ResourcesType";
|
||||
|
||||
export class PropVfxC {
|
||||
static batchRenderer: BatchedRenderer;
|
||||
static loader: QuarksLoader;
|
||||
|
||||
static Init() {
|
||||
this.batchRenderer = new BatchedRenderer();
|
||||
this.loader = new QuarksLoader();
|
||||
ThreeC.addToScene(this.batchRenderer);
|
||||
UpdateController.Instance.onUpdate.addDelegate(this.update.bind(this));
|
||||
}
|
||||
|
||||
static Remove(vfx: Object3D) {
|
||||
vfx.removeFromParent();
|
||||
vfx.parent = null;
|
||||
}
|
||||
|
||||
static update(delta: number) {
|
||||
delta *= TimeC.TimeScale;
|
||||
this.batchRenderer.update(delta);
|
||||
}
|
||||
|
||||
static Play(
|
||||
type: VFXType | string,
|
||||
parent: Object3D | null = null,
|
||||
position: Vector3 | null = null,
|
||||
rotation: Euler | null = null,
|
||||
scale: Vector3 | null = null,
|
||||
odred: number | null = null,
|
||||
) {
|
||||
const loaded = (ResourcesC.getResource(ResourcesType.VFX, type.toString()) as { obj: Object3D } | undefined)?.obj;
|
||||
if (!loaded) return new Object3D();
|
||||
|
||||
const effect = loaded.clone(true) as Object3D;
|
||||
QuarksUtil.setAutoDestroy(effect, true);
|
||||
QuarksUtil.addToBatchRenderer(effect, this.batchRenderer);
|
||||
|
||||
if (parent) parent.add(effect);
|
||||
else ThreeC.addToScene(effect);
|
||||
if (position) effect.position.copy(position);
|
||||
if (rotation) effect.rotation.copy(rotation);
|
||||
if (scale) effect.scale.copy(scale);
|
||||
if (odred) effect.renderOrder = odred;
|
||||
|
||||
QuarksUtil.play(effect);
|
||||
return effect;
|
||||
}
|
||||
|
||||
static StopEmision(effect: Object3D) {
|
||||
QuarksUtil.stop(effect);
|
||||
}
|
||||
|
||||
static Restart(effect: Object3D) {
|
||||
QuarksUtil.play(effect);
|
||||
}
|
||||
|
||||
static Pause(effect: Object3D) {
|
||||
QuarksUtil.pause(effect);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ResourceType } from "../Enums/ResourceType";
|
||||
|
||||
export const RESOURCE_PLACEHOLDER_COLORS: Record<ResourceType, string> = {
|
||||
[ResourceType.Wood]: "#6b4423",
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { UpdateController } from "@24tools/playable_template";
|
||||
import { Group } from "@tweenjs/tween.js";
|
||||
import { Object3D } from "three";
|
||||
import { GatherC } from "../Map/GatherC";
|
||||
import { DepositZoneC } from "../Map/DepositZoneC";
|
||||
import { InteractiveZoneC } from "../Map/InteractiveZoneC";
|
||||
import { ToolZoneC } from "../Map/ToolZoneC";
|
||||
import { Player } from "../Player/Player";
|
||||
import { ThreeC } from "../core/ThreeC";
|
||||
import { TickScheduler, ScheduledCall } from "../Timers/TickScheduler";
|
||||
import { ResourceInventoryC } from "./ResourceInventoryC";
|
||||
import { ResourceScreenFly } from "./ResourceScreenFly";
|
||||
import { ResourceType } from "../Enums/ResourceType";
|
||||
import { ResourceUIC } from "./ResourceUIC";
|
||||
|
||||
/** Screen-space flight duration (icon → deposit zone). Сповільнено для ефекту «поглинання». */
|
||||
const DEPOSIT_FLY_MS = 700;
|
||||
/** Pickup scale at the start of the flight near the UI icon (this value → 1 at the zone). */
|
||||
const DEPOSIT_FLY_SCALE = 0.35;
|
||||
/** Delay between starting each pickup in the chain (overlapping flights).
|
||||
* Задає й ритм прильотів → частоту «поглинаючих» імпульсів зони. */
|
||||
const CHAIN_DEPOSIT_STAGGER_MS = 130;
|
||||
|
||||
export class ResourceDepositC {
|
||||
private static inited = false;
|
||||
private static tweenGroup = new Group();
|
||||
private static isDepositing = false;
|
||||
private static activeFlights = 0;
|
||||
private static pendingLaunches = 0;
|
||||
private static scheduledCalls = new Set<ScheduledCall>();
|
||||
private static activePickups = new Set<Object3D>();
|
||||
|
||||
static init() {
|
||||
if (this.inited) return;
|
||||
this.inited = true;
|
||||
|
||||
DepositZoneC.onPlayerEnter.addDelegate(() => {
|
||||
this.tryStart();
|
||||
});
|
||||
|
||||
GatherC.onCombatIdle.addDelegate(() => {
|
||||
this.tryStart();
|
||||
});
|
||||
|
||||
ResourceInventoryC.onChanged.addDelegate(() => {
|
||||
this.tryStart();
|
||||
});
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
this.tweenGroup.update(performance.now());
|
||||
});
|
||||
}
|
||||
|
||||
/** Скасовує відкладені виклики, твіни та прибирає активні pickups (teardown). */
|
||||
static teardown() {
|
||||
for (const call of this.scheduledCalls) {
|
||||
TickScheduler.cancel(call);
|
||||
}
|
||||
this.scheduledCalls.clear();
|
||||
|
||||
this.tweenGroup.removeAll();
|
||||
|
||||
for (const pickup of this.activePickups) {
|
||||
ThreeC.removeFromScene(pickup);
|
||||
ResourceScreenFly.disposePickup(pickup);
|
||||
}
|
||||
this.activePickups.clear();
|
||||
|
||||
this.isDepositing = false;
|
||||
this.activeFlights = 0;
|
||||
this.pendingLaunches = 0;
|
||||
InteractiveZoneC.resetPulse();
|
||||
}
|
||||
|
||||
/** Відкладений виклик через ігровий тікер (із трекінгом для teardown). */
|
||||
private static delay(delayMs: number, callback: () => void) {
|
||||
const handle = TickScheduler.schedule(delayMs / 1000, () => {
|
||||
this.scheduledCalls.delete(handle);
|
||||
callback();
|
||||
});
|
||||
this.scheduledCalls.add(handle);
|
||||
}
|
||||
|
||||
static tryStart() {
|
||||
if (this.isDepositing || !this.canDeposit()) return;
|
||||
this.startDepositChain();
|
||||
}
|
||||
|
||||
private static canDeposit() {
|
||||
return DepositZoneC.isPlayerInside
|
||||
&& !Player.isAutoAttackActive()
|
||||
&& !Player.isCombatBusy()
|
||||
&& this.getDepositableType() !== null;
|
||||
}
|
||||
|
||||
private static getDepositableType() {
|
||||
for (const type of Object.values(ResourceType)) {
|
||||
if (ResourceInventoryC.get(type) > 0) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static getDepositableCount() {
|
||||
let total = 0;
|
||||
for (const type of Object.values(ResourceType)) {
|
||||
total += ResourceInventoryC.get(type);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static startDepositChain() {
|
||||
this.isDepositing = true;
|
||||
this.pendingLaunches = this.getDepositableCount();
|
||||
this.scheduleChainStep();
|
||||
}
|
||||
|
||||
private static scheduleChainStep() {
|
||||
if (!this.isDepositing) return;
|
||||
|
||||
if (this.pendingLaunches <= 0 || !this.canDeposit()) {
|
||||
this.tryFinishChain();
|
||||
return;
|
||||
}
|
||||
|
||||
const type = this.getDepositableType();
|
||||
if (!type) {
|
||||
this.tryFinishChain();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingLaunches -= 1;
|
||||
|
||||
if (!this.launchOneDeposit(type)) {
|
||||
this.tryFinishChain();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingLaunches > 0) {
|
||||
this.delay(CHAIN_DEPOSIT_STAGGER_MS, () => this.scheduleChainStep());
|
||||
} else {
|
||||
this.tryFinishChain();
|
||||
}
|
||||
}
|
||||
|
||||
private static launchOneDeposit(type: ResourceType) {
|
||||
const iconCenter = ResourceUIC.getIconCenter(type);
|
||||
if (!iconCenter) return false;
|
||||
|
||||
const pickup = ResourceScreenFly.createPickup(type);
|
||||
if (!pickup) return false;
|
||||
|
||||
const startScreen = ResourceScreenFly.screenPointFromClient(
|
||||
iconCenter.x,
|
||||
iconCenter.y,
|
||||
ToolZoneC.getScreenPoint().z,
|
||||
);
|
||||
|
||||
this.activeFlights += 1;
|
||||
this.activePickups.add(pickup);
|
||||
|
||||
ResourceScreenFly.flyAlongScreen(
|
||||
pickup,
|
||||
startScreen,
|
||||
() => ToolZoneC.getScreenPoint(),
|
||||
this.tweenGroup,
|
||||
DEPOSIT_FLY_MS,
|
||||
DEPOSIT_FLY_SCALE,
|
||||
1,
|
||||
() => {
|
||||
this.activePickups.delete(pickup);
|
||||
ResourceInventoryC.remove(type, 1);
|
||||
ResourceUIC.refresh(type);
|
||||
InteractiveZoneC.addDeposit(1);
|
||||
InteractiveZoneC.pulse();
|
||||
|
||||
this.activeFlights -= 1;
|
||||
this.tryFinishChain();
|
||||
},
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static tryFinishChain() {
|
||||
if (this.activeFlights > 0) return;
|
||||
this.isDepositing = false;
|
||||
// Підхопити ресурси, що долетіли в іконку вже під час ланцюжка:
|
||||
// їхні onChanged ігнорувались, поки isDepositing === true.
|
||||
this.tryStart();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { UpdateController } from "@24tools/playable_template";
|
||||
import { Easing, Group, Tween } from "@tweenjs/tween.js";
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { ThreeC } from "../core/ThreeC";
|
||||
import { TickScheduler, ScheduledCall } from "../Timers/TickScheduler";
|
||||
import { ResourceInventoryC } from "./ResourceInventoryC";
|
||||
import { ResourceScreenFly } from "./ResourceScreenFly";
|
||||
import { ResourceType } from "../Enums/ResourceType";
|
||||
import { ResourceUIC } from "./ResourceUIC";
|
||||
|
||||
const FALL_MS = 300;
|
||||
const EJECT_MS = 180;
|
||||
const SPAWN_HEIGHT = 0.55;
|
||||
const SCATTER_MIN = 1;
|
||||
const SCATTER_MAX = 1.05;
|
||||
const BOUNCE_HEIGHTS = [0.22, 0.09];
|
||||
const BOUNCE_UP_MS = 200;
|
||||
const BOUNCE_DOWN_MS = 180;
|
||||
const REST_AFTER_BOUNCE_MS = 400;
|
||||
/** Screen-space flight duration (world pickup → UI icon). */
|
||||
const FLY_DURATION_MS = 520;
|
||||
/** Pickup scale at the end of the flight (1 → this value). */
|
||||
const FLY_SCALE = 0.35;
|
||||
/** Delay between starting each fly-to-UI after bounce (overlapping flights). */
|
||||
const CHAIN_FLY_STAGGER_MS = 70;
|
||||
/** Delay between spawning each ground pickup from a loot burst. */
|
||||
const SPAWN_STAGGER_MS = 70;
|
||||
const GROUND_LIFT = 0.08;
|
||||
|
||||
type FlyQueueItem = {
|
||||
pickup: Object3D;
|
||||
type: ResourceType;
|
||||
};
|
||||
|
||||
|
||||
export class ResourceFlyC {
|
||||
private static inited = false;
|
||||
private static tweenGroup = new Group();
|
||||
private static flyQueue: FlyQueueItem[] = [];
|
||||
private static isDrainingFlyQueue = false;
|
||||
private static scheduledCalls = new Set<ScheduledCall>();
|
||||
private static activePickups = new Set<Object3D>();
|
||||
|
||||
static init() {
|
||||
if (this.inited) return;
|
||||
this.inited = true;
|
||||
|
||||
this.hideTemplateMeshes();
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
this.tweenGroup.update(performance.now());
|
||||
});
|
||||
}
|
||||
|
||||
/** Скасовує всі відкладені виклики, твіни та прибирає активні pickups (teardown). */
|
||||
static teardown() {
|
||||
for (const call of this.scheduledCalls) {
|
||||
TickScheduler.cancel(call);
|
||||
}
|
||||
this.scheduledCalls.clear();
|
||||
|
||||
this.tweenGroup.removeAll();
|
||||
|
||||
for (const pickup of this.activePickups) {
|
||||
ThreeC.removeFromScene(pickup);
|
||||
ResourceScreenFly.disposePickup(pickup);
|
||||
}
|
||||
this.activePickups.clear();
|
||||
|
||||
this.flyQueue.length = 0;
|
||||
this.isDrainingFlyQueue = false;
|
||||
}
|
||||
|
||||
/** Відкладений виклик через ігровий тікер (із трекінгом для teardown). */
|
||||
private static delay(delayMs: number, callback: () => void) {
|
||||
const handle = TickScheduler.schedule(delayMs / 1000, () => {
|
||||
this.scheduledCalls.delete(handle);
|
||||
callback();
|
||||
});
|
||||
this.scheduledCalls.add(handle);
|
||||
}
|
||||
|
||||
private static hideTemplateMeshes() {
|
||||
Object.values(ResourceType).forEach((type) => {
|
||||
const template = ThreeC.getObject(type);
|
||||
if (!template) return;
|
||||
|
||||
template.visible = false;
|
||||
template.removeFromParent();
|
||||
});
|
||||
}
|
||||
|
||||
static launch(origin: Vector3, type: ResourceType, index: number, count: number) {
|
||||
this.delay(index * SPAWN_STAGGER_MS, () => {
|
||||
this.startPickup(origin, type, index, count);
|
||||
});
|
||||
}
|
||||
|
||||
private static startPickup(origin: Vector3, type: ResourceType, index: number, count: number) {
|
||||
const pickup = ResourceScreenFly.createPickup(type);
|
||||
if (!pickup) return;
|
||||
|
||||
const scatter = this.getScatterOffset(index, count);
|
||||
const landPos = origin.clone().add(scatter);
|
||||
landPos.y += GROUND_LIFT;
|
||||
const spawnPos = origin.clone().add(new Vector3(0, SPAWN_HEIGHT * 0.25, 0));
|
||||
const peakPos = origin.clone()
|
||||
.add(scatter.clone().multiplyScalar(0.65))
|
||||
.add(new Vector3(0, SPAWN_HEIGHT, 0));
|
||||
|
||||
pickup.position.copy(spawnPos);
|
||||
ResourceScreenFly.orientToCamera(pickup);
|
||||
ThreeC.addToScene(pickup);
|
||||
this.activePickups.add(pickup);
|
||||
|
||||
const ejectTween = new Tween(pickup.position)
|
||||
.to({ x: peakPos.x, y: peakPos.y, z: peakPos.z }, EJECT_MS)
|
||||
.easing(Easing.Quadratic.Out)
|
||||
.onUpdate(() => {
|
||||
ResourceScreenFly.orientToCamera(pickup);
|
||||
});
|
||||
|
||||
const fallTween = new Tween(pickup.position)
|
||||
.to({ x: landPos.x, y: landPos.y, z: landPos.z }, FALL_MS)
|
||||
.easing(Easing.Quadratic.In)
|
||||
.onUpdate(() => {
|
||||
ResourceScreenFly.orientToCamera(pickup);
|
||||
})
|
||||
.onComplete(() => {
|
||||
this.playBounces(pickup, landPos, type);
|
||||
});
|
||||
|
||||
ejectTween.chain(fallTween);
|
||||
this.tweenGroup.add(ejectTween);
|
||||
this.tweenGroup.add(fallTween);
|
||||
ejectTween.start(performance.now());
|
||||
}
|
||||
|
||||
private static getScatterOffset(index: number, count: number) {
|
||||
const baseAngle = (index / Math.max(count, 1)) * Math.PI * 2;
|
||||
const angle = baseAngle + (Math.random() - 0.5) * 0.7;
|
||||
const distance = SCATTER_MIN + Math.random() * (SCATTER_MAX - SCATTER_MIN);
|
||||
|
||||
return new Vector3(
|
||||
Math.cos(angle) * distance,
|
||||
0,
|
||||
Math.sin(angle) * distance,
|
||||
);
|
||||
}
|
||||
|
||||
private static playBounces(pickup: Object3D, landPos: Vector3, type: ResourceType) {
|
||||
const runBounce = (bounceIndex: number) => {
|
||||
if (bounceIndex >= BOUNCE_HEIGHTS.length) {
|
||||
this.delay(REST_AFTER_BOUNCE_MS, () => {
|
||||
this.enqueueFlyToUI(pickup, type);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const height = BOUNCE_HEIGHTS[bounceIndex];
|
||||
const durationScale = height / BOUNCE_HEIGHTS[0];
|
||||
const peakY = landPos.y + height;
|
||||
|
||||
const upTween = new Tween(pickup.position)
|
||||
.to({ y: peakY }, BOUNCE_UP_MS * durationScale)
|
||||
.easing(Easing.Quadratic.Out)
|
||||
.onUpdate(() => {
|
||||
ResourceScreenFly.orientToCamera(pickup);
|
||||
});
|
||||
|
||||
const downTween = new Tween(pickup.position)
|
||||
.to({ y: landPos.y }, BOUNCE_DOWN_MS * durationScale)
|
||||
.easing(Easing.Quadratic.In)
|
||||
.onUpdate(() => {
|
||||
ResourceScreenFly.orientToCamera(pickup);
|
||||
})
|
||||
.onComplete(() => {
|
||||
runBounce(bounceIndex + 1);
|
||||
});
|
||||
|
||||
upTween.chain(downTween);
|
||||
this.tweenGroup.add(upTween);
|
||||
this.tweenGroup.add(downTween);
|
||||
upTween.start(performance.now());
|
||||
};
|
||||
|
||||
runBounce(0);
|
||||
}
|
||||
|
||||
private static enqueueFlyToUI(pickup: Object3D, type: ResourceType) {
|
||||
this.flyQueue.push({ pickup, type });
|
||||
if (!this.isDrainingFlyQueue) {
|
||||
this.drainFlyChainStep();
|
||||
}
|
||||
}
|
||||
|
||||
private static drainFlyChainStep() {
|
||||
const item = this.flyQueue.shift();
|
||||
if (!item) {
|
||||
this.isDrainingFlyQueue = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDrainingFlyQueue = true;
|
||||
this.flyToUI(item.pickup, item.type);
|
||||
|
||||
if (this.flyQueue.length > 0) {
|
||||
this.delay(CHAIN_FLY_STAGGER_MS, () => this.drainFlyChainStep());
|
||||
} else {
|
||||
this.isDrainingFlyQueue = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static flyToUI(pickup: Object3D, type: ResourceType) {
|
||||
const iconCenter = ResourceUIC.getIconCenter(type);
|
||||
if (!iconCenter) {
|
||||
this.activePickups.delete(pickup);
|
||||
ThreeC.removeFromScene(pickup);
|
||||
ResourceScreenFly.disposePickup(pickup);
|
||||
return;
|
||||
}
|
||||
|
||||
const startScreen = ResourceScreenFly.worldToScreen(pickup.position);
|
||||
const endScreen = ResourceScreenFly.screenPointFromClient(
|
||||
iconCenter.x,
|
||||
iconCenter.y,
|
||||
startScreen.z,
|
||||
);
|
||||
|
||||
ResourceScreenFly.flyAlongScreen(
|
||||
pickup,
|
||||
startScreen,
|
||||
endScreen,
|
||||
this.tweenGroup,
|
||||
FLY_DURATION_MS,
|
||||
1,
|
||||
FLY_SCALE,
|
||||
() => {
|
||||
this.activePickups.delete(pickup);
|
||||
ResourceInventoryC.add(type, 1);
|
||||
ResourceUIC.refresh(type);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EasyEvent } from "@24tools/playable_template";
|
||||
import { ResourceType } from "../Enums/ResourceType";
|
||||
|
||||
export class ResourceInventoryC {
|
||||
private static amounts = new Map<ResourceType, number>();
|
||||
static readonly onChanged = new EasyEvent<{ type: ResourceType }>();
|
||||
|
||||
static get(type: ResourceType) {
|
||||
return this.amounts.get(type) ?? 0;
|
||||
}
|
||||
|
||||
static add(type: ResourceType, amount: number) {
|
||||
if (amount <= 0) return;
|
||||
this.amounts.set(type, this.get(type) + amount);
|
||||
this.onChanged.Invoke({ type });
|
||||
}
|
||||
|
||||
static remove(type: ResourceType, amount: number) {
|
||||
if (amount <= 0) return;
|
||||
this.amounts.set(type, Math.max(0, this.get(type) - amount));
|
||||
this.onChanged.Invoke({ type });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { ResourcesC } from "@24tools/playable_template";
|
||||
import { Easing, Group, Tween } from "@tweenjs/tween.js";
|
||||
import {
|
||||
Material,
|
||||
Mesh,
|
||||
Object3D,
|
||||
Vector3,
|
||||
} from "three";
|
||||
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
|
||||
import { CameraC } from "../Camera/CameraC";
|
||||
import { ResourcesType } from "../Enums/ResourcesType";
|
||||
import { ThreeC } from "../core/ThreeC";
|
||||
import { ResourceType } from "../Enums/ResourceType";
|
||||
|
||||
export type ScreenPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
};
|
||||
|
||||
const PICKUP_SIZE = 1;
|
||||
|
||||
export class ResourceScreenFly {
|
||||
static createPickup(type: ResourceType) {
|
||||
const prefab = ResourcesC.getResource<GLTF>(ResourcesType.Mesh, type);
|
||||
if (!prefab?.scene) {
|
||||
console.warn(`Resource mesh not found: ${type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const meshSource = this.findFirstMesh(prefab.scene);
|
||||
if (!meshSource) {
|
||||
console.warn(`Resource mesh has no geometry: ${type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const mesh = meshSource.clone();
|
||||
mesh.position.set(0, 0, 0);
|
||||
mesh.rotation.set(0, 0, 0);
|
||||
mesh.scale.set(1, 1, 1);
|
||||
this.cloneMaterial(mesh);
|
||||
|
||||
const pickup = new Object3D();
|
||||
pickup.add(mesh);
|
||||
|
||||
mesh.geometry.computeBoundingBox();
|
||||
const size = new Vector3();
|
||||
mesh.geometry.boundingBox!.getSize(size);
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
if (maxDim > 0) {
|
||||
pickup.scale.setScalar(PICKUP_SIZE / maxDim);
|
||||
}
|
||||
|
||||
this.orientToCamera(pickup);
|
||||
return pickup;
|
||||
}
|
||||
|
||||
static flyAlongScreen(
|
||||
pickup: Object3D,
|
||||
startScreen: ScreenPoint,
|
||||
endScreen: ScreenPoint | (() => ScreenPoint),
|
||||
tweenGroup: Group,
|
||||
durationMs: number,
|
||||
startScale: number,
|
||||
endScale: number,
|
||||
onComplete: () => void,
|
||||
) {
|
||||
const resolveEnd = typeof endScreen === "function" ? endScreen : () => endScreen;
|
||||
const baseScale = pickup.scale.clone();
|
||||
pickup.position.copy(this.screenToWorld(startScreen));
|
||||
this.orientToCamera(pickup);
|
||||
ThreeC.addToScene(pickup);
|
||||
|
||||
const state = { t: 0 };
|
||||
|
||||
const flyTween = new Tween(state)
|
||||
.to({ t: 1 }, durationMs)
|
||||
.easing(Easing.Cubic.InOut)
|
||||
.onUpdate(() => {
|
||||
const screen = this.lerpScreenPoint(startScreen, resolveEnd(), state.t);
|
||||
pickup.position.copy(this.screenToWorld(screen));
|
||||
const scale = this.lerp(startScale, endScale, state.t);
|
||||
pickup.scale.set(
|
||||
baseScale.x * scale,
|
||||
baseScale.y * scale,
|
||||
baseScale.z * scale,
|
||||
);
|
||||
this.orientToCamera(pickup);
|
||||
})
|
||||
.onComplete(() => {
|
||||
ThreeC.removeFromScene(pickup);
|
||||
this.disposePickup(pickup);
|
||||
onComplete();
|
||||
});
|
||||
|
||||
tweenGroup.add(flyTween);
|
||||
flyTween.start(performance.now());
|
||||
}
|
||||
|
||||
/** Звільняє клоновані матеріали pickup-а. Геометрія спільна з GLTF — не чіпаємо. */
|
||||
static disposePickup(pickup: Object3D) {
|
||||
pickup.traverse((child) => {
|
||||
const mesh = child as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
|
||||
const material = mesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((entry) => entry.dispose());
|
||||
} else if (material) {
|
||||
(material as Material).dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static orientToCamera(object: Object3D) {
|
||||
object.quaternion.copy(CameraC.camera.quaternion);
|
||||
}
|
||||
|
||||
static worldToScreen(worldPos: Vector3): ScreenPoint {
|
||||
const canvasRect = this.getCanvasRect();
|
||||
if (!canvasRect) {
|
||||
return { x: 0, y: 0, z: 0.5 };
|
||||
}
|
||||
|
||||
const projected = worldPos.clone().project(CameraC.camera);
|
||||
|
||||
return {
|
||||
x: (projected.x * 0.5 + 0.5) * canvasRect.width + canvasRect.left,
|
||||
y: (-projected.y * 0.5 + 0.5) * canvasRect.height + canvasRect.top,
|
||||
z: projected.z,
|
||||
};
|
||||
}
|
||||
|
||||
static screenPointFromClient(clientX: number, clientY: number, ndcZ: number): ScreenPoint {
|
||||
return { x: clientX, y: clientY, z: ndcZ };
|
||||
}
|
||||
|
||||
static screenToWorld(screen: ScreenPoint) {
|
||||
const canvasRect = this.getCanvasRect();
|
||||
const camera = CameraC.camera;
|
||||
if (!canvasRect) {
|
||||
return new Vector3();
|
||||
}
|
||||
|
||||
const ndc = new Vector3(
|
||||
((screen.x - canvasRect.left) / canvasRect.width) * 2 - 1,
|
||||
-((screen.y - canvasRect.top) / canvasRect.height) * 2 + 1,
|
||||
screen.z,
|
||||
);
|
||||
ndc.unproject(camera);
|
||||
return ndc;
|
||||
}
|
||||
|
||||
private static lerp(from: number, to: number, t: number) {
|
||||
return from + (to - from) * t;
|
||||
}
|
||||
|
||||
private static lerpScreenPoint(from: ScreenPoint, to: ScreenPoint, t: number): ScreenPoint {
|
||||
return {
|
||||
x: this.lerp(from.x, to.x, t),
|
||||
y: this.lerp(from.y, to.y, t),
|
||||
z: this.lerp(from.z, to.z, t),
|
||||
};
|
||||
}
|
||||
|
||||
private static getCanvasRect() {
|
||||
const canvas = document.querySelector("canvas");
|
||||
return canvas?.getBoundingClientRect() ?? null;
|
||||
}
|
||||
|
||||
private static findFirstMesh(root: Object3D): Mesh | null {
|
||||
let result: Mesh | null = null;
|
||||
root.traverse((child) => {
|
||||
if (!result && (child as Mesh).isMesh) {
|
||||
result = child as Mesh;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static cloneMaterial(mesh: Mesh) {
|
||||
const applyPickupMaterial = (material: Material) => {
|
||||
const cloned = material.clone();
|
||||
cloned.depthTest = true;
|
||||
cloned.depthWrite = true;
|
||||
cloned.polygonOffset = true;
|
||||
cloned.polygonOffsetFactor = -2;
|
||||
cloned.polygonOffsetUnits = -2;
|
||||
return cloned;
|
||||
};
|
||||
|
||||
const { material } = mesh;
|
||||
if (Array.isArray(material)) {
|
||||
mesh.material = material.map((entry) => applyPickupMaterial(entry));
|
||||
return;
|
||||
}
|
||||
|
||||
if (material) {
|
||||
mesh.material = applyPickupMaterial(material);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Vector3 } from "three";
|
||||
import { PropC } from "../Props/PropC";
|
||||
import { ResourceFlyC } from "./ResourceFlyC";
|
||||
import { ResourceType } from "../Enums/ResourceType";
|
||||
|
||||
export class ResourceSpawnC {
|
||||
static spawnFromProp(prop: PropC, type: ResourceType, count: number) {
|
||||
if (count <= 0) return;
|
||||
|
||||
const origin = prop.object.getWorldPosition(new Vector3());
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
ResourceFlyC.launch(origin, type, i, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { UI_IMAGES } from "../../resources/images/uiImages";
|
||||
import { ResourceInventoryC } from "./ResourceInventoryC";
|
||||
import { ResourceType } from "../Enums/ResourceType";
|
||||
import { RESOURCE_PLACEHOLDER_COLORS } from "./ResourceConfig";
|
||||
|
||||
type ResourceUIEntry = {
|
||||
type: ResourceType;
|
||||
counter: HTMLElement;
|
||||
icon: HTMLElement;
|
||||
count: HTMLElement;
|
||||
};
|
||||
|
||||
export class ResourceUIC {
|
||||
private static entries = new Map<ResourceType, ResourceUIEntry>();
|
||||
private static root: HTMLElement | null = null;
|
||||
|
||||
static init() {
|
||||
const uiRoot = document.getElementById("ui");
|
||||
if (!uiRoot) return;
|
||||
|
||||
this.createTopRightHud(uiRoot);
|
||||
this.createBottomLeftWeapon(uiRoot);
|
||||
}
|
||||
|
||||
private static createTopRightHud(uiRoot: HTMLElement) {
|
||||
const topRight = document.createElement("div");
|
||||
topRight.className = "hud-top-right";
|
||||
|
||||
const avatar = document.createElement("img");
|
||||
avatar.className = "hud-avatar";
|
||||
avatar.src = UI_IMAGES.avatarTop;
|
||||
avatar.alt = "";
|
||||
topRight.appendChild(avatar);
|
||||
|
||||
this.root = document.createElement("div");
|
||||
this.root.id = "resource-bar";
|
||||
this.root.className = "resource-bar";
|
||||
topRight.appendChild(this.root);
|
||||
|
||||
uiRoot.appendChild(topRight);
|
||||
this.register(ResourceType.Wood);
|
||||
}
|
||||
|
||||
private static createBottomLeftWeapon(uiRoot: HTMLElement) {
|
||||
const bottomLeft = document.createElement("div");
|
||||
bottomLeft.className = "hud-bottom-left";
|
||||
|
||||
const weapon = document.createElement("div");
|
||||
weapon.className = "hud-weapon";
|
||||
|
||||
const bg = document.createElement("img");
|
||||
bg.className = "hud-weapon__bg";
|
||||
bg.src = UI_IMAGES.toolBg;
|
||||
bg.alt = "";
|
||||
|
||||
const tool = document.createElement("img");
|
||||
tool.className = "hud-weapon__tool";
|
||||
tool.src = UI_IMAGES.toolBat;
|
||||
tool.alt = "";
|
||||
|
||||
const level = document.createElement("span");
|
||||
level.className = "hud-weapon__level";
|
||||
level.textContent = "LVL 0";
|
||||
|
||||
weapon.appendChild(bg);
|
||||
weapon.appendChild(tool);
|
||||
weapon.appendChild(level);
|
||||
bottomLeft.appendChild(weapon);
|
||||
uiRoot.appendChild(bottomLeft);
|
||||
}
|
||||
|
||||
static register(type: ResourceType) {
|
||||
if (!this.root || this.entries.has(type)) return;
|
||||
|
||||
const counter = document.createElement("div");
|
||||
counter.className = "resource-counter";
|
||||
counter.dataset.resource = type;
|
||||
counter.style.backgroundImage = `url(${UI_IMAGES.woodCounterBg})`;
|
||||
|
||||
const count = document.createElement("span");
|
||||
count.className = "resource-count";
|
||||
count.textContent = "0";
|
||||
|
||||
const icon = document.createElement("div");
|
||||
icon.className = "resource-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
|
||||
counter.appendChild(count);
|
||||
counter.appendChild(icon);
|
||||
this.root.appendChild(counter);
|
||||
|
||||
this.entries.set(type, { type, counter, icon, count });
|
||||
this.refresh(type);
|
||||
}
|
||||
|
||||
static getIconCenter(type: ResourceType) {
|
||||
const entry = this.entries.get(type);
|
||||
if (!entry) return null;
|
||||
|
||||
const rect = entry.counter.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
static refresh(type: ResourceType) {
|
||||
const entry = this.entries.get(type);
|
||||
if (!entry) return;
|
||||
|
||||
entry.count.textContent = String(ResourceInventoryC.get(type));
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { BoxGeometry, Mesh, MeshStandardMaterial, Vector3 } from "three";
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { InputC, JoystickC } from "@24tools/playable_template";
|
||||
|
||||
export class TestSceneC {
|
||||
static init() {
|
||||
this.createPrimitive();
|
||||
|
||||
// example of using InputC events
|
||||
InputC.onTouchDown.addDelegate((event) => {
|
||||
console.log("onMouseDown", event);
|
||||
});
|
||||
|
||||
// if you have update in your controller
|
||||
// UpdateController.Instance.onUpdate.addDelegate(() => {
|
||||
// this.update();
|
||||
// });
|
||||
}
|
||||
|
||||
private static createPrimitive() {
|
||||
const geometry = new BoxGeometry(1, 1, 1);
|
||||
const material = new MeshStandardMaterial({ color: 0xcc0000 });
|
||||
const cube = new Mesh(geometry, material);
|
||||
|
||||
const geometryPlane = new BoxGeometry(5, 0.1, 7);
|
||||
const materialPlane = new MeshStandardMaterial({ color: 0xaaaaaa });
|
||||
const plane = new Mesh(geometryPlane, materialPlane);
|
||||
|
||||
let planePosition = plane.position.clone();
|
||||
|
||||
ThreeC.setShadowsStateForChildren(cube, true, false);
|
||||
|
||||
ThreeC.setShadowsStateForChildren(plane, false, true);
|
||||
|
||||
plane.position.copy(
|
||||
new Vector3(planePosition.x, planePosition.y - 0.5, planePosition.z - 1.5)
|
||||
);
|
||||
|
||||
ThreeC.addToScene(cube);
|
||||
ThreeC.addToScene(plane);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Delegate, UpdateController } from "@24tools/playable_template";
|
||||
|
||||
export type ScheduledCall = {
|
||||
remaining: number;
|
||||
callback: () => void;
|
||||
cancelled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Відкладені виклики, керовані ігровим тікером (delta з `UpdateController`),
|
||||
* а не `window.setTimeout`. Перевага: затримки можна скасувати на teardown і
|
||||
* вони не спрацюють після завершення playable.
|
||||
*
|
||||
* Прим.: коли дійде черга до уніфікації часу (план, п.2) — масштаб `TimeC.TimeScale`
|
||||
* додається саме тут, в одному місці.
|
||||
*/
|
||||
export class TickScheduler {
|
||||
private static updateDelegate: Delegate<number> | null = null;
|
||||
private static calls = new Set<ScheduledCall>();
|
||||
|
||||
/** `delaySeconds` — затримка в секундах ігрового часу. */
|
||||
static schedule(delaySeconds: number, callback: () => void): ScheduledCall {
|
||||
this.ensureUpdate();
|
||||
|
||||
const call: ScheduledCall = {
|
||||
remaining: delaySeconds,
|
||||
callback,
|
||||
cancelled: false,
|
||||
};
|
||||
this.calls.add(call);
|
||||
return call;
|
||||
}
|
||||
|
||||
static cancel(call: ScheduledCall | null | undefined) {
|
||||
if (!call) return;
|
||||
call.cancelled = true;
|
||||
this.calls.delete(call);
|
||||
}
|
||||
|
||||
private static ensureUpdate() {
|
||||
if (this.updateDelegate) return;
|
||||
this.updateDelegate = UpdateController.Instance.onUpdate.addDelegate((delta) =>
|
||||
this.update(delta),
|
||||
);
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
if (this.calls.size === 0) return;
|
||||
|
||||
for (const call of [...this.calls]) {
|
||||
if (call.cancelled) {
|
||||
this.calls.delete(call);
|
||||
continue;
|
||||
}
|
||||
|
||||
call.remaining -= delta;
|
||||
if (call.remaining <= 0) {
|
||||
this.calls.delete(call);
|
||||
call.callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class TimeC {
|
||||
static TimeScale = 1;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { UpdateController } from "@24tools/playable_template";
|
||||
import { UI_IMAGES } from "../../resources/images/uiImages";
|
||||
import { TimeC } from "../Timers/TimeC";
|
||||
|
||||
/** Секунд від 0% до 100% при FILL_SPEED = 1 */
|
||||
const INVASION_PROGRESS_TOTAL_SEC = 120;
|
||||
|
||||
/** Множник швидкості заповнення */
|
||||
const INVASION_PROGRESS_FILL_SPEED = 1;
|
||||
|
||||
export class InvasionProgressUIC {
|
||||
private static fillEl: HTMLElement | null = null;
|
||||
private static progressEl: HTMLElement | null = null;
|
||||
private static elapsedSec = 0;
|
||||
private static isRunning = false;
|
||||
|
||||
static init() {
|
||||
const uiRoot = document.getElementById("ui");
|
||||
if (!uiRoot) return;
|
||||
|
||||
const block = document.createElement("div");
|
||||
block.className = "hud-top-center";
|
||||
|
||||
const title = document.createElement("h1");
|
||||
title.className = "hud-invasion-title";
|
||||
title.textContent = "ZOMBIE INVASION";
|
||||
|
||||
const progress = document.createElement("div");
|
||||
progress.className = "hud-progress";
|
||||
|
||||
const track = document.createElement("div");
|
||||
track.className = "hud-progress__track";
|
||||
|
||||
const fill = document.createElement("div");
|
||||
fill.className = "hud-progress__fill";
|
||||
this.fillEl = fill;
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.className = "hud-progress__icon";
|
||||
icon.src = UI_IMAGES.zombieHead;
|
||||
icon.alt = "";
|
||||
|
||||
track.appendChild(fill);
|
||||
progress.appendChild(track);
|
||||
progress.appendChild(icon);
|
||||
this.progressEl = progress;
|
||||
block.appendChild(title);
|
||||
block.appendChild(progress);
|
||||
uiRoot.appendChild(block);
|
||||
|
||||
this.setProgress(0);
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((delta) => this.tick(delta));
|
||||
}
|
||||
|
||||
static startTimer() {
|
||||
this.elapsedSec = 0;
|
||||
this.isRunning = true;
|
||||
this.setProgress(0);
|
||||
}
|
||||
|
||||
private static tick(delta: number) {
|
||||
if (!this.isRunning || !this.fillEl) return;
|
||||
|
||||
this.elapsedSec +=
|
||||
delta * TimeC.TimeScale * INVASION_PROGRESS_FILL_SPEED;
|
||||
|
||||
const ratio = Math.min(
|
||||
1,
|
||||
this.elapsedSec / INVASION_PROGRESS_TOTAL_SEC,
|
||||
);
|
||||
this.setProgress(ratio);
|
||||
|
||||
if (ratio >= 1) {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
static setProgress(ratio: number) {
|
||||
if (!this.fillEl) return;
|
||||
|
||||
const clamped = Math.max(0, Math.min(1, ratio));
|
||||
this.fillEl.style.width = `${clamped * 100}%`;
|
||||
this.progressEl?.style.setProperty("--progress", String(clamped));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export class PreGameplayUIC {
|
||||
private static root: HTMLElement | null = null;
|
||||
|
||||
static init() {
|
||||
const ui = document.getElementById("ui");
|
||||
if (!ui) return;
|
||||
|
||||
ui.classList.add("is-pre-gameplay");
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "pre-gameplay";
|
||||
|
||||
const hurry = document.createElement("p");
|
||||
hurry.className = "pre-gameplay__hurry";
|
||||
hurry.textContent = "HURRY UP";
|
||||
|
||||
const bottom = document.createElement("div");
|
||||
bottom.className = "pre-gameplay__bottom";
|
||||
|
||||
const drag = document.createElement("p");
|
||||
drag.className = "pre-gameplay__drag";
|
||||
drag.textContent = "DRAG TO MOVE";
|
||||
|
||||
const joystick = document.createElement("div");
|
||||
joystick.className = "pre-gameplay-joystick";
|
||||
|
||||
const base = document.createElement("div");
|
||||
base.className = "pre-gameplay-joystick__base";
|
||||
|
||||
const knob = document.createElement("div");
|
||||
knob.className = "pre-gameplay-joystick__knob";
|
||||
|
||||
joystick.append(base, knob);
|
||||
bottom.append(drag, joystick);
|
||||
root.append(hurry, bottom);
|
||||
ui.appendChild(root);
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
static dismiss() {
|
||||
document.getElementById("ui")?.classList.remove("is-pre-gameplay");
|
||||
this.root?.remove();
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Physics_internal } from "@24tools/playable_template";
|
||||
import { Box3, Mesh, Object3D, Quaternion as ThreeQuaternion, Vector3 } from "three";
|
||||
import { Body, Box, Shape, Sphere, Vec3 } from "cannon-es";
|
||||
|
||||
export enum PhysicsLayer {
|
||||
Player = 1,
|
||||
Wall = 2,
|
||||
Trigger = 4,
|
||||
Enemy = 8,
|
||||
}
|
||||
|
||||
/** Єдиний радіус сферичного колайдера гравця (sphere body + тригерна зона). */
|
||||
export const PLAYER_COLLIDER_RADIUS = 0.5;
|
||||
|
||||
export class PhysicsBody {
|
||||
private body: Body;
|
||||
|
||||
constructor(
|
||||
threeObj: Object3D,
|
||||
trigger: boolean,
|
||||
mass: number,
|
||||
col_group: PhysicsLayer,
|
||||
col_mask: PhysicsLayer,
|
||||
player_sphere: number = PLAYER_COLLIDER_RADIUS
|
||||
) {
|
||||
const isPlayer = col_group === PhysicsLayer.Player;
|
||||
|
||||
threeObj.updateWorldMatrix(true, false);
|
||||
|
||||
const bodyPosition = new Vector3();
|
||||
const bodyQuaternion = new ThreeQuaternion();
|
||||
let shape: Shape;
|
||||
|
||||
const mesh = threeObj as Mesh;
|
||||
if (isPlayer) {
|
||||
shape = new Sphere(player_sphere);
|
||||
threeObj.getWorldPosition(bodyPosition);
|
||||
// Sphere orientation is irrelevant -> keep identity quaternion.
|
||||
} else if (mesh.isMesh && mesh.geometry) {
|
||||
// Build a tight ORIENTED box from the mesh's LOCAL geometry bounds.
|
||||
// The old world-space AABB inflated the box whenever a parent was rotated
|
||||
// and shifted it up when the mesh pivot wasn't at the geometry center.
|
||||
mesh.geometry.computeBoundingBox();
|
||||
const bbox = mesh.geometry.boundingBox!;
|
||||
|
||||
const size = new Vector3();
|
||||
bbox.getSize(size).multiply(threeObj.getWorldScale(new Vector3()));
|
||||
size.set(Math.abs(size.x), Math.abs(size.y), Math.abs(size.z));
|
||||
|
||||
bbox.getCenter(bodyPosition);
|
||||
mesh.localToWorld(bodyPosition);
|
||||
threeObj.getWorldQuaternion(bodyQuaternion);
|
||||
|
||||
shape = new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2));
|
||||
} else {
|
||||
// Fallback for non-mesh objects: world-space AABB at its center.
|
||||
const bbox = new Box3().setFromObject(threeObj);
|
||||
const size = new Vector3();
|
||||
bbox.getSize(size);
|
||||
bbox.getCenter(bodyPosition);
|
||||
|
||||
shape = new Box(new Vec3(size.x / 2, size.y / 2, size.z / 2));
|
||||
}
|
||||
|
||||
this.body = new Body({
|
||||
isTrigger: trigger,
|
||||
mass: mass,
|
||||
shape,
|
||||
collisionFilterGroup: col_group,
|
||||
collisionFilterMask: col_mask,
|
||||
});
|
||||
|
||||
this.body.position.set(bodyPosition.x, bodyPosition.y, bodyPosition.z);
|
||||
this.body.quaternion.set(
|
||||
bodyQuaternion.x,
|
||||
bodyQuaternion.y,
|
||||
bodyQuaternion.z,
|
||||
bodyQuaternion.w
|
||||
);
|
||||
|
||||
Physics_internal.physicsWorld &&
|
||||
Physics_internal.physicsWorld.addBody(this.body);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getPhysicsBody() {
|
||||
return this.body;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (!Physics_internal.physicsWorld) return;
|
||||
|
||||
Physics_internal.physicsWorld.removeBody(this.body);
|
||||
|
||||
(this.body as any) = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ThreeC } from "./ThreeC";
|
||||
import { Player } from "../Player/Player";
|
||||
import { Map } from "../Map/Map";
|
||||
import { GatherC } from "../Map/GatherC";
|
||||
import { DepositZoneC } from "../Map/DepositZoneC";
|
||||
import { ResourceUIC } from "../Resources/ResourceUIC";
|
||||
import { ResourceFlyC } from "../Resources/ResourceFlyC";
|
||||
import { ResourceDepositC } from "../Resources/ResourceDepositC";
|
||||
import { PropHpUIC } from "../Props/PropHpUIC";
|
||||
import { PropVfxC } from "../Props/PropVfxC";
|
||||
import { InvasionProgressUIC } from "../UI/InvasionProgressUIC";
|
||||
import { PreGameplayUIC } from "../UI/PreGameplayUIC";
|
||||
|
||||
export class TestSceneC {
|
||||
static init() {
|
||||
InvasionProgressUIC.init();
|
||||
ResourceUIC.init();
|
||||
PreGameplayUIC.init();
|
||||
ResourceFlyC.init();
|
||||
PropHpUIC.init();
|
||||
PropVfxC.Init();
|
||||
Map.init();
|
||||
PropHpUIC.bringOverlayToFront();
|
||||
GatherC.init();
|
||||
Player.init();
|
||||
PropHpUIC.bringOverlayToFront();
|
||||
|
||||
const mapObject = ThreeC.getObject("map");
|
||||
if (mapObject) {
|
||||
DepositZoneC.init(mapObject);
|
||||
}
|
||||
|
||||
ResourceDepositC.init();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ColorFormat } from "@24tools/ads_common";
|
||||
import { ThreeC_internal, Template } from "@24tools/playable_template";
|
||||
import { AmbientLight, DirectionalLight } from "three";
|
||||
import { AmbientLight, DirectionalLight, HemisphereLight } from "three";
|
||||
import { Color } from "three/src/math/Color";
|
||||
|
||||
export class ThreeC extends ThreeC_internal {
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Vec3 } from "cannon-es";
|
||||
import { Vector3 } from "three";
|
||||
|
||||
/**
|
||||
* Vec3(cannon) To Vector3(three)
|
||||
*/
|
||||
export function Vector3CToT(value: Vec3) {
|
||||
return new Vector3(value.x, value.y, value.z);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vector3(three) To Vec3(cannon)
|
||||
*/
|
||||
export function Vector3TToC(value: Vector3) {
|
||||
return new Vec3(value.x, value.y, value.z);
|
||||
}
|
||||
@@ -95,18 +95,18 @@ canvas {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#editor {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#ui,
|
||||
#editor {
|
||||
|
||||
#ui {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(100vh * 9 / 16);
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
padding: 2vw;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#modal {
|
||||
@@ -129,6 +129,18 @@ canvas {
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
#joystick_zone .joystick .back {
|
||||
background: transparent !important;
|
||||
border: 0.35vh solid #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#joystick_zone .joystick .front {
|
||||
background: rgba(255, 255, 255, 0.15) !important;
|
||||
border: 0.2vh solid #ffffff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
#videoPlayable {
|
||||
height: 100vh;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#ui.is-pre-gameplay .hud-invasion-title,
|
||||
#ui.is-pre-gameplay .hud-progress,
|
||||
#ui.is-pre-gameplay .hud-top-right,
|
||||
#ui.is-pre-gameplay .hud-bottom-left {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pre-gameplay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
.pre-gameplay__hurry {
|
||||
position: absolute;
|
||||
top: 14vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
font-size: 5vh;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
-webkit-text-stroke: 0.14vh #000000;
|
||||
paint-order: stroke fill;
|
||||
animation: pre-gameplay-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pre-gameplay__bottom {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 10vh;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2.5vh;
|
||||
}
|
||||
|
||||
.pre-gameplay__drag {
|
||||
margin: 0;
|
||||
font-size: 4.2vh;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
-webkit-text-stroke: 0.12vh #000000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.pre-gameplay-joystick {
|
||||
position: relative;
|
||||
width: 22vh;
|
||||
height: 22vh;
|
||||
}
|
||||
|
||||
.pre-gameplay-joystick__base {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 0.35vh solid #ffffff;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pre-gameplay-joystick__knob {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 8vh;
|
||||
height: 8vh;
|
||||
margin-left: -4vh;
|
||||
margin-top: -4vh;
|
||||
border-radius: 50%;
|
||||
border: 0.2vh solid #ffffff;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
box-sizing: border-box;
|
||||
animation: pre-gameplay-joystick-knob 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pre-gameplay-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%) scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pre-gameplay-joystick-knob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
25% {
|
||||
transform: translate(5vh, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(0, -3.5vh);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-5vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
.pre-gameplay__hurry {
|
||||
top: 8vh;
|
||||
font-size: 4vh;
|
||||
}
|
||||
|
||||
.pre-gameplay__bottom {
|
||||
bottom: 6vh;
|
||||
gap: 1.5vh;
|
||||
}
|
||||
|
||||
.pre-gameplay__drag {
|
||||
font-size: 3.2vh;
|
||||
}
|
||||
|
||||
.pre-gameplay-joystick {
|
||||
width: 18vh;
|
||||
height: 18vh;
|
||||
}
|
||||
|
||||
.pre-gameplay-joystick__knob {
|
||||
width: 6.5vh;
|
||||
height: 6.5vh;
|
||||
margin-left: -3.25vh;
|
||||
margin-top: -3.25vh;
|
||||
}
|
||||
|
||||
@keyframes pre-gameplay-joystick-knob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
25% {
|
||||
transform: translate(4vh, 0);
|
||||
}
|
||||
50% {
|
||||
transform: translate(0, -2.8vh);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-4vh, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,245 @@
|
||||
#ui {
|
||||
/* flex-basis: 60%; */
|
||||
flex-grow: 1;
|
||||
.hud-top-center {
|
||||
position: absolute;
|
||||
top: 2vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2vh;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hud-invasion-title {
|
||||
margin: 0;
|
||||
font-size: 3.2vh;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
-webkit-text-stroke: 0.12vh #000000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.hud-progress {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 3.2vh;
|
||||
max-width: 55vw;
|
||||
}
|
||||
|
||||
.hud-progress__track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0.35vh solid #000000;
|
||||
border-radius: 0.5vh;
|
||||
background: linear-gradient(180deg, #5fe86a 0%, #2db83a 100%);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hud-progress__fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #1a1a1a;
|
||||
border-radius: 0.15vh;
|
||||
}
|
||||
|
||||
.hud-progress__icon {
|
||||
position: absolute;
|
||||
left: clamp(
|
||||
2.75vh,
|
||||
calc(var(--progress, 0) * 100%),
|
||||
calc(100% - 2.75vh)
|
||||
);
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 5.5vh;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-top-right {
|
||||
position: absolute;
|
||||
top: 5.5vh;
|
||||
right: 2vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 3vh;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hud-avatar {
|
||||
width: 8vh;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.resource-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 1vh;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.resource-counter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 12vh;
|
||||
height: 4vh;
|
||||
padding: 0 9vh 0 3vh;
|
||||
background-color: transparent;
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.resource-icon {
|
||||
position: absolute;
|
||||
right: 1.2vh;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4.5vh;
|
||||
height: 4.5vh;
|
||||
border-radius: 0.4vh;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resource-count {
|
||||
min-width: 2ch;
|
||||
color: #ffffff;
|
||||
font-size: 2.4vh;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
-webkit-text-stroke: 0.15vh #000000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.hud-bottom-left {
|
||||
position: absolute;
|
||||
left: 2vw;
|
||||
bottom: 30vh;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hud-weapon {
|
||||
position: relative;
|
||||
width: 9vh;
|
||||
}
|
||||
|
||||
.hud-weapon__bg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hud-weapon__tool {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 38%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 58%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud-weapon__level {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 14%;
|
||||
transform: translateX(-50%);
|
||||
color: #ffffff;
|
||||
font-size: 1.8vh;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
-webkit-text-stroke: 0.12vh #000000;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
.resource-fly-pickup {
|
||||
position: fixed;
|
||||
width: 3vh;
|
||||
height: 3vh;
|
||||
border-radius: 0.4vh;
|
||||
border: 0.15vh solid rgba(255, 255, 255, 0.5);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
.hud-top-center {
|
||||
top: 1.2vh;
|
||||
width: 55%;
|
||||
max-width: 36vh;
|
||||
gap: 0.5vh;
|
||||
}
|
||||
|
||||
.hud-invasion-title {
|
||||
font-size: 5vh;
|
||||
}
|
||||
|
||||
.hud-progress {
|
||||
height: 2.6vh;
|
||||
}
|
||||
|
||||
.hud-progress__icon {
|
||||
height: 4.2vh;
|
||||
}
|
||||
|
||||
.hud-top-right {
|
||||
top: 1vh;
|
||||
right: 1.5vw;
|
||||
gap: 0.5vh;
|
||||
}
|
||||
|
||||
.hud-avatar {
|
||||
width: 15vh;
|
||||
}
|
||||
|
||||
.resource-counter {
|
||||
width: 15vh;
|
||||
height: 6vh;
|
||||
padding: 0 7.5vh 0 2.5vh;
|
||||
}
|
||||
|
||||
.resource-icon {
|
||||
width: 3.8vh;
|
||||
height: 3.8vh;
|
||||
right: 1vh;
|
||||
}
|
||||
|
||||
.resource-count {
|
||||
font-size:3.5vh;
|
||||
}
|
||||
|
||||
.hud-bottom-left {
|
||||
left: 20px;
|
||||
bottom: 22vh;
|
||||
}
|
||||
|
||||
.hud-weapon {
|
||||
width: 16vh;
|
||||
}
|
||||
|
||||
.hud-weapon__level {
|
||||
font-size: 1.5vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { FontFamily, formFontFamily } from "@24tools/ads_common";
|
||||
|
||||
export const customFont: undefined | Promise<FontFamily> = undefined
|
||||
export const customFont: Promise<FontFamily> = formFontFamily(
|
||||
"Passion One",
|
||||
"./PassionOne-Black.otf"
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<link href="./css/loader.css" rel="stylesheet" />
|
||||
<link href="./css/main.css" rel="stylesheet" />
|
||||
<link href="./css/ui.css" rel="stylesheet" />
|
||||
<link href="./css/preGameplay.css" rel="stylesheet" />
|
||||
<style id="animations"></style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -33,7 +34,6 @@
|
||||
|
||||
<div id="constructor">
|
||||
<div id="ui"></div>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
<script id="dev-start">
|
||||
window.onload = function () {
|
||||
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 814 B |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,13 @@
|
||||
import woodCounterBg from "./ResourceBackground_Wood.webp";
|
||||
import avatarTop from "./ZombiePunk_Icon-Top.webp";
|
||||
import toolBg from "./Tool_Backgtound.webp";
|
||||
import toolBat from "./Tool_2.webp";
|
||||
import zombieHead from "./Icon_Zombie_Head.webp";
|
||||
|
||||
export const UI_IMAGES = {
|
||||
woodCounterBg,
|
||||
avatarTop,
|
||||
toolBg,
|
||||
toolBat,
|
||||
zombieHead,
|
||||
} as const;
|
||||
@@ -13,6 +13,14 @@ export const meshes : ConvertResourceType = {
|
||||
name: "character",
|
||||
value: ConvertToBase64WhenRelease("./ZombiePunk_Character.glb"),
|
||||
},
|
||||
{
|
||||
name: "wood",
|
||||
value: ConvertToBase64WhenRelease("./wood.glb"),
|
||||
},
|
||||
{
|
||||
name: "hpBar",
|
||||
value: ConvertToBase64WhenRelease("./hpBar.glb"),
|
||||
},
|
||||
],
|
||||
loader: Template3d.meshLoader
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ConvertResourcesType } from "@24tools/playable_template";
|
||||
import { meshes } from "./meshes/meshes";
|
||||
import { sounds } from "./sounds/sounds";
|
||||
import { vfx } from "./vfx/vfx";
|
||||
|
||||
export const resources: ConvertResourcesType = [meshes, sounds];
|
||||
export const resources: ConvertResourcesType = [meshes, sounds, vfx];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
|
||||
import { ConvertResourceType, quarksLoader } from "@24tools/playable_template";
|
||||
|
||||
export enum VfxType {
|
||||
LootableHit = "lootable_hit",
|
||||
LootableDestroy = "lootable_destroy",
|
||||
}
|
||||
|
||||
export const vfx: ConvertResourceType = {
|
||||
type: "vfx",
|
||||
resources: [
|
||||
{
|
||||
name: VfxType.LootableHit,
|
||||
value: ConvertToBase64WhenRelease("./VFX_Lootable_Hit.json"),
|
||||
},
|
||||
{
|
||||
name: VfxType.LootableDestroy,
|
||||
value: ConvertToBase64WhenRelease("./VFX_Lootable_Destroy.json"),
|
||||
},
|
||||
],
|
||||
loader: quarksLoader,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TestSceneC } from "../controllers/TestSceneC";
|
||||
import { TestSceneC } from "../controllers/core/TestSceneC";
|
||||
import { SoundC, Template } from "@24tools/playable_template";
|
||||
|
||||
export const afterResourcesLoadedCb: (() => void) | undefined = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
CameraC_internal,
|
||||
CameraType,
|
||||
// JoystickC,
|
||||
JoystickC,
|
||||
Physics_internal,
|
||||
Template,
|
||||
Template3d,
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
FilterScene,
|
||||
InstallBanner,
|
||||
} from "@24tools/playable_template";
|
||||
import { CameraC } from "../controllers/CameraC";
|
||||
import { ThreeC } from "../controllers/ThreeC";
|
||||
import { CameraC } from "../controllers/Camera/CameraC";
|
||||
import { ThreeC } from "../controllers/core/ThreeC";
|
||||
import { Player } from "../controllers/Player/Player";
|
||||
import { PlayerInput } from "../controllers/Player/Input/PlayerInput";
|
||||
import { Color } from "three";
|
||||
import { Vec3 } from "cannon-es";
|
||||
export const beforeResourcesLoadedCb = () => {
|
||||
@@ -23,20 +25,22 @@ export const beforeResourcesLoadedCb = () => {
|
||||
ThreeC_internal.init();
|
||||
ThreeC.createBaseLights();
|
||||
ThreeC.setupDirectionalLight();
|
||||
PlayerInput.initJoystick();
|
||||
|
||||
let physicsWorld = Physics_internal.init(new Vec3(0, -9.81, 0));
|
||||
|
||||
let physicsWorld = Physics_internal.init(new Vec3(0, 0, 0));
|
||||
|
||||
// example of using joystick. Uncomment if you need joystick
|
||||
|
||||
// JoystickC.init({
|
||||
// zone: document.getElementById("joystick_zone") as HTMLDivElement,
|
||||
// fadeTime: 0,
|
||||
// mode: "dynamic",
|
||||
// restJoystick: true,
|
||||
// catchDistance: 1,
|
||||
// restOpacity: 0,
|
||||
// follow: false,
|
||||
// });
|
||||
// JoystickC.init({
|
||||
// zone: document.getElementById("joystick_zone") as HTMLDivElement,
|
||||
// fadeTime: 0,
|
||||
// mode: "dynamic",
|
||||
// restJoystick: true,
|
||||
// catchDistance: 1,
|
||||
// restOpacity: 0,
|
||||
// follow: false,
|
||||
// });
|
||||
|
||||
// JoystickC.onJoysticMove.addDelegate(({event, data}) => {
|
||||
// console.log('onJoysticMove', event, data);
|
||||
@@ -70,5 +74,12 @@ export const beforeResourcesLoadedCb = () => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (category === "character") {
|
||||
if (variable === "movement_speed") {
|
||||
Player.setMoveSpeed(Number(value));
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { InvasionProgressUIC } from "../controllers/UI/InvasionProgressUIC";
|
||||
import { PreGameplayUIC } from "../controllers/UI/PreGameplayUIC";
|
||||
|
||||
export const firstClickCb: () => void = () => {
|
||||
console.log("First click");
|
||||
PreGameplayUIC.dismiss();
|
||||
InvasionProgressUIC.startTimer();
|
||||
};
|
||||
@@ -1 +1,11 @@
|
||||
export const gameRedirectedCb : (() => void) | undefined = undefined
|
||||
import { ResourceFlyC } from "../controllers/Resources/ResourceFlyC";
|
||||
import { ResourceDepositC } from "../controllers/Resources/ResourceDepositC";
|
||||
import { InteractiveZoneC } from "../controllers/Map/InteractiveZoneC";
|
||||
import { ToolZoneC } from "../controllers/Map/ToolZoneC";
|
||||
|
||||
export const gameRedirectedCb: (() => void) | undefined = () => {
|
||||
ResourceFlyC.teardown();
|
||||
ResourceDepositC.teardown();
|
||||
InteractiveZoneC.dispose();
|
||||
ToolZoneC.dispose();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Template3d } from "@24tools/playable_template";
|
||||
import { CameraC } from "../controllers/Camera/CameraC";
|
||||
|
||||
export const resizeCb = () => {
|
||||
Template3d.resize();
|
||||
CameraC.setCamera(window.screenSize.portrait);
|
||||
};
|
||||
|
||||