Compare commits

..

12 Commits

Author SHA1 Message Date
Vasyl Kazakov f922fd53ae fixed bug for platfrom 2026-06-19 11:56:35 +03:00
Vasyl Kazakov ce3f254bc1 replaced files 2026-06-18 16:47:23 +03:00
Vasyl Kazakov 7cc63dbba9 add category for platform 2026-06-18 15:40:29 +03:00
Vasyl Kazakov 852e1ec963 new animation, refactor, fixes 2026-06-18 12:45:48 +03:00
Vasyl Kazakov 930ed00c95 fixed bugs 2026-06-17 17:06:13 +03:00
Vasyl Kazakov adf4e4b9bf fixed bugs 2026-06-03 11:49:30 +03:00
Vasyl Kazakov e14b97b5e7 animation, gathering, deposit, ui 2026-06-02 18:22:20 +03:00
Vasyl Kazakov 6363396f84 day 5 2026-05-29 18:01:05 +03:00
Vasyl Kazakov 2633be60a1 day 4 2026-05-28 18:35:39 +03:00
Vasyl Kazakov aefe01860e impot models and set camera 2026-05-27 12:19:17 +03:00
Vasyl Kazakov 17b2ef4267 commit second 2026-05-26 12:54:16 +03:00
Vasyl Kazakov b7e527ac6c commit 2026-05-26 12:53:01 +03:00
86 changed files with 4515 additions and 226 deletions
+2 -1
View File
@@ -21,7 +21,8 @@
"cannon-es-debugger": "^1.0.0", "cannon-es-debugger": "^1.0.0",
"howler": "^2.2.4", "howler": "^2.2.4",
"nipplejs": "^1.0.3", "nipplejs": "^1.0.3",
"three": "^0.184.0" "three": "^0.184.0",
"three.quarks": "^0.17.1"
}, },
"devDependencies": { "devDependencies": {
"@types/howler": "^2.2.13", "@types/howler": "^2.2.13",
+20
View File
@@ -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
]
},
]
}
]
+2
View File
@@ -5,10 +5,12 @@ import { analytics } from "./analytics";
//@ts-ignore //@ts-ignore
import { installBanner } from "./installBanner"; import { installBanner } from "./installBanner";
import { globalSettings } from "./globalSettings"; import { globalSettings } from "./globalSettings";
import { characterSettings } from "./characterSettings";
export const configUIParams: ConfigUiParamsCategories[][] = [ export const configUIParams: ConfigUiParamsCategories[][] = [
analytics, analytics,
installBanner, installBanner,
globalSettings, globalSettings,
characterSettings,
sounds, sounds,
]; ];
+18 -20
View File
@@ -3,7 +3,7 @@ import { ConfigUiParamsCategories } from "@24tools/ads_common";
export const globalSettings: ConfigUiParamsCategories[] = [ export const globalSettings: ConfigUiParamsCategories[] = [
{ {
id: "global", id: "global",
name: "Scene settings", name: "Global settings",
params: [ params: [
{ {
id: "light_intensity", id: "light_intensity",
@@ -30,7 +30,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
values: [ values: [
30, 30,
150, 150,
60 50
] ]
}, },
{ {
@@ -38,22 +38,21 @@ export const globalSettings: ConfigUiParamsCategories[] = [
name: "Camera position (portrait)", name: "Camera position (portrait)",
type: "float", type: "float",
array: true, array: true,
visible: "position",
values: [ values: [
[ [
-10, -10,
10, 10,
0 -4
],
[
0,
15,
11
], ],
[ [
-10, -10,
10, 10,
1 -7
],
[
-10,
10,
5
] ]
] ]
}, },
@@ -67,7 +66,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-360, -360,
360, 360,
0 2
], ],
[ [
-360, -360,
@@ -88,7 +87,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
values: [ values: [
30, 30,
150, 150,
60 30
] ]
}, },
{ {
@@ -96,22 +95,21 @@ export const globalSettings: ConfigUiParamsCategories[] = [
name: "Camera position (landscape)", name: "Camera position (landscape)",
type: "float", type: "float",
array: true, array: true,
visible: "position",
values: [ values: [
[ [
-10, -10,
10, 10,
0 -4
],
[
0,
15,
11
], ],
[ [
-10, -10,
10, 10,
1 -7
],
[
-10,
10,
5
] ]
] ]
}, },
@@ -5,16 +5,12 @@ export class CameraC extends CameraC_internal {
static setCamera(portraitOrientation: boolean) { static setCamera(portraitOrientation: boolean) {
const CATEGORY = Template.getCategory("global"); const CATEGORY = Template.getCategory("global");
if (this.camera !== null) { 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 const rotation = portraitOrientation
? Helper.returnEulerCamera(CATEGORY["camera_rotation_p"] as number[]) ? Helper.returnEulerCamera(CATEGORY["camera_rotation_p"] as number[])
: Helper.returnEulerCamera(CATEGORY["camera_rotation_l"] as number[]); : Helper.returnEulerCamera(CATEGORY["camera_rotation_l"] as number[]);
this.camera.rotation.x = rotation.x; this.camera.rotation.x = rotation.x;
this.camera.rotation.y = rotation.y; this.camera.rotation.y = rotation.y;
this.camera.rotation.z = rotation.z; this.camera.rotation.z = rotation.z;
this.camera.position.copy(position.clone());
if (this.camera instanceof PerspectiveCamera) { if (this.camera instanceof PerspectiveCamera) {
this.camera.fov = portraitOrientation this.camera.fov = portraitOrientation
? Number(CATEGORY["camera_fov_p"]) ? Number(CATEGORY["camera_fov_p"])
+92
View File
@@ -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);
}
}
+10
View File
@@ -0,0 +1,10 @@
export enum BaseAnimation {
Nan = -1,
Idle = 0,
Run = 1,
WeaponTakeOut = 2,
+3
View File
@@ -0,0 +1,3 @@
export enum MeshType {
Character = "character",
}
+3
View File
@@ -0,0 +1,3 @@
export enum PropType {
Box = "box",
}
+3
View File
@@ -0,0 +1,3 @@
export enum ResourceType {
Wood = "wood",
}
+4
View File
@@ -0,0 +1,4 @@
export enum ResourcesType {
Mesh = "mesh",
VFX = "vfx",
}
+4
View File
@@ -0,0 +1,4 @@
export enum VFXType {
LootableHit = "lootable_hit",
LootableDestroy = "lootable_destroy",
}
+72
View File
@@ -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({});
}
}
+189
View File
@@ -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({});
}
}
+153
View File
@@ -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);
});
}
}
+137
View File
@@ -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;
}
}
+47
View File
@@ -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);
}
+116
View File
@@ -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);
}
}
}
}
+46
View File
@@ -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;
}
}
-129
View File
@@ -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;
}
}
+30
View File
@@ -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);
}
}
+212
View File
@@ -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 (01) 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);
}
}
+243
View File
@@ -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;
}
}
+26
View File
@@ -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)!);
}
+65
View File
@@ -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,
};
}
+32
View File
@@ -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;
}
+352
View File
@@ -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;
}
}
+123
View File
@@ -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!;
}
}
+67
View File
@@ -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();
}
}
+245
View File
@@ -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);
}
}
}
+113
View File
@@ -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));
}
}
-42
View File
@@ -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);
}
}
+63
View File
@@ -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();
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export class TimeC {
static TimeScale = 1;
}
+86
View File
@@ -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));
}
}
+45
View File
@@ -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;
}
}
+98
View File
@@ -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;
}
}
+35
View File
@@ -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 { ColorFormat } from "@24tools/ads_common";
import { ThreeC_internal, Template } from "@24tools/playable_template"; 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"; import { Color } from "three/src/math/Color";
export class ThreeC extends ThreeC_internal { export class ThreeC extends ThreeC_internal {
+16
View File
@@ -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);
}
+18 -6
View File
@@ -95,18 +95,18 @@ canvas {
padding: 20px; padding: 20px;
} }
#editor {
pointer-events: none;
}
#ui,
#editor { #ui {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: calc(100vh * 9 / 16); width: 100%;
height: 100vh; height: 100vh;
pointer-events: none;
padding: 2vw;
box-sizing: border-box;
} }
#modal { #modal {
@@ -129,6 +129,18 @@ canvas {
z-index: 900; 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) { @media (orientation: landscape) {
#videoPlayable { #videoPlayable {
height: 100vh; height: 100vh;
+157
View File
@@ -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);
}
}
}
+244 -3
View File
@@ -1,4 +1,245 @@
#ui { .hud-top-center {
/* flex-basis: 60%; */ position: absolute;
flex-grow: 1; 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;
}
} }
+4 -1
View File
@@ -1,3 +1,6 @@
import { FontFamily, formFontFamily } from "@24tools/ads_common"; 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"
);
+1 -1
View File
@@ -12,6 +12,7 @@
<link href="./css/loader.css" rel="stylesheet" /> <link href="./css/loader.css" rel="stylesheet" />
<link href="./css/main.css" rel="stylesheet" /> <link href="./css/main.css" rel="stylesheet" />
<link href="./css/ui.css" rel="stylesheet" /> <link href="./css/ui.css" rel="stylesheet" />
<link href="./css/preGameplay.css" rel="stylesheet" />
<style id="animations"></style> <style id="animations"></style>
</head> </head>
<body> <body>
@@ -33,7 +34,6 @@
<div id="constructor"> <div id="constructor">
<div id="ui"></div> <div id="ui"></div>
<div id="editor"></div>
</div> </div>
<script id="dev-start"> <script id="dev-start">
window.onload = function () { window.onload = function () {
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+13
View File
@@ -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;
Binary file not shown.
+8
View File
@@ -13,6 +13,14 @@ export const meshes : ConvertResourceType = {
name: "character", name: "character",
value: ConvertToBase64WhenRelease("./ZombiePunk_Character.glb"), value: ConvertToBase64WhenRelease("./ZombiePunk_Character.glb"),
}, },
{
name: "wood",
value: ConvertToBase64WhenRelease("./wood.glb"),
},
{
name: "hpBar",
value: ConvertToBase64WhenRelease("./hpBar.glb"),
},
], ],
loader: Template3d.meshLoader loader: Template3d.meshLoader
} }
Binary file not shown.
+2 -1
View File
@@ -1,5 +1,6 @@
import { ConvertResourcesType } from "@24tools/playable_template"; import { ConvertResourcesType } from "@24tools/playable_template";
import { meshes } from "./meshes/meshes"; import { meshes } from "./meshes/meshes";
import { sounds } from "./sounds/sounds"; import { sounds } from "./sounds/sounds";
import { vfx } from "./vfx/vfx";
export const resources: ConvertResourcesType = [meshes, sounds]; export const resources: ConvertResourcesType = [meshes, sounds, vfx];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { TestSceneC } from "../controllers/TestSceneC"; import { TestSceneC } from "../controllers/core/TestSceneC";
import { SoundC, Template } from "@24tools/playable_template"; import { SoundC, Template } from "@24tools/playable_template";
export const afterResourcesLoadedCb: (() => void) | undefined = () => { export const afterResourcesLoadedCb: (() => void) | undefined = () => {
+24 -13
View File
@@ -1,7 +1,7 @@
import { import {
CameraC_internal, CameraC_internal,
CameraType, CameraType,
// JoystickC, JoystickC,
Physics_internal, Physics_internal,
Template, Template,
Template3d, Template3d,
@@ -9,8 +9,10 @@ import {
FilterScene, FilterScene,
InstallBanner, InstallBanner,
} from "@24tools/playable_template"; } from "@24tools/playable_template";
import { CameraC } from "../controllers/CameraC"; import { CameraC } from "../controllers/Camera/CameraC";
import { ThreeC } from "../controllers/ThreeC"; import { ThreeC } from "../controllers/core/ThreeC";
import { Player } from "../controllers/Player/Player";
import { PlayerInput } from "../controllers/Player/Input/PlayerInput";
import { Color } from "three"; import { Color } from "three";
import { Vec3 } from "cannon-es"; import { Vec3 } from "cannon-es";
export const beforeResourcesLoadedCb = () => { export const beforeResourcesLoadedCb = () => {
@@ -23,20 +25,22 @@ export const beforeResourcesLoadedCb = () => {
ThreeC_internal.init(); ThreeC_internal.init();
ThreeC.createBaseLights(); ThreeC.createBaseLights();
ThreeC.setupDirectionalLight(); 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 // example of using joystick. Uncomment if you need joystick
// JoystickC.init({ // JoystickC.init({
// zone: document.getElementById("joystick_zone") as HTMLDivElement, // zone: document.getElementById("joystick_zone") as HTMLDivElement,
// fadeTime: 0, // fadeTime: 0,
// mode: "dynamic", // mode: "dynamic",
// restJoystick: true, // restJoystick: true,
// catchDistance: 1, // catchDistance: 1,
// restOpacity: 0, // restOpacity: 0,
// follow: false, // follow: false,
// }); // });
// JoystickC.onJoysticMove.addDelegate(({event, data}) => { // JoystickC.onJoysticMove.addDelegate(({event, data}) => {
// console.log('onJoysticMove', event, data); // console.log('onJoysticMove', event, data);
@@ -70,5 +74,12 @@ export const beforeResourcesLoadedCb = () => {
} }
return; return;
} }
if (category === "character") {
if (variable === "movement_speed") {
Player.setMoveSpeed(Number(value));
}
return;
}
}); });
}; };
+5 -1
View File
@@ -1,3 +1,7 @@
import { InvasionProgressUIC } from "../controllers/UI/InvasionProgressUIC";
import { PreGameplayUIC } from "../controllers/UI/PreGameplayUIC";
export const firstClickCb: () => void = () => { export const firstClickCb: () => void = () => {
console.log("First click"); PreGameplayUIC.dismiss();
InvasionProgressUIC.startTimer();
}; };
+11 -1
View File
@@ -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();
};
+2
View File
@@ -1,5 +1,7 @@
import { Template3d } from "@24tools/playable_template"; import { Template3d } from "@24tools/playable_template";
import { CameraC } from "../controllers/Camera/CameraC";
export const resizeCb = () => { export const resizeCb = () => {
Template3d.resize(); Template3d.resize();
CameraC.setCamera(window.screenSize.portrait);
}; };