animation, gathering, deposit, ui

This commit is contained in:
Vasyl Kazakov
2026-06-02 18:22:20 +03:00
parent 6363396f84
commit e14b97b5e7
61 changed files with 2379 additions and 539 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",
+11 -11
View File
@@ -43,17 +43,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-10, -10,
10, 10,
6 -4
], ],
[ [
0, 0,
8, 15,
9 11
], ],
[ [
-10, -10,
10, 10,
7 -7
] ]
] ]
}, },
@@ -67,7 +67,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-360, -360,
360, 360,
-5 0
], ],
[ [
-360, -360,
@@ -88,7 +88,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
values: [ values: [
30, 30,
150, 150,
55 30
] ]
}, },
{ {
@@ -101,17 +101,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-10, -10,
10, 10,
2 -4
], ],
[ [
0, 0,
8, 15,
5 9
], ],
[ [
-10, -10,
10, 10,
3 -7
] ]
] ]
}, },
@@ -125,7 +125,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [
[ [
-360, -360,
360, 360,
0 5
], ],
[ [
-360, -360,
+4
View File
@@ -0,0 +1,4 @@
export enum VFXType {
LootableHit = "lootable_hit",
LootableDestroy = "lootable_destroy",
}
+71
View File
@@ -0,0 +1,71 @@
import { EasyEvent, UpdateController } from "@24tools/playable_template";
import { Box3, Object3D, Vector3 } from "three";
import { Player } from "../Presets/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() {
this.getPlayerPosition(this.playerPosition);
refreshInteractiveZoneBounds(this.zone!, this.bounds);
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({});
}
}
+102 -15
View File
@@ -1,70 +1,140 @@
import { JoystickC, UpdateController } from "@24tools/playable_template"; import { EasyEvent, JoystickC, UpdateController } from "@24tools/playable_template";
import { Vector3 } from "three"; import { Vector3 } from "three";
import { PhysicsTriggerC } from "./PhysicsTriggerC"; import { PhysicsTriggerC } from "./PhysicsTriggerC";
import { PropC, PropRegistry } from "./PropC"; import { PropC, PropRegistry } from "./PropC";
import { Player } from "../Presets/Player"; import { Player } from "../Presets/Player";
export class GatherC { export class GatherC {
static readonly onCombatIdle = new EasyEvent<{}>();
private static activeProps = new Set<PropC>(); private static activeProps = new Set<PropC>();
private static pendingAutoAttack = false; private static pendingAutoAttack = false;
private static combatIdleNotified = false;
static init() { static init() {
PropC.onBroken.addDelegate(() => this.onPropDestroyed());
PhysicsTriggerC.onTriggerEnter.addDelegate((payload) => { PhysicsTriggerC.onTriggerEnter.addDelegate((payload) => {
const prop = PropRegistry.get(payload.lootableObject); const prop = PropRegistry.get(payload.lootableObject);
if (!prop || prop.isBroken) return; if (!prop || prop.isBroken) return;
console.log(`[GatherC] enter gather zone: ${payload.lootableObject.name}`);
this.activeProps.add(prop); this.activeProps.add(prop);
this.scheduleAutoAttackCheck(); this.scheduleAutoAttackCheck();
}); });
PhysicsTriggerC.onTriggerExit.addDelegate((payload) => { PhysicsTriggerC.onTriggerExit.addDelegate((payload) => {
console.log(`[GatherC] exit gather zone: ${payload.lootableObject.name}`);
const prop = PropRegistry.get(payload.lootableObject); const prop = PropRegistry.get(payload.lootableObject);
if (prop) this.activeProps.delete(prop); if (prop) this.activeProps.delete(prop);
this.removeBrokenProps(); this.removeBrokenProps();
this.resyncActivePropsFromPhysics();
if (this.getAttackableProps().length === 0) { this.handleRemainingTargets();
Player.stopAutoAttack();
}
}); });
JoystickC.onJoysticEnd.addDelegate(() => { JoystickC.onJoysticEnd.addDelegate(() => {
this.scheduleAutoAttackCheck(); this.scheduleAutoAttackCheck();
}); });
UpdateController.Instance.onUpdate.addDelegate(() => { JoystickC.onJoysticMove.addDelegate(() => {
if (!this.pendingAutoAttack) return; if (!Player.isMoving()) {
this.pendingAutoAttack = false; this.scheduleAutoAttackCheck();
this.tryStartAutoAttack(); }
}); });
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() { private static scheduleAutoAttackCheck() {
this.pendingAutoAttack = true; this.pendingAutoAttack = true;
} }
private static onAttackHit() { private static onAttackStrike() {
for (const prop of this.getAttackableProps()) { for (const prop of this.getAttackableProps()) {
prop.takeDamage(1); prop.takeDamage(1);
} }
this.removeBrokenProps(); this.removeBrokenProps();
this.resyncActivePropsFromPhysics();
this.handleRemainingTargets();
}
if (this.getAttackableProps().length === 0) { private static tryResumeAutoAttack() {
Player.stopAutoAttack(); 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) {
if (Player.isAutoAttackActive() || Player.isCombatBusy()) {
Player.stopAutoAttack();
}
this.notifyCombatIdleIfNeeded();
return; return;
} }
Player.playAttack(() => this.onAttackHit()); 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() { private static tryStartAutoAttack() {
const props = this.getAttackableProps(); const props = this.getAttackableProps();
if (props.length === 0) return; if (props.length === 0) return;
if (Player.isMoving()) return; if (Player.isMoving() || Player.isCombatBusy()) return;
const targetPosition = this.getTargetPosition(props); const targetPosition = this.getTargetPosition(props);
Player.startAutoAttack(() => this.onAttackHit(), targetPosition); 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 { private static getTargetPosition(props: PropC[]): Vector3 {
@@ -93,4 +163,21 @@ export class GatherC {
if (prop.isBroken) this.activeProps.delete(prop); 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({});
}
} }
+83
View File
@@ -0,0 +1,83 @@
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);
export class InteractiveZoneC {
private static zoneRoot: Object3D | null = null;
private static deposited = 0;
private static baseColors = new WeakMap<Material, Color>();
static init(mapObject: Object3D) {
const zone = findInteractiveZoneFromMap(mapObject);
if (!zone) {
console.warn(`Interactive zone not found: ${INTERACTIVE_ZONE_NAME}`);
return;
}
this.zoneRoot = zone;
this.setupMaterials(zone);
}
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();
}
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());
}
mesh.material = cloned;
});
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);
});
}
}
+10 -3
View File
@@ -3,13 +3,16 @@ import { ThreeC } from "../ThreeC";
import { PhysicsBody, PhysicsLayer } from "../PhysicsC"; import { PhysicsBody, PhysicsLayer } from "../PhysicsC";
import { PhysicsTriggerC } from "./PhysicsTriggerC"; import { PhysicsTriggerC } from "./PhysicsTriggerC";
import { PropC } from "./PropC"; import { PropC } from "./PropC";
import { findDamageStateLayers } from "./PropDamageLayers";
import { resolvePropType } from "../Resources/PropDropTable"; import { resolvePropType } from "../Resources/PropDropTable";
import { InteractiveZoneC } from "./InteractiveZoneC";
const MAP_PHYSICS_LAYERS = ["Colliders", "Lootable"]; const MAP_PHYSICS_LAYERS = ["Colliders", "Lootable"];
const GATHER_TRIGGER_PADDING = 0.2; const GATHER_TRIGGER_PADDING = 0.5;
export class Map { export class Map {
static Init() { static init() {
const mapObject = ThreeC.getObject("map"); const mapObject = ThreeC.getObject("map");
if (!mapObject) { if (!mapObject) {
console.warn("Map model resource not found: map"); console.warn("Map model resource not found: map");
@@ -22,6 +25,7 @@ export class Map {
PhysicsTriggerC.init(); PhysicsTriggerC.init();
this.buildPhysics(mapObject); this.buildPhysics(mapObject);
InteractiveZoneC.init(mapObject);
} }
private static buildPhysics(mapObject: Object3D) { private static buildPhysics(mapObject: Object3D) {
@@ -55,7 +59,10 @@ export class Map {
private static setupLootableObject(lootableObject: Object3D) { private static setupLootableObject(lootableObject: Object3D) {
const propType = resolvePropType(lootableObject.name); const propType = resolvePropType(lootableObject.name);
const prop = new PropC(lootableObject, propType); 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 colliderMesh = this.findMesh(lootableObject, (name) => name.includes("collider"));
const visualMeshes = this.getVisualMeshes(lootableObject); const visualMeshes = this.getVisualMeshes(lootableObject);
+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);
}
+38 -30
View File
@@ -1,11 +1,9 @@
import { import {
EasyEvent, EasyEvent,
Physics_internal,
UpdateController, UpdateController,
} from "@24tools/playable_template"; } from "@24tools/playable_template";
import { Object3D } from "three"; import { Box3, Object3D, Vector3 } from "three";
import { Body, Box } from "cannon-es"; import { Body } from "cannon-es";
import { PhysicsBody, PhysicsLayer } from "../PhysicsC";
const PLAYER_RADIUS = 0.3; const PLAYER_RADIUS = 0.3;
@@ -18,15 +16,15 @@ export type TriggerEventPayload = {
type TriggerRecord = { type TriggerRecord = {
lootableObject: Object3D; lootableObject: Object3D;
triggerObject: Object3D; triggerObject: Object3D;
center: Vector3;
radius: number; radius: number;
position: Body["position"];
physicsBody: PhysicsBody;
}; };
export class PhysicsTriggerC { export class PhysicsTriggerC {
private static inited = false; private static inited = false;
private static triggers: TriggerRecord[] = []; private static triggers: TriggerRecord[] = [];
private static activeTriggers = new Set<TriggerRecord>(); private static activeTriggers = new Set<TriggerRecord>();
private static playerBody: Body | null = null;
static onTriggerEnter = new EasyEvent<TriggerEventPayload>(); static onTriggerEnter = new EasyEvent<TriggerEventPayload>();
static onTriggerExit = new EasyEvent<TriggerEventPayload>(); static onTriggerExit = new EasyEvent<TriggerEventPayload>();
@@ -38,50 +36,62 @@ export class PhysicsTriggerC {
UpdateController.Instance.onUpdate.addDelegate(() => this.update()); UpdateController.Instance.onUpdate.addDelegate(() => this.update());
} }
static setPlayerBody(body: Body) {
this.playerBody = body;
}
static register(triggerObject: Object3D, lootableObject: Object3D) { static register(triggerObject: Object3D, lootableObject: Object3D) {
const physicsBody = new PhysicsBody( triggerObject.updateWorldMatrix(true, true);
triggerObject,
true,
0,
PhysicsLayer.Trigger,
PhysicsLayer.Player,
);
const body = physicsBody.getPhysicsBody(); const center = new Vector3();
body.allowSleep = false; const size = new Vector3();
const bounds = new Box3().setFromObject(triggerObject);
const shape = body.shapes[0] as Box; bounds.getCenter(center);
const halfExtents = shape.halfExtents; bounds.getSize(size);
this.triggers.push({ this.triggers.push({
lootableObject, lootableObject,
triggerObject, triggerObject,
radius: Math.max(halfExtents.x, halfExtents.y, halfExtents.z), center,
position: body.position, radius: Math.max(size.x, size.y, size.z) / 2,
physicsBody,
}); });
} }
static getActiveLootableObjects(): Object3D[] {
const lootables: Object3D[] = [];
for (const record of this.activeTriggers) {
lootables.push(record.lootableObject);
}
return lootables;
}
static unregister(lootableObject: Object3D) { static unregister(lootableObject: Object3D) {
const playerBody = this.playerBody;
this.triggers = this.triggers.filter((record) => { this.triggers = this.triggers.filter((record) => {
if (record.lootableObject !== lootableObject) return true; if (record.lootableObject !== lootableObject) return true;
record.physicsBody.destroy(); if (this.activeTriggers.has(record) && playerBody) {
this.onTriggerExit.Invoke({
lootableObject: record.lootableObject,
triggerObject: record.triggerObject,
playerBody,
});
}
this.activeTriggers.delete(record); this.activeTriggers.delete(record);
return false; return false;
}); });
} }
private static update() { private static update() {
const playerBody = Physics_internal.physicsWorld?.bodies.find( const playerBody = this.playerBody;
(body) => body.collisionFilterGroup === PhysicsLayer.Player,
);
if (!playerBody) return; if (!playerBody) return;
for (const trigger of this.triggers) { for (const trigger of this.triggers) {
const dx = playerBody.position.x - trigger.position.x; const dx = playerBody.position.x - trigger.center.x;
const dy = playerBody.position.y - trigger.position.y; const dy = playerBody.position.y - trigger.center.y;
const dz = playerBody.position.z - trigger.position.z; const dz = playerBody.position.z - trigger.center.z;
const distanceSq = dx * dx + dy * dy + dz * dz; const distanceSq = dx * dx + dy * dy + dz * dz;
const isInside = distanceSq <= (trigger.radius + PLAYER_RADIUS) ** 2; const isInside = distanceSq <= (trigger.radius + PLAYER_RADIUS) ** 2;
const wasInside = this.activeTriggers.has(trigger); const wasInside = this.activeTriggers.has(trigger);
@@ -97,11 +107,9 @@ export class PhysicsTriggerC {
if (isInside) { if (isInside) {
this.activeTriggers.add(trigger); this.activeTriggers.add(trigger);
this.onTriggerEnter.Invoke(payload); this.onTriggerEnter.Invoke(payload);
console.log("[Gather Enter]", trigger.lootableObject.name);
} else { } else {
this.activeTriggers.delete(trigger); this.activeTriggers.delete(trigger);
this.onTriggerExit.Invoke(payload); this.onTriggerExit.Invoke(payload);
console.log("[Gather Exit]", trigger.lootableObject.name);
} }
} }
} }
+152 -5
View File
@@ -1,10 +1,24 @@
import { Object3D } from "three"; import { EasyEvent, UpdateController } from "@24tools/playable_template";
import { Euler, Material, Mesh, Object3D, Vector3 } from "three";
import { PhysicsBody } from "../PhysicsC"; import { PhysicsBody } from "../PhysicsC";
import { PropType } from "./PropType"; import { PropType } from "./PropType";
import { PhysicsTriggerC } from "./PhysicsTriggerC"; import { PhysicsTriggerC } from "./PhysicsTriggerC";
import { PROP_DROPS } from "../Resources/PropDropTable"; import { PROP_DROPS } from "../Resources/PropDropTable";
import { createDropPlan, PropDropPlan } from "../Resources/PropDropPlanner"; import { createDropPlan, PropDropPlan } from "../Resources/PropDropPlanner";
import { ResourceSpawnC } from "../Resources/ResourceSpawnC"; 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 { export class PropRegistry {
private static props = new Map<Object3D, PropC>(); private static props = new Map<Object3D, PropC>();
@@ -16,9 +30,15 @@ export class PropRegistry {
static get(object: Object3D) { static get(object: Object3D) {
return this.props.get(object); return this.props.get(object);
} }
static unregister(object: Object3D) {
this.props.delete(object);
}
} }
export class PropC { export class PropC {
static readonly onBroken = new EasyEvent<PropC>();
readonly object: Object3D; readonly object: Object3D;
readonly propType: PropType; readonly propType: PropType;
health: number; health: number;
@@ -27,44 +47,171 @@ export class PropC {
private wallBodies: PhysicsBody[] = []; private wallBodies: PhysicsBody[] = [];
private dropPlan: PropDropPlan | null = null; private dropPlan: PropDropPlan | null = null;
private hpBar: PropHpBar | null = null;
private damageLayers: Object3D[] = [];
constructor(object: Object3D, propType: PropType, maxHealth = 3) { 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.object = object;
this.propType = propType; this.propType = propType;
this.maxHealth = maxHealth; this.maxHealth = maxHealth;
this.health = maxHealth; this.health = maxHealth;
this.restPosition.copy(object.position);
this.restRotation.copy(object.rotation);
PropRegistry.register(this); 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) { addWallBody(physicsBody: PhysicsBody) {
this.wallBodies.push(physicsBody); this.wallBodies.push(physicsBody);
} }
initDamageLayers(layers: Object3D[]) {
this.damageLayers = layers;
}
takeDamage(amount: number): boolean { takeDamage(amount: number): boolean {
if (this.isBroken) return false; if (this.isBroken) return false;
const hitIndex = this.maxHealth - this.health; const hitIndex = this.maxHealth - this.health;
this.ensureDropPlan(); this.ensureDropPlan();
this.applyDamageVisual(hitIndex);
this.health -= amount; this.health -= amount;
this.spawnForHit(hitIndex); this.spawnForHit(hitIndex);
this.hpBar?.onDamage();
PropVfxC.Play(VFXType.LootableHit, null, this.object.getWorldPosition(new Vector3()));
if (this.health > 0) return false; if (this.health > 0) {
this.playHitShake();
return false;
}
this.break(); this.playHitShake(() => this.break());
return true; 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() { break() {
if (this.isBroken) return; if (this.isBroken) return;
this.isBroken = true; this.isBroken = true;
this.object.visible = false; this.onShakeComplete = null;
this.stopShake();
PropHpUIC.removeBar(this);
this.hpBar = null;
this.wallBodies.forEach((body) => body.destroy()); this.wallBodies.forEach((body) => body.destroy());
this.wallBodies = []; this.wallBodies = [];
PhysicsTriggerC.unregister(this.object); 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() { private ensureDropPlan() {
+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)!);
}
+171
View File
@@ -0,0 +1,171 @@
import { Box3, Mesh, Object3D, Vector3 } from "three";
import { CameraC } from "../CameraC";
import { PropC } from "./PropC";
const BAR_Y_OFFSET = 0.8;
const BAR_X_OFFSET = -0.3;
const CATCHUP_SPEED = 2.5;
export class PropHpBar {
private readonly prop: PropC;
private readonly root: HTMLElement;
private readonly currentEl: HTMLElement;
private readonly delayedEl: HTMLElement;
private readonly anchor = new Vector3();
private readonly bounds = new Box3();
private displayedHealth: number;
private visible = false;
constructor(prop: PropC, parent: HTMLElement) {
this.prop = prop;
this.displayedHealth = prop.health;
this.root = document.createElement("div");
this.root.className = "prop-hp-bar";
const track = document.createElement("div");
track.className = "prop-hp-bar__track";
this.delayedEl = document.createElement("div");
this.delayedEl.className = "prop-hp-bar__delayed";
this.currentEl = document.createElement("div");
this.currentEl.className = "prop-hp-bar__current";
track.appendChild(this.delayedEl);
track.appendChild(this.currentEl);
this.root.appendChild(track);
parent.appendChild(this.root);
this.applyWidths();
}
onDamage() {
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,
);
}
this.delayedEl.style.width = `${this.getHealthRatio(this.displayedHealth) * 100}%`;
if (!this.visible) return;
this.updatePosition();
}
setVisible(value: boolean) {
this.visible = value;
this.root.classList.toggle("is-visible", value);
if (value) {
this.displayedHealth = this.prop.health;
this.applyWidths();
this.updatePosition();
}
}
destroy() {
this.root.remove();
}
private applyWidths() {
const ratio = this.getHealthRatio(this.prop.health);
this.currentEl.style.width = `${ratio * 100}%`;
this.delayedEl.style.width = `${this.getHealthRatio(this.displayedHealth) * 100}%`;
}
private getHealthRatio(health: number) {
if (this.prop.maxHealth <= 0) return 0;
return Math.max(health, 0) / this.prop.maxHealth;
}
private updatePosition() {
if (!this.computeVisualBounds()) {
this.root.style.display = "none";
return;
}
this.bounds.getCenter(this.anchor);
this.anchor.x += BAR_X_OFFSET;
this.anchor.y = this.bounds.max.y + BAR_Y_OFFSET;
const camera = CameraC.camera;
const projected = this.anchor.project(camera);
if (projected.z > 1) {
this.root.style.display = "none";
return;
}
const screenPos = PropHpBar.worldToLayerPosition(projected, this.root.parentElement);
if (!screenPos) {
this.root.style.display = "none";
return;
}
this.root.style.display = "";
this.root.style.left = `${screenPos.x}px`;
this.root.style.top = `${screenPos.y}px`;
}
private static worldToLayerPosition(
projected: Vector3,
layer: HTMLElement | null,
): { x: number; y: number } | null {
const canvas = document.querySelector("canvas");
if (!canvas || !layer) return null;
const canvasRect = canvas.getBoundingClientRect();
const layerRect = layer.getBoundingClientRect();
return {
x:
(projected.x * 0.5 + 0.5) * canvasRect.width +
canvasRect.left -
layerRect.left,
y:
(-projected.y * 0.5 + 0.5) * canvasRect.height +
canvasRect.top -
layerRect.top,
};
}
private computeVisualBounds() {
this.prop.object.updateWorldMatrix(true, true);
this.bounds.makeEmpty();
const includeMesh = (node: Object3D) => {
if (!(node as Mesh).isMesh) return;
const name = node.name.toLowerCase();
if (name.includes("collider") || name.includes("trigger") || name.includes("gathertrigger")) {
return;
}
this.bounds.expandByObject(node);
};
includeMesh(this.prop.object);
this.prop.object.traverse((child) => includeMesh(child));
if (!this.bounds.isEmpty()) return true;
this.bounds.setFromObject(this.prop.object);
return !this.bounds.isEmpty();
}
}
+82
View File
@@ -0,0 +1,82 @@
import { UpdateController } from "@24tools/playable_template";
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 root: HTMLElement | null = null;
private static bars = new Map<PropC, PropHpBar>();
static init() {
if (this.inited) return;
this.inited = true;
const uiRoot = document.getElementById("ui");
if (!uiRoot) return;
this.root = document.createElement("div");
this.root.id = "prop-hp-layer";
this.root.className = "prop-hp-layer";
uiRoot.appendChild(this.root);
UpdateController.Instance.onUpdate.addDelegate((delta) => {
for (const bar of this.bars.values()) {
bar.update(delta);
}
});
}
static createBar(prop: PropC): PropHpBar | undefined {
if (!this.root || this.bars.has(prop)) return this.bars.get(prop);
const bar = new PropHpBar(prop, this.root);
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);
}
}
+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 "../ThreeC";
import { TimeC } from "../Timers/TimeC";
import { VFXType } from "../Enums/VFXType";
import { ResourcesType } from "../Presets/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);
}
}
+55 -20
View File
@@ -1,5 +1,5 @@
import { EasyEvent } from "@24tools/playable_template"; import { EasyEvent } from "@24tools/playable_template";
import { AnimationAction, AnimationClip, AnimationMixer, Color, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, Object3D, Vector3 } from "three"; import { AnimationAction, AnimationClip, AnimationMixer, LoopOnce, LoopRepeat, Object3D, Vector3 } from "three";
import { clone } from "three/examples/jsm/utils/SkeletonUtils"; import { clone } from "three/examples/jsm/utils/SkeletonUtils";
import { ThreeC } from "../../ThreeC"; import { ThreeC } from "../../ThreeC";
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader"; import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
@@ -9,23 +9,21 @@ export class Character {
animMixer: AnimationMixer; animMixer: AnimationMixer;
animationList: AnimationClip[] = []; animationList: AnimationClip[] = [];
// isWalking: boolean = false;
curClipAction: null | AnimationAction = null; curClipAction: null | AnimationAction = null;
animStopTimeout: null | NodeJS.Timeout = null
onAnimLoop: EasyEvent<{}> = new EasyEvent<{}>(); onAnimLoop: EasyEvent<{}> = new EasyEvent<{}>();
onAnimFinish: EasyEvent<{}> = new EasyEvent<{}>(); onAnimFinish: EasyEvent<{}> = new EasyEvent<{}>();
constructor(prefab: GLTF, start_position = new Vector3()) { constructor(prefab: GLTF, start_position = new Vector3()) {
let tObj = clone(prefab.scene); const tObj = clone(prefab.scene);
tObj.castShadow = true; tObj.castShadow = true;
let animMixer = new AnimationMixer(tObj); const animMixer = new AnimationMixer(tObj);
animMixer.addEventListener('loop', () => { animMixer.addEventListener("loop", () => {
this.onAnimLoop.Invoke({}); this.onAnimLoop.Invoke({});
}); });
animMixer.addEventListener('finished', () => { animMixer.addEventListener("finished", () => {
this.onAnimFinish.Invoke({}); this.onAnimFinish.Invoke({});
}); });
@@ -35,20 +33,21 @@ export class Character {
this.animMixer = animMixer; this.animMixer = animMixer;
this.animationList = prefab.animations; this.animationList = prefab.animations;
ThreeC.addToScene(tObj);
ThreeC.addAnimMixer(animMixer); ThreeC.addAnimMixer(animMixer);
return this; return this;
} }
set AnimationSpeed(timeScale: number) { set AnimationSpeed(timeScale: number) {
if (this.curClipAction) if (this.curClipAction) {
this.curClipAction.timeScale = timeScale; this.curClipAction.timeScale = timeScale;
}
} }
set AnimationWeight(weight: number) { set AnimationWeight(weight: number) {
if (this.curClipAction) if (this.curClipAction) {
this.curClipAction.weight = weight; this.curClipAction.weight = weight;
}
} }
setObjectVisible(name: string, visible: boolean) { setObjectVisible(name: string, visible: boolean) {
@@ -71,27 +70,63 @@ export class Character {
this.setObjectVisible("Character_Pistol", false); this.setObjectVisible("Character_Pistol", false);
} }
playAnimation(anim_id: number, one_time: boolean = false, fade = 0.25, randomStart = false) { isPlayingAnimation(anim_id: number) {
let oldClipAction: null | AnimationAction = this.curClipAction; if (!this.curClipAction || anim_id < 0 || anim_id >= this.animationList.length) {
var clipAction = this.animMixer.clipAction(this.animationList[anim_id]); 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) { if (one_time) {
clipAction.clampWhenFinished = true; clipAction.clampWhenFinished = true;
clipAction.setLoop(LoopOnce, 1); clipAction.setLoop(LoopOnce, 1);
} } else {
else {
clipAction.clampWhenFinished = false; clipAction.clampWhenFinished = false;
clipAction.setLoop(LoopRepeat, Infinity); clipAction.setLoop(LoopRepeat, Infinity);
} }
clipAction.timeScale = 1; clipAction.timeScale = 1;
clipAction.weight = 1;
if (oldClipAction) {
oldClipAction.fadeOut(fade);
}
clipAction.reset(); clipAction.reset();
if (randomStart) if (randomStart) {
clipAction.time = Math.random() * this.animationList[anim_id].duration; clipAction.time = Math.random() * this.animationList[anim_id].duration;
}
clipAction.play(); clipAction.play();
if (oldClipAction && oldClipAction != clipAction) clipAction.fadeIn(fade);
oldClipAction.crossFadeTo(clipAction, fade, true); 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; this.curClipAction = clipAction;
} }
} }
@@ -1,3 +1,4 @@
export enum ResourcesType { export enum ResourcesType {
Mesh = "mesh", Mesh = "mesh",
VFX = "vfx",
} }
@@ -2,5 +2,4 @@ import { Vector3 } from "three";
export interface IMoveInput { export interface IMoveInput {
get CurrentDirection(): Vector3; get CurrentDirection(): Vector3;
update(delta);
} }
+34 -64
View File
@@ -1,109 +1,79 @@
import { Delegate, JoystickC, UpdateController } from "@24tools/playable_template"; import { Delegate, JoystickC } from "@24tools/playable_template";
import { Vector3 } from "three"; import { Vector3 } from "three";
import { IMoveInput } from "./MoveInput"; import { IMoveInput } from "./MoveInput";
import { FollowCameraC } from "../Movment/CameraMovment/FollowCamera"; import { FollowCameraC } from "../Movment/CameraMovment/FollowCamera";
// type JoystickVectorData = {
// vector?: {
// x: number;
// y: number;
// };
// };
// type JoystickPayload = {
// event?: Event;
// data?: JoystickVectorData;
// };
export class PlayerInput implements IMoveInput { export class PlayerInput implements IMoveInput {
private static threshold = 0.25;
public static initJoystick() {
public static InitJoystick() {
const screenSize = window.screenSize; const screenSize = window.screenSize;
const minSize = Math.min(screenSize.width, screenSize.height); const minSize = Math.min(screenSize.width, screenSize.height);
const joystickSizeAspect = 0.2; const joystickSizeAspect = 0.2;
const fadeTime = 200;
const options = { const options = {
zone: document.getElementById("joystick_zone") as HTMLElement, zone: document.getElementById("joystick_zone") as HTMLElement,
size: minSize * joystickSizeAspect, size: minSize * joystickSizeAspect,
restJoystick: true, restJoystick: true,
dynamicPage: true, dynamicPage: true,
catchDistance: minSize * joystickSizeAspect / 2, catchDistance: minSize * joystickSizeAspect / 2,
fadeTime: fadeTime, fadeTime: 200,
}; };
JoystickC.init(options); JoystickC.init(options);
// JoystickC.onJoysticMove.addDelegate(({ event, data }) => {
// console.log('onJoysticMove', event, data);
// })
} }
protected currentDirection: Vector3 = new Vector3(); protected currentDirection = new Vector3();
private updateDelegate: Delegate<number>; private moveDelegate: Delegate<any>;
private StartDelegate: Delegate<any>; private stopDelegate: Delegate<any>;
private MoveDelegate: Delegate<any>; private startDelegate: Delegate<any>;
private StopDelegate: Delegate<any>;
private static threshold = 0.25;
get CurrentDirection() { return this.currentDirection.clone(); }; get CurrentDirection() {
get IsActive() { return this.inputaActive; }; return this.currentDirection;
}
inputaActive: boolean = false; get IsActive() {
return this.inputActive;
}
inputActive = false;
constructor() { constructor() {
this.updateDelegate = UpdateController.Instance.onUpdate.addDelegate(this.update.bind(this)); this.moveDelegate = JoystickC.onJoysticMove.addDelegate(this.onTouchMove.bind(this));
JoystickC.onJoysticDown.addListener(this.moveDelegate);
this.MoveDelegate = JoystickC.onJoysticMove.addDelegate(this.onTouchMove.bind(this)); this.stopDelegate = JoystickC.onJoysticEnd.addDelegate(this.onTouchUp.bind(this));
// "down" also carries joystick data in this SDK, useful for first non-zero direction. this.startDelegate = JoystickC.onJoysticStart.addDelegate(this.onTouchDown.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));
}
update(delta: number) {
} }
onTouchMove(payload: any) { onTouchMove(payload: any) {
this.currentDirection = this.GetDiraction(payload); this.getDirection(payload, this.currentDirection);
if (this.currentDirection.length() <= PlayerInput.threshold) if (this.currentDirection.length() <= PlayerInput.threshold) {
this.currentDirection.multiplyScalar(0); this.currentDirection.multiplyScalar(0);
}
} }
onTouchDown(_event: any) { onTouchDown(_event: any) {
// console.log(event); if (this.inputActive) return;
this.inputActive = true;
if (this.inputaActive) return;
this.inputaActive = true;
} }
onTouchUp() { onTouchUp() {
if (!this.inputaActive) return; if (!this.inputActive) return;
this.inputaActive = false; this.inputActive = false;
this.currentDirection.multiplyScalar(0); this.currentDirection.multiplyScalar(0);
} }
GetDiraction(payload: any) { private getDirection(payload: any, out: Vector3) {
// Compatible with old/new nipplejs payloads wrapped by JoystickC:
// - { event, data } where data.vector exists
// - { event, data: undefined } where event.data.vector exists
const normalizedData = const normalizedData =
payload?.data ?? payload?.data ??
payload?.event?.data ?? payload?.event?.data ??
payload?.event; payload?.event;
const vector = normalizedData?.vector; const vector = normalizedData?.vector;
if (!vector) return new Vector3(); if (!vector) {
out.set(0, 0, 0);
return out;
}
const x = vector.x; out.set(-vector.x, 0, vector.y);
const y = vector.y; out.applyEuler(FollowCameraC.RotationCorrection);
const dir = new Vector3(-x, 0, y); return out;
dir.applyEuler(FollowCameraC.RotationCorection);
return dir;
} }
} }
@@ -12,23 +12,21 @@ export class FollowCameraC {
static cameraContainer: Object3D = new Object3D(); static cameraContainer: Object3D = new Object3D();
static cameraRotation: Object3D = new Object3D(); static cameraRotation: Object3D = new Object3D();
/** Normalized movement direction set each frame by the player */
static inputDirection: Vector3 = new Vector3(); static inputDirection: Vector3 = new Vector3();
/** How far (world units) the camera shifts ahead of the player */ static lookAheadAmount = 1;
static lookAheadAmount: number = 2; static lookAheadLerpSpeed = 0.7;
/** Lerp speed for the look-ahead offset (lower = smoother/slower) */
static lookAheadLerpSpeed: number = 0.7;
private static lookAheadCurrent: Vector3 = new Vector3(); 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) { static init(target: Object3D) {
this.target = target; this.target = target;
this.offset = this.Offset; this.offset = this.Offset;
console.log(target); this.updateDelegate = new Delegate<number>(this.update.bind(this));
this.updateDelegate = new Delegate<number>(this.Update.bind(this));
UpdateController.Instance.onUpdate.addListener(this.updateDelegate); UpdateController.Instance.onUpdate.addListener(this.updateDelegate);
ThreeC.addToScene(this.mainContainer); ThreeC.addToScene(this.mainContainer);
@@ -45,49 +43,41 @@ export class FollowCameraC {
this.cameraContainer.position.y += this.Offset.y; this.cameraContainer.position.y += this.Offset.y;
} }
private static Update(delta: number) { private static update(delta: number) {
if (!this.target.position) return; if (!this.target.position) return;
// Lerp look-ahead toward current movement direction (stays at last direction when stopped) this.lookAheadTarget.copy(this.inputDirection).multiplyScalar(this.lookAheadAmount);
const lookAheadTarget = this.inputDirection.clone().multiplyScalar(this.lookAheadAmount); this.lookAheadCurrent.lerp(this.lookAheadTarget, delta * this.lookAheadLerpSpeed);
this.lookAheadCurrent.lerp(lookAheadTarget, delta * this.lookAheadLerpSpeed);
const offset = this.Offset; const offset = this.Offset;
// Base position without look-ahead this.basePos.copy(this.target.position);
const basePos = this.target.position.clone(); this.basePos.x += offset.x;
basePos.x += offset.x; this.basePos.z += offset.z;
basePos.z += offset.z;
// Final target = base + look-ahead shift this.targetPos.copy(this.basePos);
const targetPos = basePos.clone(); this.targetPos.x += this.lookAheadCurrent.x;
targetPos.x += this.lookAheadCurrent.x; this.targetPos.z += this.lookAheadCurrent.z;
targetPos.z += this.lookAheadCurrent.z;
// Capture actual previous position BEFORE any mutation this.oldPos.copy(this.mainContainer.position);
const oldPos = this.mainContainer.position.clone();
// Compute rotation using base position to avoid tilt from look-ahead offset this.mainContainer.position.copy(this.basePos);
this.mainContainer.position.copy(basePos);
this.mainContainer.lookAt(this.target.position); this.mainContainer.lookAt(this.target.position);
this.cameraContainer.lookAt(this.target.position); this.cameraContainer.lookAt(this.target.position);
// Smooth follow lerp from real previous position toward target with look-ahead
const lerpSpeed = 10; const lerpSpeed = 10;
this.mainContainer.position.lerpVectors(oldPos, targetPos, delta * lerpSpeed); this.mainContainer.position.lerpVectors(this.oldPos, this.targetPos, delta * lerpSpeed);
} }
static get RotationCorection() { static get RotationCorrection() {
const rotation = this.mainContainer.rotation.clone(); return this.mainContainer.rotation.clone();
return rotation;
} }
static get Offset() { static get Offset() {
const portrait = window.screenSize.portrait const portrait = window.screenSize.portrait;
const values = portrait const values = portrait
? Template.getValue<number[]>("global", "camera_position_p") ? Template.getValue<number[]>("global", "camera_position_p")
: Template.getValue<number[]>("global", "camera_position_l"); : Template.getValue<number[]>("global", "camera_position_l");
const offset = Helper.returnVectorCamera(values); return Helper.returnVectorCamera(values);
return offset
} }
} }
+15 -15
View File
@@ -1,25 +1,25 @@
import { Delegate, UpdateController } from "@24tools/playable_template";
import { IMoveInput } from "../Input/MoveInput"; import { IMoveInput } from "../Input/MoveInput";
import { Vector3 } from "three"; import { Vector3 } from "three";
export class MoveC { export class MoveC {
private input: IMoveInput; private readonly input: IMoveInput;
private speed: number = 5; private readonly speed: number;
private updateDelegate: Delegate<number>; private readonly moveDirection = new Vector3();
private moveDiraction: Vector3 = new Vector3();
get Diraction() { return this.moveDiraction };
get Weight() { return this.moveDiraction.length() / this.speed };
constructor(Input: IMoveInput, speed: number = 5) { get Direction() {
this.updateDelegate = new Delegate<number>(this.update.bind(this)); return this.moveDirection;
UpdateController.Instance.onUpdate.addListener(this.updateDelegate); }
this.input = Input;
get Weight() {
return this.moveDirection.length() / this.speed;
}
constructor(input: IMoveInput, speed = 5) {
this.input = input;
this.speed = speed; this.speed = speed;
} }
private update(delta: number) { update(_delta: number) {
// delta *= TimeC.TimeScale; this.moveDirection.copy(this.input.CurrentDirection).multiplyScalar(this.speed);
const moveStep = this.input.CurrentDirection.multiplyScalar(this.speed);
this.moveDiraction.copy(moveStep);
} }
} }
+62 -23
View File
@@ -1,37 +1,76 @@
import { Delegate, UpdateController } from "@24tools/playable_template";
import { IMoveInput } from "../Input/MoveInput";
import { Object3D, Quaternion, Vector3 } from "three"; import { Object3D, Quaternion, Vector3 } from "three";
export class RotationC { export class RotationC {
private target: Object3D; private static readonly completeAngle = 0.01;
private input: IMoveInput;
private speed: number = 5;
private updateDelegate: Delegate<number>;
private currentQ: Quaternion = new Quaternion();
private targetQ: Quaternion = new Quaternion();
constructor(target: Object3D, Input: IMoveInput, speed: number = 5) { private readonly target: Object3D;
this.updateDelegate = new Delegate<number>(this.update.bind(this)); private readonly speed: number;
UpdateController.Instance.onUpdate.addListener(this.updateDelegate); private readonly targetDirection = new Vector3(0, 0, 1);
this.input = Input; private readonly worldPosition = new Vector3();
this.speed = speed; 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.target = target;
this.currentQ.copy(this.target.quaternion); this.speed = speed;
this.targetQ.copy(this.target.quaternion); 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);
}
} }
private update(delta: number) { setTargetDirection(direction: Vector3) {
const loockAtStep = this.input.CurrentDirection; if (direction.lengthSq() === 0) return;
if (loockAtStep.length() == 0) return; this.targetDirection.copy(direction).normalize();
}
this.currentQ.copy(this.target.quaternion); 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();
}
const loockAtPoint = this.target.position.clone().add(loockAtStep); syncToCurrentFacing() {
this.target.lookAt(loockAtPoint); this.target.getWorldDirection(this.targetDirection);
this.targetQ.copy(this.target.quaternion); this.targetDirection.y = 0;
this.target.quaternion.copy(this.currentQ); 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.target.quaternion.slerp(this.targetQ, delta * this.speed);
this.currentQ.copy(this.target.quaternion); 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);
}
} }
+92 -153
View File
@@ -5,47 +5,36 @@ import { MeshType } from "./Enums/MeshType";
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader"; import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";
import { BaseAnimation } from "./Enums/BaseAnimation"; import { BaseAnimation } from "./Enums/BaseAnimation";
import { PhysicsBody, PhysicsLayer } from "../PhysicsC"; import { PhysicsBody, PhysicsLayer } from "../PhysicsC";
import { Object3D, Quaternion, Vector3 } from "three"; import { Object3D, Vector3 } from "three";
import { ThreeC } from "../ThreeC"; import { ThreeC } from "../ThreeC";
import { PlayerInput } from "./Input/PlayerInput"; import { PlayerInput } from "./Input/PlayerInput";
import { MoveC } from "./Movment/MoveC"; import { MoveC } from "./Movment/MoveC";
import { Vector3CToT, Vector3TToC } from "./Helper"; import { Vector3CToT, Vector3TToC } from "./Helper";
import { RotationC } from "./Movment/RotationC"; import { RotationC } from "./Movment/RotationC";
import { FollowCameraC } from "./Movment/CameraMovment/FollowCamera"; import { FollowCameraC } from "./Movment/CameraMovment/FollowCamera";
import { PlayerCombat } from "./PlayerCombat";
import { PhysicsTriggerC } from "../Map/PhysicsTriggerC";
import { PropHpUIC } from "../Map/PropHpUIC";
export class Player { export class Player {
private static inited: boolean = false; private static inited = false;
private static isRunning: boolean = false; private static isRunning = false;
private static isAttacking: boolean = false;
private static isAutoAttacking: boolean = false;
private static isTurningToTarget: boolean = false;
private static isBatEquipped: boolean = false;
private static attackPhase: "none" | "attacking" = "none";
private static updateDelegate: Delegate<number>; private static updateDelegate: Delegate<number>;
private static onAttackComplete: (() => void) | null = null;
private static onTurnComplete: (() => void) | null = null;
private static turnTargetQ = new Quaternion();
private static container: Object3D = new Object3D; private static container: Object3D = new Object3D();
private static input: PlayerInput; private static input: PlayerInput;
private static movement: MoveC; private static movement: MoveC;
private static rotation: RotationC; private static rotation: RotationC;
private static spawnPosition: Vector3 = new Vector3(0, 0, 0); private static spawnPosition = new Vector3(0, 0, -4);
private static readonly turnSpeed = 8;
private static readonly turnCompleteAngle = 0.05;
static character: Character; static character: Character;
static physics: PhysicsBody; static physics: PhysicsBody;
static SetSpawnPosition(position: Vector3) { static init() {
this.spawnPosition.copy(position);
}
static Init() {
if (this.inited) return; if (this.inited) return;
this.inited = true; this.inited = true;
const asset = ResourcesC.getResource<GLTF>(ResourcesType.Mesh, MeshType.Character); const asset = ResourcesC.getResource<GLTF>(ResourcesType.Mesh, MeshType.Character);
this.character = new Character(asset); this.character = new Character(asset);
@@ -55,19 +44,31 @@ export class Player {
this.container.add(this.character.tObj); this.container.add(this.character.tObj);
ThreeC.addToScene(this.container); ThreeC.addToScene(this.container);
this.InitPhisic(); this.initPhysics();
PhysicsTriggerC.setPlayerBody(this.physics.getPhysicsBody());
this.input = new PlayerInput(); this.input = new PlayerInput();
const moveSpeed = 3; const moveSpeed = 3;
const rotationSpeed = 8; const rotationSpeed = 8;
this.movement = new MoveC(this.input, moveSpeed); this.movement = new MoveC(this.input, moveSpeed);
this.rotation = new RotationC(this.container, this.input, rotationSpeed); this.rotation = new RotationC(this.container, rotationSpeed);
this.character.onAnimFinish.addDelegate(() => this.OnCombatAnimationFinished()); PlayerCombat.init({
character: this.character,
getPhysicsBody: () => this.physics.getPhysicsBody(),
isMoving: () => this.isMoving(),
onStopRunning: () => this.stopRunning(),
onResumeRunning: () => this.resumeRunning(),
onSetTurnTarget: (worldPosition) => this.rotation.setTargetWorldPosition(worldPosition),
onUpdateRotation: (delta) => this.rotation.update(delta),
isRotationComplete: () => this.rotation.isComplete(),
onSyncRotation: () => this.syncRotationToFacing(),
});
this.updateDelegate = new Delegate<number>((delta) => this.Update(delta)); this.updateDelegate = new Delegate<number>((delta) => this.update(delta));
UpdateController.Instance.onUpdate.addListener(this.updateDelegate); UpdateController.Instance.onUpdate.addListener(this.updateDelegate);
FollowCameraC.Init(this.container); FollowCameraC.init(this.container);
} }
static getWorldPosition() { static getWorldPosition() {
@@ -75,42 +76,39 @@ export class Player {
} }
static isMoving() { static isMoving() {
return this.input.IsActive; return this.movement.Direction.lengthSq() > 0;
} }
static startAutoAttack(onHit: () => void, targetWorldPosition: Vector3) { static isCombatBusy() {
if (this.isAutoAttacking) return; return PlayerCombat.isBusy;
}
this.isAutoAttacking = true; static isAutoAttackActive() {
this.beginTurnToTarget(targetWorldPosition, () => this.playAttack(onHit)); return PlayerCombat.isAutoAttackActive;
}
static startAutoAttack(
onStrike: () => void,
onComplete: () => void,
targetWorldPosition: Vector3,
) {
PlayerCombat.startAutoAttack(onStrike, onComplete, targetWorldPosition);
} }
static stopAutoAttack() { static stopAutoAttack() {
this.isAutoAttacking = false; PlayerCombat.stopAutoAttack();
this.onAttackComplete = null; PropHpUIC.hideAll();
this.cancelTurnToTarget();
this.resetCombatState();
if (!this.isAttacking) {
this.StopRunning();
}
} }
static playAttack(onComplete: () => void) { static retargetAutoAttack(targetWorldPosition: Vector3) {
this.onAttackComplete = onComplete; PlayerCombat.retargetAutoAttack(targetWorldPosition);
this.isAttacking = true;
this.isRunning = false;
this.physics.getPhysicsBody().velocity.set(0, 0, 0);
if (!this.isBatEquipped) {
this.equipBat();
}
this.playBatAttack();
} }
private static InitPhisic() { static playAttack(onStrike: () => void) {
PlayerCombat.playAttack(onStrike);
}
private static initPhysics() {
this.physics = new PhysicsBody( this.physics = new PhysicsBody(
this.container, this.container,
false, false,
@@ -120,136 +118,77 @@ export class Player {
); );
} }
private static beginTurnToTarget(targetWorldPosition: Vector3, onComplete: () => void) { private static startRunning() {
const worldPosition = this.container.getWorldPosition(new Vector3()); if (PlayerCombat.isBusy) return;
const lookAtPoint = targetWorldPosition.clone(); if (this.isRunning) return;
lookAtPoint.y = worldPosition.y; if (this.character.isPlayingAnimation(BaseAnimation.Run)) {
this.isRunning = true;
const turnHelper = new Object3D();
turnHelper.position.copy(worldPosition);
turnHelper.lookAt(lookAtPoint);
this.turnTargetQ.copy(turnHelper.quaternion);
this.onTurnComplete = onComplete;
this.isTurningToTarget = true;
this.physics.getPhysicsBody().velocity.set(0, 0, 0);
}
private static cancelTurnToTarget() {
this.isTurningToTarget = false;
this.onTurnComplete = null;
}
private static updateTurnToTarget(delta: number) {
this.container.quaternion.slerp(this.turnTargetQ, delta * this.turnSpeed);
if (this.container.quaternion.angleTo(this.turnTargetQ) > this.turnCompleteAngle) {
return; return;
} }
this.container.quaternion.copy(this.turnTargetQ);
this.isTurningToTarget = false;
const callback = this.onTurnComplete;
this.onTurnComplete = null;
callback?.();
}
private static playBatAttack() {
this.attackPhase = "attacking";
this.character.playAnimation(this.getBatAttackAnimation(), true);
}
private static getBatAttackAnimation() {
if (this.character.animationList.length > BaseAnimation.Loot) {
return BaseAnimation.Loot;
}
return BaseAnimation.Idle;
}
private static equipBat() {
this.isBatEquipped = true;
this.character.setBatEquipped(true);
}
private static resetCombatState() {
this.attackPhase = "none";
this.isAttacking = false;
this.isBatEquipped = false;
this.character.setDefaultWeapons();
this.character.playAnimation(BaseAnimation.Idle);
}
private static OnCombatAnimationFinished() {
if (this.attackPhase !== "attacking") return;
this.attackPhase = "none";
this.isAttacking = false;
const callback = this.onAttackComplete;
this.onAttackComplete = null;
callback?.();
}
private static StartRunning() {
if (this.isRunning || this.isAttacking || this.isTurningToTarget) return;
this.character.playAnimation(BaseAnimation.Run); this.character.playAnimation(BaseAnimation.Run);
this.isRunning = true; this.isRunning = true;
} }
private static StopRunning() { private static resumeRunning() {
if (!this.isRunning || this.isAttacking || this.isTurningToTarget) return; if (PlayerCombat.isBusy) return;
this.character.playAnimation(BaseAnimation.Idle); this.isRunning = true;
this.AnimationValue = 1; if (this.character.isPlayingAnimation(BaseAnimation.Run)) return;
this.character.crossFadeToAnimation(BaseAnimation.Run, false, 0.15);
}
private static stopRunning() {
if (!this.isRunning) return;
if (!PlayerCombat.isBusy) {
this.character.playAnimation(BaseAnimation.Idle);
}
this.animationValue = 1;
this.isRunning = false; this.isRunning = false;
} }
private static set AnimationValue(value: number) { private static set animationValue(value: number) {
this.character.AnimationSpeed = value; this.character.AnimationSpeed = value;
this.character.AnimationWeight = value * 12.5 + 87.5; this.character.AnimationWeight = value * 12.5 + 87.5;
} }
private static Update(delta: number) { private static update(delta: number) {
if (this.input.IsActive && (this.isAutoAttacking || this.isTurningToTarget)) { this.movement.update(delta);
this.stopAutoAttack();
if (this.isMoving()) {
PlayerCombat.cancelOnMoveInput();
} }
if (this.isTurningToTarget) { if (PlayerCombat.update(delta)) {
this.physics.getPhysicsBody().velocity.set(0, 0, 0); this.syncVisual(delta);
this.updateTurnToTarget(delta);
this.MoveVisual(delta);
return; return;
} }
if (this.isAttacking) { const direction = this.movement.Direction;
this.physics.getPhysicsBody().velocity.set(0, 0, 0);
this.MoveVisual(delta);
return;
}
const diraction = this.movement.Diraction;
const weight = this.movement.Weight; const weight = this.movement.Weight;
const isMoving = direction.lengthSq() > 0;
if (diraction.length() > 0) { if (isMoving) {
this.StartRunning(); this.startRunning();
this.AnimationValue = weight; this.animationValue = weight;
FollowCameraC.inputDirection.copy(diraction).normalize(); FollowCameraC.inputDirection.copy(direction).normalize();
this.rotation.setTargetDirection(direction);
} else { } else {
this.StopRunning(); this.stopRunning();
} }
const cPos = Vector3TToC(diraction); this.rotation.update(delta);
this.physics.getPhysicsBody().velocity.copy(cPos); this.physics.getPhysicsBody().velocity.copy(Vector3TToC(direction));
this.physics.getPhysicsBody().wakeUp(); this.physics.getPhysicsBody().wakeUp();
this.MoveVisual(delta); this.syncVisual(delta);
} }
private static MoveVisual(delta: number) { private static syncRotationToFacing() {
this.rotation.syncToCurrentFacing();
}
private static syncVisual(delta: number) {
const lerpSpeed = 10; const lerpSpeed = 10;
const targetPos = Vector3CToT(this.physics.getPhysicsBody().position); const targetPos = Vector3CToT(this.physics.getPhysicsBody().position);
this.container.position.lerp(targetPos, delta * lerpSpeed); this.container.position.lerp(targetPos, delta * lerpSpeed);
} }
} }
+273
View File
@@ -0,0 +1,273 @@
import { Vector3 } from "three";
import { Body } from "cannon-es";
import { Character } from "./Character/Character";
import { BaseAnimation } from "./Enums/BaseAnimation";
type PlayerCombatDeps = {
character: Character;
getPhysicsBody: () => Body;
isMoving: () => boolean;
onStopRunning: () => void;
onResumeRunning: () => void;
onSetTurnTarget: (worldPosition: Vector3) => void;
onUpdateRotation: (delta: number) => void;
isRotationComplete: () => boolean;
onSyncRotation: () => void;
};
export class PlayerCombat {
private static deps: PlayerCombatDeps;
private static isAttacking = false;
private static isAutoAttacking = false;
private static isTurningToTarget = false;
private static isBatEquipped = false;
private static isLootLoopActive = false;
private static pendingCombatExit = false;
private static attackPhase: "none" | "attacking" = "none";
private static onStrike: (() => void) | null = null;
private static onTurnComplete: (() => void) | null = null;
private static strikeMarkIndex = 0;
private static prevStrikeNormTime = 0;
private static readonly turnIdleFade = 0.1;
private static readonly lootEnterFade = 0.25;
private static readonly combatExitFade = 0.8;
/** Normalized clip time (01) when the bat crosses the target: R→L, then L→R. */
private static readonly attackStrikeMarks = [0.38, 0.72];
/** After 1st hit (R→L) — safe exit if combat ends before 2nd hit (L→R). */
private static readonly lootExitAfterFirstStrike = 0.5;
/** After 2nd hit (L→R) — follow-through before idle. */
private static readonly lootExitAfterSecondStrike = 0.78;
/** End of clip when exiting before any strike (e.g. left the zone). */
private static readonly lootExitEndOfCycle = 0.9;
static init(deps: PlayerCombatDeps) {
this.deps = deps;
}
static get isBusy() {
return this.isAttacking || this.isTurningToTarget || this.pendingCombatExit;
}
static get isAutoAttackActive() {
return this.isAutoAttacking;
}
static startAutoAttack(
onStrike: () => void,
_onComplete: () => void,
targetWorldPosition: Vector3,
) {
if (this.isAutoAttacking) return;
this.isAutoAttacking = true;
this.deps.onStopRunning();
this.beginTurnToTarget(targetWorldPosition, () => this.playAttack(onStrike));
}
static retargetAutoAttack(targetWorldPosition: Vector3) {
if (!this.isAutoAttacking) return;
this.deps.onSetTurnTarget(targetWorldPosition);
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
if (this.isTurningToTarget) {
return;
}
this.onTurnComplete = () => {
this.deps.onSyncRotation();
};
this.isTurningToTarget = true;
}
static stopAutoAttack() {
const wasLooting = this.isLootLoopActive && this.attackPhase === "attacking";
const needsExit = this.isAutoAttacking
|| this.isTurningToTarget
|| this.isAttacking
|| this.pendingCombatExit
|| this.isBatEquipped;
this.isAutoAttacking = false;
this.onStrike = null;
this.cancelTurnToTarget();
if (wasLooting) {
this.pendingCombatExit = true;
return;
}
if (needsExit) {
this.finishCombatExit();
}
}
static playAttack(onStrike: () => void) {
this.onStrike = onStrike;
this.isAttacking = true;
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
if (!this.isBatEquipped) {
this.equipBat();
}
this.playBatAttack();
}
static update(delta: number): boolean {
if (this.isTurningToTarget) {
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
this.updateTurnToTarget(delta);
return true;
}
if (this.isAttacking) {
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
this.updateAttackStrikes();
return true;
}
return false;
}
static cancelOnMoveInput() {
if (this.isAutoAttacking || this.isTurningToTarget) {
this.stopAutoAttack();
}
}
private static beginTurnToTarget(targetWorldPosition: Vector3, onComplete: () => void) {
this.deps.onSetTurnTarget(targetWorldPosition);
this.onTurnComplete = onComplete;
this.isTurningToTarget = true;
this.deps.getPhysicsBody().velocity.set(0, 0, 0);
this.deps.character.playAnimation(BaseAnimation.Idle, false, this.turnIdleFade);
}
private static cancelTurnToTarget() {
if (!this.isTurningToTarget) return;
this.isTurningToTarget = false;
this.onTurnComplete = null;
this.deps.onSyncRotation();
}
private static updateTurnToTarget(delta: number) {
this.deps.onUpdateRotation(delta);
if (!this.deps.isRotationComplete()) {
return;
}
this.isTurningToTarget = false;
this.deps.onSyncRotation();
const callback = this.onTurnComplete;
this.onTurnComplete = null;
callback?.();
}
private static playBatAttack() {
if (this.isLootLoopActive && this.isAutoAttacking) return;
this.startLootLoop();
}
private static startLootLoop() {
const animId = this.getBatAttackAnimation();
if (animId !== BaseAnimation.Loot) {
this.attackPhase = "attacking";
this.deps.character.playAnimation(animId, false, this.lootEnterFade);
return;
}
this.attackPhase = "attacking";
this.strikeMarkIndex = 0;
this.prevStrikeNormTime = 0;
this.isLootLoopActive = true;
this.deps.character.playAnimation(BaseAnimation.Loot, false, this.lootEnterFade);
}
private static updateAttackStrikes() {
if (this.attackPhase !== "attacking") return;
const action = this.deps.character.curClipAction;
if (!action) return;
const clipDuration = action.getClip().duration;
if (clipDuration <= 0) return;
const normalizedTime = action.time / clipDuration;
if (normalizedTime < this.prevStrikeNormTime) {
if (this.pendingCombatExit) {
this.finishCombatExit();
return;
}
this.strikeMarkIndex = 0;
}
this.prevStrikeNormTime = normalizedTime;
while (
this.strikeMarkIndex < this.attackStrikeMarks.length &&
normalizedTime >= this.attackStrikeMarks[this.strikeMarkIndex]
) {
this.onStrike?.();
this.strikeMarkIndex++;
}
if (this.pendingCombatExit && this.canExitLootNow(normalizedTime)) {
this.finishCombatExit();
}
}
private static canExitLootNow(normalizedTime: number) {
if (this.strikeMarkIndex >= 2) {
return normalizedTime >= this.lootExitAfterSecondStrike;
}
if (this.strikeMarkIndex >= 1) {
return normalizedTime >= this.lootExitAfterFirstStrike;
}
return normalizedTime >= this.lootExitEndOfCycle;
}
private static getBatAttackAnimation() {
if (this.deps.character.animationList.length > BaseAnimation.Loot) {
return BaseAnimation.Loot;
}
return BaseAnimation.Idle;
}
private static equipBat() {
this.isBatEquipped = true;
this.deps.character.setBatEquipped(true);
}
private static finishCombatExit() {
this.pendingCombatExit = false;
this.attackPhase = "none";
this.isAttacking = false;
this.strikeMarkIndex = 0;
this.prevStrikeNormTime = 0;
this.isLootLoopActive = false;
this.isBatEquipped = false;
this.deps.character.setDefaultWeapons();
this.deps.onSyncRotation();
if (this.deps.isMoving()) {
this.deps.onResumeRunning();
return;
}
this.deps.character.crossFadeToAnimation(BaseAnimation.Idle, false, this.combatExitFade);
this.deps.onStopRunning();
}
}
+2 -2
View File
@@ -1,3 +1,5 @@
import { ResourceType } from "./ResourceType";
function randomInt(min: number, max: number) { function randomInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min; return Math.floor(Math.random() * (max - min + 1)) + min;
} }
@@ -26,8 +28,6 @@ function splitRandom(total: number, parts: number) {
return result; return result;
} }
import { ResourceType } from "./ResourceType";
export type PropDropPlan = { export type PropDropPlan = {
resource: ResourceType; resource: ResourceType;
totalDrop: number; totalDrop: number;
+2 -1
View File
@@ -14,7 +14,7 @@ export const PROP_DROPS: Record<PropType, DropRule[]> = {
{ {
resource: ResourceType.Wood, resource: ResourceType.Wood,
minTotal: 1, minTotal: 1,
maxTotal: 3, maxTotal: 4,
minSpawnHits: 1, minSpawnHits: 1,
maxSpawnHits: 2, maxSpawnHits: 2,
}, },
@@ -27,5 +27,6 @@ export function resolvePropType(objectName: string): PropType {
return PropType.Box; return PropType.Box;
} }
console.warn(`Unknown prop type for "${objectName}", falling back to Box`);
return PropType.Box; return PropType.Box;
} }
@@ -0,0 +1,5 @@
import { ResourceType } from "./ResourceType";
export const RESOURCE_PLACEHOLDER_COLORS: Record<ResourceType, string> = {
[ResourceType.Wood]: "#6b4423",
};
@@ -0,0 +1,107 @@
import { UpdateController } from "@24tools/playable_template";
import { Group } from "@tweenjs/tween.js";
import { GatherC } from "../Map/GatherC";
import { DepositZoneC } from "../Map/DepositZoneC";
import { InteractiveZoneC } from "../Map/InteractiveZoneC";
import { Player } from "../Presets/Player";
import { ResourceInventoryC } from "./ResourceInventoryC";
import { ResourceScreenFly } from "./ResourceScreenFly";
import { ResourceType } from "./ResourceType";
import { ResourceUIC } from "./ResourceUIC";
const DEPOSIT_FLY_MS = 520;
const DEPOSIT_FLY_SCALE = 0.35;
const CHAIN_DEPOSIT_DELAY_MS = 140;
export class ResourceDepositC {
private static inited = false;
private static tweenGroup = new Group();
private static isProcessing = false;
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());
});
}
static tryStart() {
if (!this.canDeposit()) return;
this.processNext();
}
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 processNext() {
if (this.isProcessing) return;
const type = this.getDepositableType();
if (!type || !this.canDeposit()) {
this.isProcessing = false;
return;
}
const iconCenter = ResourceUIC.getIconCenter(type);
const zoneScreen = InteractiveZoneC.getScreenCenter();
if (!iconCenter) return;
const pickup = ResourceScreenFly.createPickup(type);
if (!pickup) return;
this.isProcessing = true;
const startScreen = ResourceScreenFly.screenPointFromClient(
iconCenter.x,
iconCenter.y,
zoneScreen.z,
);
ResourceScreenFly.flyAlongScreen(
pickup,
startScreen,
zoneScreen,
this.tweenGroup,
DEPOSIT_FLY_MS,
DEPOSIT_FLY_SCALE,
() => {
ResourceInventoryC.remove(type, 1);
ResourceUIC.refresh(type);
InteractiveZoneC.addDeposit(1);
window.setTimeout(() => {
this.isProcessing = false;
this.processNext();
}, CHAIN_DEPOSIT_DELAY_MS);
},
);
}
}
+156 -71
View File
@@ -1,121 +1,206 @@
import { UpdateController } from "@24tools/playable_template"; import { UpdateController } from "@24tools/playable_template";
import { Easing, Group, Tween } from "@tweenjs/tween.js"; import { Easing, Group, Tween } from "@tweenjs/tween.js";
import { import { Object3D, Vector3 } from "three";
BoxGeometry,
Mesh,
MeshBasicMaterial,
Vector3,
} from "three";
import { CameraC } from "../CameraC";
import { ThreeC } from "../ThreeC"; import { ThreeC } from "../ThreeC";
import { ResourceInventoryC } from "./ResourceInventoryC"; import { ResourceInventoryC } from "./ResourceInventoryC";
import { ResourceScreenFly } from "./ResourceScreenFly";
import { ResourceType } from "./ResourceType"; import { ResourceType } from "./ResourceType";
import { ResourceUIC } from "./ResourceUIC"; import { ResourceUIC } from "./ResourceUIC";
const POP_DURATION_MS = 220; 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;
const FLY_DURATION_MS = 520; const FLY_DURATION_MS = 520;
const PICKUP_SIZE = 0.14; const CHAIN_FLY_DELAY_MS = 140;
const FLY_SCALE = 0.35;
const STAGGER_MS = 70; const STAGGER_MS = 70;
const GROUND_LIFT = 0.08;
const PLACEHOLDER_COLORS: Record<ResourceType, string> = { type FlyQueueItem = {
[ResourceType.Wood]: "#6b4423", pickup: Object3D;
type: ResourceType;
}; };
export class ResourceFlyC { export class ResourceFlyC {
private static inited = false; private static inited = false;
private static tweenGroup = new Group(); private static tweenGroup = new Group();
private static flyQueue: FlyQueueItem[] = [];
private static isProcessingFlyQueue = false;
static init() { static init() {
if (this.inited) return; if (this.inited) return;
this.inited = true; this.inited = true;
this.hideTemplateMeshes();
UpdateController.Instance.onUpdate.addDelegate(() => { UpdateController.Instance.onUpdate.addDelegate(() => {
this.tweenGroup.update(performance.now()); this.tweenGroup.update(performance.now());
}); });
} }
static launch(origin: Vector3, type: ResourceType, index: number) { private static hideTemplateMeshes() {
const spawnPos = origin.clone().add( Object.values(ResourceType).forEach((type) => {
new Vector3( const template = ThreeC.getObject(type);
(Math.random() - 0.5) * 0.35, if (!template) return;
0.25 + Math.random() * 0.2,
(Math.random() - 0.5) * 0.35,
),
);
template.visible = false;
template.removeFromParent();
});
}
static launch(origin: Vector3, type: ResourceType, index: number, count: number) {
window.setTimeout(() => { window.setTimeout(() => {
this.startPickup(spawnPos, type); this.startPickup(origin, type, index, count);
}, index * STAGGER_MS); }, index * STAGGER_MS);
} }
private static startPickup(worldPos: Vector3, type: ResourceType) { private static startPickup(origin: Vector3, type: ResourceType, index: number, count: number) {
const mesh = this.createWorldMesh(type); const pickup = ResourceScreenFly.createPickup(type);
mesh.position.copy(worldPos); if (!pickup) return;
ThreeC.addToScene(mesh);
const popTarget = worldPos.clone().add(new Vector3(0, 0.35, 0)); 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));
const popTween = new Tween(mesh.position) pickup.position.copy(spawnPos);
.to({ x: popTarget.x, y: popTarget.y, z: popTarget.z }, POP_DURATION_MS) ResourceScreenFly.orientToCamera(pickup);
ThreeC.addToScene(pickup);
const ejectTween = new Tween(pickup.position)
.to({ x: peakPos.x, y: peakPos.y, z: peakPos.z }, EJECT_MS)
.easing(Easing.Quadratic.Out) .easing(Easing.Quadratic.Out)
.onComplete(() => { .onUpdate(() => {
ThreeC.removeFromScene(mesh); ResourceScreenFly.orientToCamera(pickup);
mesh.geometry.dispose();
(mesh.material as MeshBasicMaterial).dispose();
this.flyToUI(popTarget, type);
}); });
this.tweenGroup.add(popTween); const fallTween = new Tween(pickup.position)
popTween.start(performance.now()); .to({ x: landPos.x, y: landPos.y, z: landPos.z }, FALL_MS)
}
private static flyToUI(fromWorldPos: Vector3, type: ResourceType) {
const start = this.worldToScreen(fromWorldPos);
const target = ResourceUIC.getIconCenter(type);
if (!target) return;
const element = document.createElement("div");
element.className = "resource-fly-pickup";
element.style.backgroundColor = PLACEHOLDER_COLORS[type];
element.style.left = `${start.x}px`;
element.style.top = `${start.y}px`;
document.body.appendChild(element);
const state = { x: start.x, y: start.y, scale: 1 };
const flyTween = new Tween(state)
.to({ x: target.x, y: target.y, scale: 0.35 }, FLY_DURATION_MS)
.easing(Easing.Quadratic.In) .easing(Easing.Quadratic.In)
.onUpdate(() => { .onUpdate(() => {
element.style.left = `${state.x}px`; ResourceScreenFly.orientToCamera(pickup);
element.style.top = `${state.y}px`;
element.style.transform = `translate(-50%, -50%) scale(${state.scale})`;
}) })
.onComplete(() => { .onComplete(() => {
element.remove(); this.playBounces(pickup, landPos, type);
ResourceInventoryC.add(type, 1);
ResourceUIC.refresh(type);
}); });
this.tweenGroup.add(flyTween); ejectTween.chain(fallTween);
flyTween.start(performance.now()); this.tweenGroup.add(ejectTween);
this.tweenGroup.add(fallTween);
ejectTween.start(performance.now());
} }
private static createWorldMesh(type: ResourceType) { private static getScatterOffset(index: number, count: number) {
const color = PLACEHOLDER_COLORS[type]; const baseAngle = (index / Math.max(count, 1)) * Math.PI * 2;
return new Mesh( const angle = baseAngle + (Math.random() - 0.5) * 0.7;
new BoxGeometry(PICKUP_SIZE, PICKUP_SIZE, PICKUP_SIZE), const distance = SCATTER_MIN + Math.random() * (SCATTER_MAX - SCATTER_MIN);
new MeshBasicMaterial({ color }),
return new Vector3(
Math.cos(angle) * distance,
0,
Math.sin(angle) * distance,
); );
} }
private static worldToScreen(worldPos: Vector3) { private static playBounces(pickup: Object3D, landPos: Vector3, type: ResourceType) {
const camera = CameraC.camera; const runBounce = (bounceIndex: number) => {
const projected = worldPos.clone().project(camera); if (bounceIndex >= BOUNCE_HEIGHTS.length) {
window.setTimeout(() => {
this.enqueueFlyToUI(pickup, type);
}, REST_AFTER_BOUNCE_MS);
return;
}
return { const height = BOUNCE_HEIGHTS[bounceIndex];
x: (projected.x * 0.5 + 0.5) * window.innerWidth, const durationScale = height / BOUNCE_HEIGHTS[0];
y: (-projected.y * 0.5 + 0.5) * window.innerHeight, 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 });
this.processFlyQueue();
}
private static processFlyQueue() {
if (this.isProcessingFlyQueue || this.flyQueue.length === 0) return;
this.isProcessingFlyQueue = true;
const item = this.flyQueue.shift();
if (!item) {
this.isProcessingFlyQueue = false;
return;
}
this.flyToUI(item.pickup, item.type, () => {
window.setTimeout(() => {
this.isProcessingFlyQueue = false;
this.processFlyQueue();
}, CHAIN_FLY_DELAY_MS);
});
}
private static flyToUI(pickup: Object3D, type: ResourceType, onComplete: () => void) {
const iconCenter = ResourceUIC.getIconCenter(type);
if (!iconCenter) {
ThreeC.removeFromScene(pickup);
onComplete();
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,
FLY_SCALE,
() => {
ResourceInventoryC.add(type, 1);
ResourceUIC.refresh(type);
onComplete();
},
);
} }
} }
@@ -1,7 +1,9 @@
import { EasyEvent } from "@24tools/playable_template";
import { ResourceType } from "./ResourceType"; import { ResourceType } from "./ResourceType";
export class ResourceInventoryC { export class ResourceInventoryC {
private static amounts = new Map<ResourceType, number>(); private static amounts = new Map<ResourceType, number>();
static readonly onChanged = new EasyEvent<{ type: ResourceType }>();
static get(type: ResourceType) { static get(type: ResourceType) {
return this.amounts.get(type) ?? 0; return this.amounts.get(type) ?? 0;
@@ -10,5 +12,12 @@ export class ResourceInventoryC {
static add(type: ResourceType, amount: number) { static add(type: ResourceType, amount: number) {
if (amount <= 0) return; if (amount <= 0) return;
this.amounts.set(type, this.get(type) + amount); 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,184 @@
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 "../CameraC";
import { ResourcesType } from "../Presets/Enums/ResourcesType";
import { ThreeC } from "../ThreeC";
import { ResourceType } from "./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,
tweenGroup: Group,
durationMs: number,
endScale: number,
onComplete: () => void,
) {
const startScale = 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, endScreen, state.t);
pickup.position.copy(this.screenToWorld(screen));
const scale = this.lerp(1, endScale, state.t);
pickup.scale.set(
startScale.x * scale,
startScale.y * scale,
startScale.z * scale,
);
this.orientToCamera(pickup);
})
.onComplete(() => {
ThreeC.removeFromScene(pickup);
onComplete();
});
tweenGroup.add(flyTween);
flyTween.start(performance.now());
}
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);
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ export class ResourceSpawnC {
const origin = prop.object.getWorldPosition(new Vector3()); const origin = prop.object.getWorldPosition(new Vector3());
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
ResourceFlyC.launch(origin, type, i); ResourceFlyC.launch(origin, type, i, count);
} }
} }
} }
+55 -12
View File
@@ -1,16 +1,15 @@
import { UI_IMAGES } from "../../resources/images/uiImages";
import { ResourceInventoryC } from "./ResourceInventoryC"; import { ResourceInventoryC } from "./ResourceInventoryC";
import { ResourceType } from "./ResourceType"; import { ResourceType } from "./ResourceType";
import { RESOURCE_PLACEHOLDER_COLORS } from "./ResourceConfig";
type ResourceUIEntry = { type ResourceUIEntry = {
type: ResourceType; type: ResourceType;
counter: HTMLElement;
icon: HTMLElement; icon: HTMLElement;
count: HTMLElement; count: HTMLElement;
}; };
const PLACEHOLDER_COLORS: Record<ResourceType, string> = {
[ResourceType.Wood]: "#6b4423",
};
export class ResourceUIC { export class ResourceUIC {
private static entries = new Map<ResourceType, ResourceUIEntry>(); private static entries = new Map<ResourceType, ResourceUIEntry>();
private static root: HTMLElement | null = null; private static root: HTMLElement | null = null;
@@ -19,34 +18,78 @@ export class ResourceUIC {
const uiRoot = document.getElementById("ui"); const uiRoot = document.getElementById("ui");
if (!uiRoot) return; 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 = document.createElement("div");
this.root.id = "resource-bar"; this.root.id = "resource-bar";
this.root.className = "resource-bar"; this.root.className = "resource-bar";
uiRoot.appendChild(this.root); topRight.appendChild(this.root);
uiRoot.appendChild(topRight);
this.register(ResourceType.Wood); 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) { static register(type: ResourceType) {
if (!this.root || this.entries.has(type)) return; if (!this.root || this.entries.has(type)) return;
const counter = document.createElement("div"); const counter = document.createElement("div");
counter.className = "resource-counter"; counter.className = "resource-counter";
counter.dataset.resource = type; counter.dataset.resource = type;
counter.style.backgroundImage = `url(${UI_IMAGES.woodCounterBg})`;
const icon = document.createElement("div");
icon.className = "resource-icon";
icon.style.backgroundColor = PLACEHOLDER_COLORS[type];
const count = document.createElement("span"); const count = document.createElement("span");
count.className = "resource-count"; count.className = "resource-count";
count.textContent = "0"; count.textContent = "0";
counter.appendChild(icon); const icon = document.createElement("div");
icon.className = "resource-icon";
icon.setAttribute("aria-hidden", "true");
counter.appendChild(count); counter.appendChild(count);
counter.appendChild(icon);
this.root.appendChild(counter); this.root.appendChild(counter);
this.entries.set(type, { type, icon, count }); this.entries.set(type, { type, counter, icon, count });
this.refresh(type); this.refresh(type);
} }
@@ -54,7 +97,7 @@ export class ResourceUIC {
const entry = this.entries.get(type); const entry = this.entries.get(type);
if (!entry) return null; if (!entry) return null;
const rect = entry.icon.getBoundingClientRect(); const rect = entry.counter.getBoundingClientRect();
return { return {
x: rect.left + rect.width / 2, x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2, y: rect.top + rect.height / 2,
+17 -8
View File
@@ -1,22 +1,31 @@
import { Vector3 } from "three"; import { ThreeC } from "./ThreeC";
import { Player } from "./Presets/Player"; import { Player } from "./Presets/Player";
import { Map } from "./Map/Map"; import { Map } from "./Map/Map";
import { GatherC } from "./Map/GatherC"; import { GatherC } from "./Map/GatherC";
import { DepositZoneC } from "./Map/DepositZoneC";
import { ResourceUIC } from "./Resources/ResourceUIC"; import { ResourceUIC } from "./Resources/ResourceUIC";
import { ResourceFlyC } from "./Resources/ResourceFlyC"; import { ResourceFlyC } from "./Resources/ResourceFlyC";
import { ResourceDepositC } from "./Resources/ResourceDepositC";
import { PropHpUIC } from "./Map/PropHpUIC";
import { PropVfxC } from "./Map/PropVfxC";
import { InvasionProgressUIC } from "./UI/InvasionProgressUIC";
export class TestSceneC { export class TestSceneC {
static init() { static init() {
InvasionProgressUIC.init();
ResourceUIC.init(); ResourceUIC.init();
ResourceFlyC.init(); ResourceFlyC.init();
Map.Init(); PropHpUIC.init();
this.InitPlayer(); PropVfxC.Init();
Map.init();
GatherC.init(); GatherC.init();
} Player.init();
private static InitPlayer() { const mapObject = ThreeC.getObject("map");
const playerSpawnPoint = new Vector3(4, 0, 27); if (mapObject) {
Player.SetSpawnPosition(playerSpawnPoint); DepositZoneC.init(mapObject);
Player.Init(); }
ResourceDepositC.init();
} }
} }
-38
View File
@@ -65,42 +65,4 @@ export class ThreeC extends ThreeC_internal {
dirLight.shadow.camera.near = 0.5; dirLight.shadow.camera.near = 0.5;
dirLight.shadow.camera.far = 200; dirLight.shadow.camera.far = 200;
} }
// static setupTopDownLighting() {
// let dirLight = this.defaultDirectionalLight;
// if (!dirLight) {
// this.defaultDirectionalLight = new DirectionalLight(0xffffff, 2.5);
// dirLight = this.defaultDirectionalLight;
// }
// // Позиціонуємо світло для топ-даун виду і додаємо в сцену
// dirLight.position.set(15, 20, -15);
// dirLight.intensity = 3.0;
// dirLight.target.position.set(0, 0, 0);
// dirLight.castShadow = true;
// const d = 25;
// dirLight.shadow.camera.left = -d;
// dirLight.shadow.camera.right = d;
// dirLight.shadow.camera.top = d;
// dirLight.shadow.camera.bottom = -d;
// dirLight.shadow.mapSize.width = 2048;
// dirLight.shadow.mapSize.height = 2048;
// dirLight.shadow.camera.near = 0.5;
// dirLight.shadow.camera.far = 200;
// this.addToScene(dirLight);
// // Ensure ambient light exists and is stronger for top-down
// if (!this.defaultAmbientLight) {
// this.defaultAmbientLight = new AmbientLight(0xffffff, 1.5);
// }
// this.defaultAmbientLight.intensity = 1.6;
// this.addToScene(this.defaultAmbientLight);
// // Add a soft hemisphere light to fill shadows
// const hemi = new HemisphereLight(0xffffbb, 0x080820, 0.6);
// this.addToScene(hemi);
// }
} }
+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));
}
}
+6 -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 {
+264 -13
View File
@@ -1,37 +1,176 @@
.resource-bar { .hud-top-center {
position: fixed; position: absolute;
top: 2vh; top: 2vh;
left: 12vw; left: 50%;
transform: translateX(-50%);
width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1vh; align-items: center;
gap: 2vh;
overflow: visible;
pointer-events: none; pointer-events: none;
z-index: 10; 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 { .resource-counter {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1vw; justify-content: flex-start;
padding: 0.6vh 1.2vw; width: 12vh;
background: rgba(0, 0, 0, 0.45); height: 4vh;
border-radius: 0.8vh; 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 { .resource-icon {
width: 4vh; position: absolute;
height: 4vh; right: 1.2vh;
border-radius: 0.5vh; top: 50%;
border: 0.2vh solid rgba(255, 255, 255, 0.35); transform: translateY(-50%);
width: 4.5vh;
height: 4.5vh;
border-radius: 0.4vh;
flex-shrink: 0; flex-shrink: 0;
} }
.resource-count { .resource-count {
min-width: 2ch; min-width: 2ch;
color: #ffffff; color: #ffffff;
font-size: 3vh; 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; font-weight: 700;
line-height: 1; line-height: 1;
white-space: nowrap;
-webkit-text-stroke: 0.12vh #000000;
paint-order: stroke fill;
} }
.resource-fly-pickup { .resource-fly-pickup {
@@ -44,3 +183,115 @@
pointer-events: none; pointer-events: none;
z-index: 20; z-index: 20;
} }
.prop-hp-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.prop-hp-bar {
position: absolute;
width: 8vh;
height: 1.2vh;
transform: translate(-50%, -100%);
opacity: 0;
visibility: hidden;
pointer-events: none;
z-index: 15;
transition: opacity 0.15s ease;
}
.prop-hp-bar.is-visible {
opacity: 1;
visibility: visible;
}
.prop-hp-bar__track {
position: relative;
width: 100%;
height: 100%;
background: #000000;
border-radius: 0.25vh;
overflow: hidden;
}
.prop-hp-bar__delayed,
.prop-hp-bar__current {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 0.25vh;
width: 100%;
}
.prop-hp-bar__delayed {
background: #ffffff;
z-index: 1;
}
.prop-hp-bar__current {
background: #3b8bff;
z-index: 2;
}
@media (orientation: landscape) {
.hud-top-center {
top: 1.2vh;
width: 55%;
max-width: 36vh;
gap: 0.5vh;
}
.hud-invasion-title {
font-size: 2.6vh;
}
.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: 10vh;
}
.resource-counter {
width: 12vh;
height: 4vh;
padding: 0 7.5vh 0 2.5vh;
}
.resource-icon {
width: 3.8vh;
height: 3.8vh;
right: 1vh;
}
.resource-count {
font-size: 2.2vh;
}
.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
View File
@@ -33,7 +33,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;
+4
View File
@@ -13,6 +13,10 @@ export const meshes : ConvertResourceType = {
name: "character", name: "character",
value: ConvertToBase64WhenRelease("./ZombiePunk_Character.glb"), value: ConvertToBase64WhenRelease("./ZombiePunk_Character.glb"),
}, },
{
name: "wood",
value: ConvertToBase64WhenRelease("./wood.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,
};
@@ -24,7 +24,7 @@ export const beforeResourcesLoadedCb = () => {
ThreeC_internal.init(); ThreeC_internal.init();
ThreeC.createBaseLights(); ThreeC.createBaseLights();
ThreeC.setupDirectionalLight(); ThreeC.setupDirectionalLight();
PlayerInput.InitJoystick(); PlayerInput.initJoystick();
let physicsWorld = Physics_internal.init(new Vec3(0, 0, 0)); let physicsWorld = Physics_internal.init(new Vec3(0, 0, 0));
+3 -1
View File
@@ -1,3 +1,5 @@
import { InvasionProgressUIC } from "../controllers/UI/InvasionProgressUIC";
export const firstClickCb: () => void = () => { export const firstClickCb: () => void = () => {
console.log("First click"); InvasionProgressUIC.startTimer();
}; };
+2
View File
@@ -1,5 +1,7 @@
import { Template3d } from "@24tools/playable_template"; import { Template3d } from "@24tools/playable_template";
import { CameraC } from "../controllers/CameraC";
export const resizeCb = () => { export const resizeCb = () => {
Template3d.resize(); Template3d.resize();
CameraC.setCamera(window.screenSize.portrait);
}; };
+1 -1
View File
File diff suppressed because one or more lines are too long