diff --git a/package.json b/package.json index 1696284..d8d48bc 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "cannon-es-debugger": "^1.0.0", "howler": "^2.2.4", "nipplejs": "^1.0.3", - "three": "^0.184.0" + "three": "^0.184.0", + "three.quarks": "^0.17.1" }, "devDependencies": { "@types/howler": "^2.2.13", diff --git a/src/configUIParams/globalSettings.ts b/src/configUIParams/globalSettings.ts index 9feefba..9aad6d6 100644 --- a/src/configUIParams/globalSettings.ts +++ b/src/configUIParams/globalSettings.ts @@ -43,17 +43,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [ [ -10, 10, - 6 + -4 ], [ 0, - 8, - 9 + 15, + 11 ], [ -10, 10, - 7 + -7 ] ] }, @@ -67,7 +67,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [ [ -360, 360, - -5 + 0 ], [ -360, @@ -88,7 +88,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [ values: [ 30, 150, - 55 + 30 ] }, { @@ -101,17 +101,17 @@ export const globalSettings: ConfigUiParamsCategories[] = [ [ -10, 10, - 2 + -4 ], [ 0, - 8, - 5 + 15, + 9 ], [ -10, 10, - 3 + -7 ] ] }, @@ -125,7 +125,7 @@ export const globalSettings: ConfigUiParamsCategories[] = [ [ -360, 360, - 0 + 5 ], [ -360, diff --git a/src/controllers/Enums/VFXType.ts b/src/controllers/Enums/VFXType.ts new file mode 100644 index 0000000..5f75c67 --- /dev/null +++ b/src/controllers/Enums/VFXType.ts @@ -0,0 +1,4 @@ +export enum VFXType { + LootableHit = "lootable_hit", + LootableDestroy = "lootable_destroy", +} diff --git a/src/controllers/Map/DepositZoneC.ts b/src/controllers/Map/DepositZoneC.ts new file mode 100644 index 0000000..7722e13 --- /dev/null +++ b/src/controllers/Map/DepositZoneC.ts @@ -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({}); + } +} diff --git a/src/controllers/Map/GatherC.ts b/src/controllers/Map/GatherC.ts index eee13b3..0c0c1bf 100644 --- a/src/controllers/Map/GatherC.ts +++ b/src/controllers/Map/GatherC.ts @@ -1,70 +1,140 @@ -import { JoystickC, UpdateController } from "@24tools/playable_template"; +import { EasyEvent, JoystickC, UpdateController } from "@24tools/playable_template"; import { Vector3 } from "three"; import { PhysicsTriggerC } from "./PhysicsTriggerC"; import { PropC, PropRegistry } from "./PropC"; import { Player } from "../Presets/Player"; export class GatherC { + static readonly onCombatIdle = new EasyEvent<{}>(); + private static activeProps = new Set(); private static pendingAutoAttack = false; + private static combatIdleNotified = false; static init() { + PropC.onBroken.addDelegate(() => this.onPropDestroyed()); + PhysicsTriggerC.onTriggerEnter.addDelegate((payload) => { const prop = PropRegistry.get(payload.lootableObject); if (!prop || prop.isBroken) return; + console.log(`[GatherC] enter gather zone: ${payload.lootableObject.name}`); this.activeProps.add(prop); this.scheduleAutoAttackCheck(); }); PhysicsTriggerC.onTriggerExit.addDelegate((payload) => { + console.log(`[GatherC] exit gather zone: ${payload.lootableObject.name}`); + const prop = PropRegistry.get(payload.lootableObject); if (prop) this.activeProps.delete(prop); this.removeBrokenProps(); - - if (this.getAttackableProps().length === 0) { - Player.stopAutoAttack(); - } + this.resyncActivePropsFromPhysics(); + this.handleRemainingTargets(); }); JoystickC.onJoysticEnd.addDelegate(() => { this.scheduleAutoAttackCheck(); }); - UpdateController.Instance.onUpdate.addDelegate(() => { - if (!this.pendingAutoAttack) return; - this.pendingAutoAttack = false; - this.tryStartAutoAttack(); + JoystickC.onJoysticMove.addDelegate(() => { + if (!Player.isMoving()) { + this.scheduleAutoAttackCheck(); + } }); + + UpdateController.Instance.onUpdate.addDelegate(() => { + if (this.pendingAutoAttack) { + this.pendingAutoAttack = false; + this.tryStartAutoAttack(); + return; + } + + this.tryResumeAutoAttack(); + this.notifyCombatIdleIfNeeded(); + }); + } + + private static onPropDestroyed() { + this.removeBrokenProps(); + this.resyncActivePropsFromPhysics(); + this.handleRemainingTargets(); } private static scheduleAutoAttackCheck() { this.pendingAutoAttack = true; } - private static onAttackHit() { + private static onAttackStrike() { for (const prop of this.getAttackableProps()) { prop.takeDamage(1); } this.removeBrokenProps(); + this.resyncActivePropsFromPhysics(); + this.handleRemainingTargets(); + } - if (this.getAttackableProps().length === 0) { - Player.stopAutoAttack(); + private static tryResumeAutoAttack() { + if (this.getAttackableProps().length === 0) return; + if (Player.isAutoAttackActive() || Player.isCombatBusy()) return; + if (Player.isMoving()) return; + this.tryStartAutoAttack(); + } + + private static handleRemainingTargets() { + const props = this.getAttackableProps(); + if (props.length === 0) { + if (Player.isAutoAttackActive() || Player.isCombatBusy()) { + Player.stopAutoAttack(); + } + this.notifyCombatIdleIfNeeded(); 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() { const props = this.getAttackableProps(); if (props.length === 0) return; - if (Player.isMoving()) return; + if (Player.isMoving() || Player.isCombatBusy()) return; 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 { @@ -93,4 +163,21 @@ export class GatherC { 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({}); + } } diff --git a/src/controllers/Map/InteractiveZoneC.ts b/src/controllers/Map/InteractiveZoneC.ts new file mode 100644 index 0000000..8340270 --- /dev/null +++ b/src/controllers/Map/InteractiveZoneC.ts @@ -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(); + + 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); + }); + } +} diff --git a/src/controllers/Map/Map.ts b/src/controllers/Map/Map.ts index b55f11e..55f3701 100644 --- a/src/controllers/Map/Map.ts +++ b/src/controllers/Map/Map.ts @@ -3,13 +3,16 @@ import { ThreeC } from "../ThreeC"; import { PhysicsBody, PhysicsLayer } from "../PhysicsC"; import { PhysicsTriggerC } from "./PhysicsTriggerC"; import { PropC } from "./PropC"; +import { findDamageStateLayers } from "./PropDamageLayers"; import { resolvePropType } from "../Resources/PropDropTable"; +import { InteractiveZoneC } from "./InteractiveZoneC"; + const MAP_PHYSICS_LAYERS = ["Colliders", "Lootable"]; -const GATHER_TRIGGER_PADDING = 0.2; +const GATHER_TRIGGER_PADDING = 0.5; export class Map { - static Init() { + static init() { const mapObject = ThreeC.getObject("map"); if (!mapObject) { console.warn("Map model resource not found: map"); @@ -22,6 +25,7 @@ export class Map { PhysicsTriggerC.init(); this.buildPhysics(mapObject); + InteractiveZoneC.init(mapObject); } private static buildPhysics(mapObject: Object3D) { @@ -55,7 +59,10 @@ export class Map { private static setupLootableObject(lootableObject: Object3D) { 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 visualMeshes = this.getVisualMeshes(lootableObject); diff --git a/src/controllers/Map/MapInteractiveZone.ts b/src/controllers/Map/MapInteractiveZone.ts new file mode 100644 index 0000000..8f30267 --- /dev/null +++ b/src/controllers/Map/MapInteractiveZone.ts @@ -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); +} diff --git a/src/controllers/Map/PhysicsTriggerC.ts b/src/controllers/Map/PhysicsTriggerC.ts index cbe2914..75ddd49 100644 --- a/src/controllers/Map/PhysicsTriggerC.ts +++ b/src/controllers/Map/PhysicsTriggerC.ts @@ -1,11 +1,9 @@ import { EasyEvent, - Physics_internal, UpdateController, } from "@24tools/playable_template"; -import { Object3D } from "three"; -import { Body, Box } from "cannon-es"; -import { PhysicsBody, PhysicsLayer } from "../PhysicsC"; +import { Box3, Object3D, Vector3 } from "three"; +import { Body } from "cannon-es"; const PLAYER_RADIUS = 0.3; @@ -18,15 +16,15 @@ export type TriggerEventPayload = { type TriggerRecord = { lootableObject: Object3D; triggerObject: Object3D; + center: Vector3; radius: number; - position: Body["position"]; - physicsBody: PhysicsBody; }; export class PhysicsTriggerC { private static inited = false; private static triggers: TriggerRecord[] = []; private static activeTriggers = new Set(); + private static playerBody: Body | null = null; static onTriggerEnter = new EasyEvent(); static onTriggerExit = new EasyEvent(); @@ -38,50 +36,62 @@ export class PhysicsTriggerC { UpdateController.Instance.onUpdate.addDelegate(() => this.update()); } + static setPlayerBody(body: Body) { + this.playerBody = body; + } + static register(triggerObject: Object3D, lootableObject: Object3D) { - const physicsBody = new PhysicsBody( - triggerObject, - true, - 0, - PhysicsLayer.Trigger, - PhysicsLayer.Player, - ); + triggerObject.updateWorldMatrix(true, true); - const body = physicsBody.getPhysicsBody(); - body.allowSleep = false; - - const shape = body.shapes[0] as Box; - const halfExtents = shape.halfExtents; + const center = new Vector3(); + const size = new Vector3(); + const bounds = new Box3().setFromObject(triggerObject); + bounds.getCenter(center); + bounds.getSize(size); this.triggers.push({ lootableObject, triggerObject, - radius: Math.max(halfExtents.x, halfExtents.y, halfExtents.z), - position: body.position, - physicsBody, + center, + radius: Math.max(size.x, size.y, size.z) / 2, }); } + static getActiveLootableObjects(): Object3D[] { + const lootables: Object3D[] = []; + for (const record of this.activeTriggers) { + lootables.push(record.lootableObject); + } + return lootables; + } + static unregister(lootableObject: Object3D) { + const playerBody = this.playerBody; + this.triggers = this.triggers.filter((record) => { if (record.lootableObject !== lootableObject) return true; - record.physicsBody.destroy(); + if (this.activeTriggers.has(record) && playerBody) { + this.onTriggerExit.Invoke({ + lootableObject: record.lootableObject, + triggerObject: record.triggerObject, + playerBody, + }); + } + this.activeTriggers.delete(record); return false; }); } private static update() { - const playerBody = Physics_internal.physicsWorld?.bodies.find( - (body) => body.collisionFilterGroup === PhysicsLayer.Player, - ); + const playerBody = this.playerBody; if (!playerBody) return; for (const trigger of this.triggers) { - const dx = playerBody.position.x - trigger.position.x; - const dy = playerBody.position.y - trigger.position.y; - const dz = playerBody.position.z - trigger.position.z; + const dx = playerBody.position.x - trigger.center.x; + const dy = playerBody.position.y - trigger.center.y; + const dz = playerBody.position.z - trigger.center.z; const distanceSq = dx * dx + dy * dy + dz * dz; const isInside = distanceSq <= (trigger.radius + PLAYER_RADIUS) ** 2; const wasInside = this.activeTriggers.has(trigger); @@ -97,11 +107,9 @@ export class PhysicsTriggerC { if (isInside) { this.activeTriggers.add(trigger); this.onTriggerEnter.Invoke(payload); - console.log("[Gather Enter]", trigger.lootableObject.name); } else { this.activeTriggers.delete(trigger); this.onTriggerExit.Invoke(payload); - console.log("[Gather Exit]", trigger.lootableObject.name); } } } diff --git a/src/controllers/Map/PropC.ts b/src/controllers/Map/PropC.ts index 0a415e5..69aee83 100644 --- a/src/controllers/Map/PropC.ts +++ b/src/controllers/Map/PropC.ts @@ -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 { PropType } from "./PropType"; import { PhysicsTriggerC } from "./PhysicsTriggerC"; import { PROP_DROPS } from "../Resources/PropDropTable"; import { createDropPlan, PropDropPlan } from "../Resources/PropDropPlanner"; import { ResourceSpawnC } from "../Resources/ResourceSpawnC"; +import { PropHpUIC } from "./PropHpUIC"; +import { PropHpBar } from "./PropHpBar"; +import { PropVfxC } from "./PropVfxC"; +import { VFXType } from "../Enums/VFXType"; + +/** Тривалість тряски після удару (секунди). */ +const SHAKE_DURATION = 0.22; +/** Амплітуда зміщення моделі по X/Z під час тряски (world units). */ +const SHAKE_POSITION_AMP = 0.045; +/** Амплітуда нахилу моделі під час тряски (радіани). */ +const SHAKE_ROTATION_AMP = 0.1; +/** Частота коливань тряски (кількість «хитань» за секунду). */ +const SHAKE_FREQUENCY = 2; export class PropRegistry { private static props = new Map(); @@ -16,9 +30,15 @@ export class PropRegistry { static get(object: Object3D) { return this.props.get(object); } + + static unregister(object: Object3D) { + this.props.delete(object); + } } export class PropC { + static readonly onBroken = new EasyEvent(); + readonly object: Object3D; readonly propType: PropType; health: number; @@ -27,44 +47,171 @@ export class PropC { private wallBodies: PhysicsBody[] = []; 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(); + private static shakeSystemInited = false; + + constructor(object: Object3D, propType: PropType, maxHealth = 1) { this.object = object; this.propType = propType; this.maxHealth = maxHealth; this.health = maxHealth; + this.restPosition.copy(object.position); + this.restRotation.copy(object.rotation); PropRegistry.register(this); + this.hpBar = PropHpUIC.createBar(this) ?? null; + PropC.ensureShakeSystem(); + } + + private static ensureShakeSystem() { + if (this.shakeSystemInited) return; + this.shakeSystemInited = true; + + UpdateController.Instance.onUpdate.addDelegate((delta) => { + for (const prop of [...this.shakingProps]) { + prop.updateShake(delta); + } + }); } addWallBody(physicsBody: PhysicsBody) { this.wallBodies.push(physicsBody); } + initDamageLayers(layers: Object3D[]) { + this.damageLayers = layers; + } + takeDamage(amount: number): boolean { if (this.isBroken) return false; const hitIndex = this.maxHealth - this.health; this.ensureDropPlan(); + this.applyDamageVisual(hitIndex); this.health -= amount; this.spawnForHit(hitIndex); + this.hpBar?.onDamage(); + PropVfxC.Play(VFXType.LootableHit, null, this.object.getWorldPosition(new Vector3())); - if (this.health > 0) return false; + if (this.health > 0) { + this.playHitShake(); + return false; + } - this.break(); + this.playHitShake(() => this.break()); return true; } + private applyDamageVisual(hitIndex: number) { + const layer = this.damageLayers[hitIndex]; + if (!layer) return; + + layer.visible = false; + } + + private playHitShake(onComplete?: () => void) { + if (this.isBroken) return; + + if (onComplete) { + this.onShakeComplete = onComplete; + } + + this.shakeTimeLeft = SHAKE_DURATION; + this.shakeElapsed = 0; + PropC.shakingProps.add(this); + } + + private updateShake(delta: number) { + if (this.shakeTimeLeft <= 0) { + this.finishShake(); + return; + } + + this.shakeTimeLeft -= delta; + this.shakeElapsed += delta; + + const progress = Math.max(this.shakeTimeLeft / SHAKE_DURATION, 0); + const intensity = progress * progress; + const phase = this.shakeElapsed * SHAKE_FREQUENCY * Math.PI * 2; + const wobble = Math.sin(phase); + + this.object.position.set( + this.restPosition.x + wobble * SHAKE_POSITION_AMP * intensity, + this.restPosition.y, + this.restPosition.z + Math.cos(phase) * SHAKE_POSITION_AMP * intensity * 0.75, + ); + + this.object.rotation.set( + this.restRotation.x + wobble * SHAKE_ROTATION_AMP * intensity * 0.35, + this.restRotation.y, + this.restRotation.z + Math.cos(phase) * SHAKE_ROTATION_AMP * intensity, + ); + + if (this.shakeTimeLeft <= 0) { + this.finishShake(); + } + } + + private finishShake() { + this.stopShake(); + + const callback = this.onShakeComplete; + this.onShakeComplete = null; + callback?.(); + } + + private stopShake() { + this.object.position.copy(this.restPosition); + this.object.rotation.copy(this.restRotation); + this.shakeTimeLeft = 0; + PropC.shakingProps.delete(this); + } + break() { if (this.isBroken) return; this.isBroken = true; - this.object.visible = false; + this.onShakeComplete = null; + this.stopShake(); + + PropHpUIC.removeBar(this); + this.hpBar = null; this.wallBodies.forEach((body) => body.destroy()); this.wallBodies = []; PhysicsTriggerC.unregister(this.object); + PropC.onBroken.Invoke(this); + PropRegistry.unregister(this.object); + + PropVfxC.Play(VFXType.LootableDestroy, null, this.object.getWorldPosition(new Vector3())); + + this.disposeProceduralResources(); + this.object.parent?.remove(this.object); + } + + private disposeProceduralResources() { + const trigger = this.object.getObjectByName("GatherTrigger"); + if (!trigger || !(trigger as Mesh).isMesh) return; + + const mesh = trigger as Mesh; + mesh.geometry.dispose(); + + const material = mesh.material; + if (Array.isArray(material)) { + material.forEach((entry) => entry.dispose()); + } else if (material) { + (material as Material).dispose(); + } } private ensureDropPlan() { diff --git a/src/controllers/Map/PropDamageLayers.ts b/src/controllers/Map/PropDamageLayers.ts new file mode 100644 index 0000000..1d6e317 --- /dev/null +++ b/src/controllers/Map/PropDamageLayers.ts @@ -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)!); +} diff --git a/src/controllers/Map/PropHpBar.ts b/src/controllers/Map/PropHpBar.ts new file mode 100644 index 0000000..a6be40f --- /dev/null +++ b/src/controllers/Map/PropHpBar.ts @@ -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(); + } +} diff --git a/src/controllers/Map/PropHpUIC.ts b/src/controllers/Map/PropHpUIC.ts new file mode 100644 index 0000000..7f4e961 --- /dev/null +++ b/src/controllers/Map/PropHpUIC.ts @@ -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(); + + 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); + } +} diff --git a/src/controllers/Map/PropVfxC.ts b/src/controllers/Map/PropVfxC.ts new file mode 100644 index 0000000..a06643e --- /dev/null +++ b/src/controllers/Map/PropVfxC.ts @@ -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); + } +} diff --git a/src/controllers/Presets/Character/Character.ts b/src/controllers/Presets/Character/Character.ts index d8ef841..648c987 100644 --- a/src/controllers/Presets/Character/Character.ts +++ b/src/controllers/Presets/Character/Character.ts @@ -1,5 +1,5 @@ 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 { ThreeC } from "../../ThreeC"; import { GLTF } from "three/examples/jsm/loaders/GLTFLoader"; @@ -9,23 +9,21 @@ export class Character { animMixer: AnimationMixer; animationList: AnimationClip[] = []; - // isWalking: boolean = false; curClipAction: null | AnimationAction = null; - animStopTimeout: null | NodeJS.Timeout = null onAnimLoop: EasyEvent<{}> = new EasyEvent<{}>(); onAnimFinish: EasyEvent<{}> = new EasyEvent<{}>(); constructor(prefab: GLTF, start_position = new Vector3()) { - let tObj = clone(prefab.scene); + const tObj = clone(prefab.scene); tObj.castShadow = true; - let animMixer = new AnimationMixer(tObj); + const animMixer = new AnimationMixer(tObj); - animMixer.addEventListener('loop', () => { + animMixer.addEventListener("loop", () => { this.onAnimLoop.Invoke({}); }); - animMixer.addEventListener('finished', () => { + animMixer.addEventListener("finished", () => { this.onAnimFinish.Invoke({}); }); @@ -35,20 +33,21 @@ export class Character { this.animMixer = animMixer; this.animationList = prefab.animations; - ThreeC.addToScene(tObj); ThreeC.addAnimMixer(animMixer); return this; } set AnimationSpeed(timeScale: number) { - if (this.curClipAction) + if (this.curClipAction) { this.curClipAction.timeScale = timeScale; + } } set AnimationWeight(weight: number) { - if (this.curClipAction) + if (this.curClipAction) { this.curClipAction.weight = weight; + } } setObjectVisible(name: string, visible: boolean) { @@ -71,27 +70,63 @@ export class Character { this.setObjectVisible("Character_Pistol", false); } - playAnimation(anim_id: number, one_time: boolean = false, fade = 0.25, randomStart = false) { - let oldClipAction: null | AnimationAction = this.curClipAction; - var clipAction = this.animMixer.clipAction(this.animationList[anim_id]); + isPlayingAnimation(anim_id: number) { + if (!this.curClipAction || anim_id < 0 || anim_id >= this.animationList.length) { + return false; + } + + return this.curClipAction.getClip() === this.animationList[anim_id]; + } + + playAnimation(anim_id: number, one_time = false, fade = 0.25, randomStart = false) { + const oldClipAction = this.curClipAction; + const clipAction = this.animMixer.clipAction(this.animationList[anim_id]); if (one_time) { clipAction.clampWhenFinished = true; clipAction.setLoop(LoopOnce, 1); - } - else { + } else { clipAction.clampWhenFinished = false; - clipAction.setLoop(LoopRepeat, Infinity); + clipAction.setLoop(LoopRepeat, Infinity); } clipAction.timeScale = 1; - clipAction.weight = 1; + + if (oldClipAction) { + oldClipAction.fadeOut(fade); + } clipAction.reset(); - if (randomStart) + if (randomStart) { clipAction.time = Math.random() * this.animationList[anim_id].duration; + } clipAction.play(); - if (oldClipAction && oldClipAction != clipAction) - oldClipAction.crossFadeTo(clipAction, fade, true); + clipAction.fadeIn(fade); this.curClipAction = clipAction; } -} \ No newline at end of file + + /** 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; + } +} diff --git a/src/controllers/Presets/Enums/ResourcesType.ts b/src/controllers/Presets/Enums/ResourcesType.ts index 16791d8..5149f79 100644 --- a/src/controllers/Presets/Enums/ResourcesType.ts +++ b/src/controllers/Presets/Enums/ResourcesType.ts @@ -1,3 +1,4 @@ export enum ResourcesType { Mesh = "mesh", + VFX = "vfx", } \ No newline at end of file diff --git a/src/controllers/Presets/Input/MoveInput.ts b/src/controllers/Presets/Input/MoveInput.ts index 0e2cdc8..3812e9b 100644 --- a/src/controllers/Presets/Input/MoveInput.ts +++ b/src/controllers/Presets/Input/MoveInput.ts @@ -2,5 +2,4 @@ import { Vector3 } from "three"; export interface IMoveInput { get CurrentDirection(): Vector3; - update(delta); -} \ No newline at end of file +} diff --git a/src/controllers/Presets/Input/PlayerInput.ts b/src/controllers/Presets/Input/PlayerInput.ts index 54d151b..45d50ab 100644 --- a/src/controllers/Presets/Input/PlayerInput.ts +++ b/src/controllers/Presets/Input/PlayerInput.ts @@ -1,109 +1,79 @@ -import { Delegate, JoystickC, UpdateController } from "@24tools/playable_template"; +import { Delegate, JoystickC } from "@24tools/playable_template"; import { Vector3 } from "three"; import { IMoveInput } from "./MoveInput"; import { FollowCameraC } from "../Movment/CameraMovment/FollowCamera"; -// type JoystickVectorData = { -// vector?: { -// x: number; -// y: number; -// }; -// }; - -// type JoystickPayload = { -// event?: Event; -// data?: JoystickVectorData; -// }; - export class PlayerInput implements IMoveInput { + private static threshold = 0.25; - - public static InitJoystick() { + public static initJoystick() { const screenSize = window.screenSize; const minSize = Math.min(screenSize.width, screenSize.height); const joystickSizeAspect = 0.2; - const fadeTime = 200; const options = { zone: document.getElementById("joystick_zone") as HTMLElement, size: minSize * joystickSizeAspect, restJoystick: true, dynamicPage: true, catchDistance: minSize * joystickSizeAspect / 2, - fadeTime: fadeTime, + fadeTime: 200, }; JoystickC.init(options); - - // JoystickC.onJoysticMove.addDelegate(({ event, data }) => { - // console.log('onJoysticMove', event, data); - // }) } - protected currentDirection: Vector3 = new Vector3(); - private updateDelegate: Delegate; - private StartDelegate: Delegate; - private MoveDelegate: Delegate; - private StopDelegate: Delegate; - private static threshold = 0.25; + protected currentDirection = new Vector3(); + private moveDelegate: Delegate; + private stopDelegate: Delegate; + private startDelegate: Delegate; - get CurrentDirection() { return this.currentDirection.clone(); }; - get IsActive() { return this.inputaActive; }; + get CurrentDirection() { + return this.currentDirection; + } - inputaActive: boolean = false; + get IsActive() { + return this.inputActive; + } + inputActive = false; constructor() { - this.updateDelegate = UpdateController.Instance.onUpdate.addDelegate(this.update.bind(this)); - - this.MoveDelegate = JoystickC.onJoysticMove.addDelegate(this.onTouchMove.bind(this)); - // "down" also carries joystick data in this SDK, useful for first non-zero direction. - 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) { - + this.moveDelegate = JoystickC.onJoysticMove.addDelegate(this.onTouchMove.bind(this)); + JoystickC.onJoysticDown.addListener(this.moveDelegate); + this.stopDelegate = JoystickC.onJoysticEnd.addDelegate(this.onTouchUp.bind(this)); + this.startDelegate = JoystickC.onJoysticStart.addDelegate(this.onTouchDown.bind(this)); } onTouchMove(payload: any) { - this.currentDirection = this.GetDiraction(payload); - if (this.currentDirection.length() <= PlayerInput.threshold) + this.getDirection(payload, this.currentDirection); + if (this.currentDirection.length() <= PlayerInput.threshold) { this.currentDirection.multiplyScalar(0); + } } onTouchDown(_event: any) { - // console.log(event); - - if (this.inputaActive) return; - this.inputaActive = true; + if (this.inputActive) return; + this.inputActive = true; } onTouchUp() { - if (!this.inputaActive) return; - this.inputaActive = false; + if (!this.inputActive) return; + this.inputActive = false; this.currentDirection.multiplyScalar(0); } - GetDiraction(payload: any) { - // Compatible with old/new nipplejs payloads wrapped by JoystickC: - // - { event, data } where data.vector exists - // - { event, data: undefined } where event.data.vector exists + private getDirection(payload: any, out: Vector3) { const normalizedData = payload?.data ?? payload?.event?.data ?? payload?.event; const vector = normalizedData?.vector; - if (!vector) return new Vector3(); + if (!vector) { + out.set(0, 0, 0); + return out; + } - const x = vector.x; - const y = vector.y; - const dir = new Vector3(-x, 0, y); - dir.applyEuler(FollowCameraC.RotationCorection); - - return dir; + out.set(-vector.x, 0, vector.y); + out.applyEuler(FollowCameraC.RotationCorrection); + return out; } - -} \ No newline at end of file +} diff --git a/src/controllers/Presets/Movment/CameraMovment/FollowCamera.ts b/src/controllers/Presets/Movment/CameraMovment/FollowCamera.ts index 1e789f5..b244307 100644 --- a/src/controllers/Presets/Movment/CameraMovment/FollowCamera.ts +++ b/src/controllers/Presets/Movment/CameraMovment/FollowCamera.ts @@ -12,23 +12,21 @@ export class FollowCameraC { static cameraContainer: Object3D = new Object3D(); static cameraRotation: Object3D = new Object3D(); - /** Normalized movement direction set each frame by the player */ static inputDirection: Vector3 = new Vector3(); - /** How far (world units) the camera shifts ahead of the player */ - static lookAheadAmount: number = 2; - /** Lerp speed for the look-ahead offset (lower = smoother/slower) */ - static lookAheadLerpSpeed: number = 0.7; + static lookAheadAmount = 1; + static lookAheadLerpSpeed = 0.7; private static lookAheadCurrent: Vector3 = new Vector3(); + private static lookAheadTarget: Vector3 = new Vector3(); + private static basePos: Vector3 = new Vector3(); + private static targetPos: Vector3 = new Vector3(); + private static oldPos: Vector3 = new Vector3(); - static Init(target: Object3D) { + static init(target: Object3D) { this.target = target; this.offset = this.Offset; - console.log(target); - - - this.updateDelegate = new Delegate(this.Update.bind(this)); + this.updateDelegate = new Delegate(this.update.bind(this)); UpdateController.Instance.onUpdate.addListener(this.updateDelegate); ThreeC.addToScene(this.mainContainer); @@ -45,49 +43,41 @@ export class FollowCameraC { this.cameraContainer.position.y += this.Offset.y; } - private static Update(delta: number) { + private static update(delta: number) { if (!this.target.position) return; - // Lerp look-ahead toward current movement direction (stays at last direction when stopped) - const lookAheadTarget = this.inputDirection.clone().multiplyScalar(this.lookAheadAmount); - this.lookAheadCurrent.lerp(lookAheadTarget, delta * this.lookAheadLerpSpeed); + this.lookAheadTarget.copy(this.inputDirection).multiplyScalar(this.lookAheadAmount); + this.lookAheadCurrent.lerp(this.lookAheadTarget, delta * this.lookAheadLerpSpeed); const offset = this.Offset; - // Base position without look-ahead - const basePos = this.target.position.clone(); - basePos.x += offset.x; - basePos.z += offset.z; + this.basePos.copy(this.target.position); + this.basePos.x += offset.x; + this.basePos.z += offset.z; - // Final target = base + look-ahead shift - const targetPos = basePos.clone(); - targetPos.x += this.lookAheadCurrent.x; - targetPos.z += this.lookAheadCurrent.z; + this.targetPos.copy(this.basePos); + this.targetPos.x += this.lookAheadCurrent.x; + this.targetPos.z += this.lookAheadCurrent.z; - // Capture actual previous position BEFORE any mutation - const oldPos = this.mainContainer.position.clone(); + this.oldPos.copy(this.mainContainer.position); - // Compute rotation using base position to avoid tilt from look-ahead offset - this.mainContainer.position.copy(basePos); + this.mainContainer.position.copy(this.basePos); this.mainContainer.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; - this.mainContainer.position.lerpVectors(oldPos, targetPos, delta * lerpSpeed); + this.mainContainer.position.lerpVectors(this.oldPos, this.targetPos, delta * lerpSpeed); } - static get RotationCorection() { - const rotation = this.mainContainer.rotation.clone(); - return rotation; + static get RotationCorrection() { + return this.mainContainer.rotation.clone(); } static get Offset() { - const portrait = window.screenSize.portrait + const portrait = window.screenSize.portrait; const values = portrait ? Template.getValue("global", "camera_position_p") : Template.getValue("global", "camera_position_l"); - const offset = Helper.returnVectorCamera(values); - return offset + return Helper.returnVectorCamera(values); } -} \ No newline at end of file +} diff --git a/src/controllers/Presets/Movment/MoveC.ts b/src/controllers/Presets/Movment/MoveC.ts index c8c5379..7ccd85c 100644 --- a/src/controllers/Presets/Movment/MoveC.ts +++ b/src/controllers/Presets/Movment/MoveC.ts @@ -1,25 +1,25 @@ -import { Delegate, UpdateController } from "@24tools/playable_template"; import { IMoveInput } from "../Input/MoveInput"; import { Vector3 } from "three"; export class MoveC { - private input: IMoveInput; - private speed: number = 5; - private updateDelegate: Delegate; - private moveDiraction: Vector3 = new Vector3(); - get Diraction() { return this.moveDiraction }; - get Weight() { return this.moveDiraction.length() / this.speed }; + private readonly input: IMoveInput; + private readonly speed: number; + private readonly moveDirection = new Vector3(); - constructor(Input: IMoveInput, speed: number = 5) { - this.updateDelegate = new Delegate(this.update.bind(this)); - UpdateController.Instance.onUpdate.addListener(this.updateDelegate); - this.input = Input; + get Direction() { + return this.moveDirection; + } + + get Weight() { + return this.moveDirection.length() / this.speed; + } + + constructor(input: IMoveInput, speed = 5) { + this.input = input; this.speed = speed; } - private update(delta: number) { - // delta *= TimeC.TimeScale; - const moveStep = this.input.CurrentDirection.multiplyScalar(this.speed); - this.moveDiraction.copy(moveStep); + update(_delta: number) { + this.moveDirection.copy(this.input.CurrentDirection).multiplyScalar(this.speed); } -} \ No newline at end of file +} diff --git a/src/controllers/Presets/Movment/RotationC.ts b/src/controllers/Presets/Movment/RotationC.ts index c3cadda..7f01fa4 100644 --- a/src/controllers/Presets/Movment/RotationC.ts +++ b/src/controllers/Presets/Movment/RotationC.ts @@ -1,37 +1,76 @@ -import { Delegate, UpdateController } from "@24tools/playable_template"; -import { IMoveInput } from "../Input/MoveInput"; import { Object3D, Quaternion, Vector3 } from "three"; export class RotationC { - private target: Object3D; - private input: IMoveInput; - private speed: number = 5; - private updateDelegate: Delegate; - private currentQ: Quaternion = new Quaternion(); - private targetQ: Quaternion = new Quaternion(); + private static readonly completeAngle = 0.01; - constructor(target: Object3D, Input: IMoveInput, speed: number = 5) { - this.updateDelegate = new Delegate(this.update.bind(this)); - UpdateController.Instance.onUpdate.addListener(this.updateDelegate); - this.input = Input; - this.speed = speed; + private readonly target: Object3D; + private readonly speed: number; + private readonly targetDirection = new Vector3(0, 0, 1); + private readonly worldPosition = new Vector3(); + private readonly currentQ = new Quaternion(); + private readonly targetQ = new Quaternion(); + private readonly lookAtPoint = new Vector3(); + + constructor(target: Object3D, speed: number = 5) { this.target = target; - this.currentQ.copy(this.target.quaternion); - this.targetQ.copy(this.target.quaternion); + this.speed = speed; + this.currentQ.copy(target.quaternion); + this.targetQ.copy(target.quaternion); + target.getWorldDirection(this.targetDirection); + this.targetDirection.y = 0; + if (this.targetDirection.lengthSq() > 0) { + this.targetDirection.normalize(); + } else { + this.targetDirection.set(0, 0, 1); + } } - private update(delta: number) { - const loockAtStep = this.input.CurrentDirection; - if (loockAtStep.length() == 0) return; + setTargetDirection(direction: Vector3) { + if (direction.lengthSq() === 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); - this.target.lookAt(loockAtPoint); - this.targetQ.copy(this.target.quaternion); - this.target.quaternion.copy(this.currentQ); + syncToCurrentFacing() { + this.target.getWorldDirection(this.targetDirection); + this.targetDirection.y = 0; + if (this.targetDirection.lengthSq() === 0) return; + this.targetDirection.normalize(); + } + + isComplete() { + this.computeTargetQuaternion(this.targetQ); + return this.currentQ.angleTo(this.targetQ) < RotationC.completeAngle; + } + + update(delta: number) { + this.computeTargetQuaternion(this.targetQ); + + if (this.currentQ.angleTo(this.targetQ) < RotationC.completeAngle) { + this.target.quaternion.copy(this.targetQ); + return; + } this.target.quaternion.slerp(this.targetQ, delta * this.speed); this.currentQ.copy(this.target.quaternion); } -} \ No newline at end of file + + 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); + } +} diff --git a/src/controllers/Presets/Player.ts b/src/controllers/Presets/Player.ts index cbef845..28cdf72 100644 --- a/src/controllers/Presets/Player.ts +++ b/src/controllers/Presets/Player.ts @@ -5,47 +5,36 @@ import { MeshType } from "./Enums/MeshType"; import { GLTF } from "three/examples/jsm/loaders/GLTFLoader"; import { BaseAnimation } from "./Enums/BaseAnimation"; import { PhysicsBody, PhysicsLayer } from "../PhysicsC"; -import { Object3D, Quaternion, Vector3 } from "three"; +import { Object3D, Vector3 } from "three"; import { ThreeC } from "../ThreeC"; import { PlayerInput } from "./Input/PlayerInput"; import { MoveC } from "./Movment/MoveC"; import { Vector3CToT, Vector3TToC } from "./Helper"; import { RotationC } from "./Movment/RotationC"; import { FollowCameraC } from "./Movment/CameraMovment/FollowCamera"; +import { PlayerCombat } from "./PlayerCombat"; +import { PhysicsTriggerC } from "../Map/PhysicsTriggerC"; +import { PropHpUIC } from "../Map/PropHpUIC"; export class Player { - private static inited: boolean = false; - private static isRunning: boolean = 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 inited = false; + private static isRunning = false; private static updateDelegate: Delegate; - 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 movement: MoveC; private static rotation: RotationC; - private static spawnPosition: Vector3 = new Vector3(0, 0, 0); - - private static readonly turnSpeed = 8; - private static readonly turnCompleteAngle = 0.05; + private static spawnPosition = new Vector3(0, 0, -4); static character: Character; static physics: PhysicsBody; - static SetSpawnPosition(position: Vector3) { - this.spawnPosition.copy(position); - } - - static Init() { + static init() { if (this.inited) return; this.inited = true; + const asset = ResourcesC.getResource(ResourcesType.Mesh, MeshType.Character); this.character = new Character(asset); @@ -55,19 +44,31 @@ export class Player { this.container.add(this.character.tObj); ThreeC.addToScene(this.container); - this.InitPhisic(); + this.initPhysics(); + PhysicsTriggerC.setPlayerBody(this.physics.getPhysicsBody()); + this.input = new PlayerInput(); const moveSpeed = 3; const rotationSpeed = 8; 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((delta) => this.Update(delta)); + this.updateDelegate = new Delegate((delta) => this.update(delta)); UpdateController.Instance.onUpdate.addListener(this.updateDelegate); - FollowCameraC.Init(this.container); + FollowCameraC.init(this.container); } static getWorldPosition() { @@ -75,42 +76,39 @@ export class Player { } static isMoving() { - return this.input.IsActive; + return this.movement.Direction.lengthSq() > 0; } - static startAutoAttack(onHit: () => void, targetWorldPosition: Vector3) { - if (this.isAutoAttacking) return; + static isCombatBusy() { + return PlayerCombat.isBusy; + } - this.isAutoAttacking = true; - this.beginTurnToTarget(targetWorldPosition, () => this.playAttack(onHit)); + static isAutoAttackActive() { + return PlayerCombat.isAutoAttackActive; + } + + static startAutoAttack( + onStrike: () => void, + onComplete: () => void, + targetWorldPosition: Vector3, + ) { + PlayerCombat.startAutoAttack(onStrike, onComplete, targetWorldPosition); } static stopAutoAttack() { - this.isAutoAttacking = false; - this.onAttackComplete = null; - this.cancelTurnToTarget(); - this.resetCombatState(); - - if (!this.isAttacking) { - this.StopRunning(); - } + PlayerCombat.stopAutoAttack(); + PropHpUIC.hideAll(); } - static playAttack(onComplete: () => void) { - this.onAttackComplete = onComplete; - this.isAttacking = true; - this.isRunning = false; - - this.physics.getPhysicsBody().velocity.set(0, 0, 0); - - if (!this.isBatEquipped) { - this.equipBat(); - } - - this.playBatAttack(); + static retargetAutoAttack(targetWorldPosition: Vector3) { + PlayerCombat.retargetAutoAttack(targetWorldPosition); } - private static InitPhisic() { + static playAttack(onStrike: () => void) { + PlayerCombat.playAttack(onStrike); + } + + private static initPhysics() { this.physics = new PhysicsBody( this.container, false, @@ -120,136 +118,77 @@ export class Player { ); } - private static beginTurnToTarget(targetWorldPosition: Vector3, onComplete: () => void) { - const worldPosition = this.container.getWorldPosition(new Vector3()); - const lookAtPoint = targetWorldPosition.clone(); - lookAtPoint.y = worldPosition.y; - - 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) { + private static startRunning() { + if (PlayerCombat.isBusy) return; + if (this.isRunning) return; + if (this.character.isPlayingAnimation(BaseAnimation.Run)) { + this.isRunning = true; 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.isRunning = true; } - private static StopRunning() { - if (!this.isRunning || this.isAttacking || this.isTurningToTarget) return; - this.character.playAnimation(BaseAnimation.Idle); - this.AnimationValue = 1; + private static resumeRunning() { + if (PlayerCombat.isBusy) return; + this.isRunning = true; + 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; } - private static set AnimationValue(value: number) { + private static set animationValue(value: number) { this.character.AnimationSpeed = value; this.character.AnimationWeight = value * 12.5 + 87.5; } - private static Update(delta: number) { - if (this.input.IsActive && (this.isAutoAttacking || this.isTurningToTarget)) { - this.stopAutoAttack(); + private static update(delta: number) { + this.movement.update(delta); + + if (this.isMoving()) { + PlayerCombat.cancelOnMoveInput(); } - if (this.isTurningToTarget) { - this.physics.getPhysicsBody().velocity.set(0, 0, 0); - this.updateTurnToTarget(delta); - this.MoveVisual(delta); + if (PlayerCombat.update(delta)) { + this.syncVisual(delta); return; } - if (this.isAttacking) { - this.physics.getPhysicsBody().velocity.set(0, 0, 0); - this.MoveVisual(delta); - return; - } - - const diraction = this.movement.Diraction; + const direction = this.movement.Direction; const weight = this.movement.Weight; + const isMoving = direction.lengthSq() > 0; - if (diraction.length() > 0) { - this.StartRunning(); - this.AnimationValue = weight; - FollowCameraC.inputDirection.copy(diraction).normalize(); + if (isMoving) { + this.startRunning(); + this.animationValue = weight; + FollowCameraC.inputDirection.copy(direction).normalize(); + this.rotation.setTargetDirection(direction); } 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.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 targetPos = Vector3CToT(this.physics.getPhysicsBody().position); - this.container.position.lerp(targetPos, delta * lerpSpeed); } } diff --git a/src/controllers/Presets/PlayerCombat.ts b/src/controllers/Presets/PlayerCombat.ts new file mode 100644 index 0000000..5efc974 --- /dev/null +++ b/src/controllers/Presets/PlayerCombat.ts @@ -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 (0–1) 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(); + } +} diff --git a/src/controllers/Resources/PropDropPlanner.ts b/src/controllers/Resources/PropDropPlanner.ts index da75e63..d450137 100644 --- a/src/controllers/Resources/PropDropPlanner.ts +++ b/src/controllers/Resources/PropDropPlanner.ts @@ -1,3 +1,5 @@ +import { ResourceType } from "./ResourceType"; + function randomInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } @@ -26,8 +28,6 @@ function splitRandom(total: number, parts: number) { return result; } -import { ResourceType } from "./ResourceType"; - export type PropDropPlan = { resource: ResourceType; totalDrop: number; diff --git a/src/controllers/Resources/PropDropTable.ts b/src/controllers/Resources/PropDropTable.ts index ad97e3d..2268b67 100644 --- a/src/controllers/Resources/PropDropTable.ts +++ b/src/controllers/Resources/PropDropTable.ts @@ -14,7 +14,7 @@ export const PROP_DROPS: Record = { { resource: ResourceType.Wood, minTotal: 1, - maxTotal: 3, + maxTotal: 4, minSpawnHits: 1, maxSpawnHits: 2, }, @@ -27,5 +27,6 @@ export function resolvePropType(objectName: string): PropType { return PropType.Box; } + console.warn(`Unknown prop type for "${objectName}", falling back to Box`); return PropType.Box; } diff --git a/src/controllers/Resources/ResourceConfig.ts b/src/controllers/Resources/ResourceConfig.ts new file mode 100644 index 0000000..fb6abad --- /dev/null +++ b/src/controllers/Resources/ResourceConfig.ts @@ -0,0 +1,5 @@ +import { ResourceType } from "./ResourceType"; + +export const RESOURCE_PLACEHOLDER_COLORS: Record = { + [ResourceType.Wood]: "#6b4423", +}; diff --git a/src/controllers/Resources/ResourceDepositC.ts b/src/controllers/Resources/ResourceDepositC.ts new file mode 100644 index 0000000..89fdcb4 --- /dev/null +++ b/src/controllers/Resources/ResourceDepositC.ts @@ -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); + }, + ); + } +} diff --git a/src/controllers/Resources/ResourceFlyC.ts b/src/controllers/Resources/ResourceFlyC.ts index adb4d81..b577da2 100644 --- a/src/controllers/Resources/ResourceFlyC.ts +++ b/src/controllers/Resources/ResourceFlyC.ts @@ -1,121 +1,206 @@ import { UpdateController } from "@24tools/playable_template"; import { Easing, Group, Tween } from "@tweenjs/tween.js"; -import { - BoxGeometry, - Mesh, - MeshBasicMaterial, - Vector3, -} from "three"; -import { CameraC } from "../CameraC"; +import { Object3D, Vector3 } from "three"; import { ThreeC } from "../ThreeC"; import { ResourceInventoryC } from "./ResourceInventoryC"; +import { ResourceScreenFly } from "./ResourceScreenFly"; import { ResourceType } from "./ResourceType"; 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 PICKUP_SIZE = 0.14; +const CHAIN_FLY_DELAY_MS = 140; +const FLY_SCALE = 0.35; const STAGGER_MS = 70; +const GROUND_LIFT = 0.08; -const PLACEHOLDER_COLORS: Record = { - [ResourceType.Wood]: "#6b4423", +type FlyQueueItem = { + pickup: Object3D; + type: ResourceType; }; + export class ResourceFlyC { private static inited = false; private static tweenGroup = new Group(); + private static flyQueue: FlyQueueItem[] = []; + private static isProcessingFlyQueue = false; static init() { if (this.inited) return; this.inited = true; + this.hideTemplateMeshes(); + UpdateController.Instance.onUpdate.addDelegate(() => { this.tweenGroup.update(performance.now()); }); } - static launch(origin: Vector3, type: ResourceType, index: number) { - const spawnPos = origin.clone().add( - new Vector3( - (Math.random() - 0.5) * 0.35, - 0.25 + Math.random() * 0.2, - (Math.random() - 0.5) * 0.35, - ), - ); + private static hideTemplateMeshes() { + Object.values(ResourceType).forEach((type) => { + const template = ThreeC.getObject(type); + if (!template) return; + template.visible = false; + template.removeFromParent(); + }); + } + + static launch(origin: Vector3, type: ResourceType, index: number, count: number) { window.setTimeout(() => { - this.startPickup(spawnPos, type); + this.startPickup(origin, type, index, count); }, index * STAGGER_MS); } - private static startPickup(worldPos: Vector3, type: ResourceType) { - const mesh = this.createWorldMesh(type); - mesh.position.copy(worldPos); - ThreeC.addToScene(mesh); + private static startPickup(origin: Vector3, type: ResourceType, index: number, count: number) { + const pickup = ResourceScreenFly.createPickup(type); + if (!pickup) return; - 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) - .to({ x: popTarget.x, y: popTarget.y, z: popTarget.z }, POP_DURATION_MS) + pickup.position.copy(spawnPos); + 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) - .onComplete(() => { - ThreeC.removeFromScene(mesh); - mesh.geometry.dispose(); - (mesh.material as MeshBasicMaterial).dispose(); - this.flyToUI(popTarget, type); + .onUpdate(() => { + ResourceScreenFly.orientToCamera(pickup); }); - this.tweenGroup.add(popTween); - popTween.start(performance.now()); - } - - 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) + const fallTween = new Tween(pickup.position) + .to({ x: landPos.x, y: landPos.y, z: landPos.z }, FALL_MS) .easing(Easing.Quadratic.In) .onUpdate(() => { - element.style.left = `${state.x}px`; - element.style.top = `${state.y}px`; - element.style.transform = `translate(-50%, -50%) scale(${state.scale})`; + ResourceScreenFly.orientToCamera(pickup); }) .onComplete(() => { - element.remove(); - ResourceInventoryC.add(type, 1); - ResourceUIC.refresh(type); + this.playBounces(pickup, landPos, type); }); - this.tweenGroup.add(flyTween); - flyTween.start(performance.now()); + ejectTween.chain(fallTween); + this.tweenGroup.add(ejectTween); + this.tweenGroup.add(fallTween); + ejectTween.start(performance.now()); } - private static createWorldMesh(type: ResourceType) { - const color = PLACEHOLDER_COLORS[type]; - return new Mesh( - new BoxGeometry(PICKUP_SIZE, PICKUP_SIZE, PICKUP_SIZE), - new MeshBasicMaterial({ color }), + private static getScatterOffset(index: number, count: number) { + const baseAngle = (index / Math.max(count, 1)) * Math.PI * 2; + const angle = baseAngle + (Math.random() - 0.5) * 0.7; + const distance = SCATTER_MIN + Math.random() * (SCATTER_MAX - SCATTER_MIN); + + return new Vector3( + Math.cos(angle) * distance, + 0, + Math.sin(angle) * distance, ); } - private static worldToScreen(worldPos: Vector3) { - const camera = CameraC.camera; - const projected = worldPos.clone().project(camera); + private static playBounces(pickup: Object3D, landPos: Vector3, type: ResourceType) { + const runBounce = (bounceIndex: number) => { + if (bounceIndex >= BOUNCE_HEIGHTS.length) { + window.setTimeout(() => { + this.enqueueFlyToUI(pickup, type); + }, REST_AFTER_BOUNCE_MS); + return; + } - return { - x: (projected.x * 0.5 + 0.5) * window.innerWidth, - y: (-projected.y * 0.5 + 0.5) * window.innerHeight, + const height = BOUNCE_HEIGHTS[bounceIndex]; + const durationScale = height / BOUNCE_HEIGHTS[0]; + const peakY = landPos.y + height; + + const upTween = new Tween(pickup.position) + .to({ y: peakY }, BOUNCE_UP_MS * durationScale) + .easing(Easing.Quadratic.Out) + .onUpdate(() => { + ResourceScreenFly.orientToCamera(pickup); + }); + + const downTween = new Tween(pickup.position) + .to({ y: landPos.y }, BOUNCE_DOWN_MS * durationScale) + .easing(Easing.Quadratic.In) + .onUpdate(() => { + ResourceScreenFly.orientToCamera(pickup); + }) + .onComplete(() => { + runBounce(bounceIndex + 1); + }); + + upTween.chain(downTween); + this.tweenGroup.add(upTween); + this.tweenGroup.add(downTween); + upTween.start(performance.now()); }; + + runBounce(0); + } + + private static enqueueFlyToUI(pickup: Object3D, type: ResourceType) { + this.flyQueue.push({ pickup, type }); + 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(); + }, + ); } } diff --git a/src/controllers/Resources/ResourceInventoryC.ts b/src/controllers/Resources/ResourceInventoryC.ts index 8b60bec..7f827c2 100644 --- a/src/controllers/Resources/ResourceInventoryC.ts +++ b/src/controllers/Resources/ResourceInventoryC.ts @@ -1,7 +1,9 @@ +import { EasyEvent } from "@24tools/playable_template"; import { ResourceType } from "./ResourceType"; export class ResourceInventoryC { private static amounts = new Map(); + static readonly onChanged = new EasyEvent<{ type: ResourceType }>(); static get(type: ResourceType) { return this.amounts.get(type) ?? 0; @@ -10,5 +12,12 @@ export class ResourceInventoryC { static add(type: ResourceType, amount: number) { if (amount <= 0) return; this.amounts.set(type, this.get(type) + amount); + this.onChanged.Invoke({ type }); + } + + static remove(type: ResourceType, amount: number) { + if (amount <= 0) return; + this.amounts.set(type, Math.max(0, this.get(type) - amount)); + this.onChanged.Invoke({ type }); } } diff --git a/src/controllers/Resources/ResourceScreenFly.ts b/src/controllers/Resources/ResourceScreenFly.ts new file mode 100644 index 0000000..bea22fb --- /dev/null +++ b/src/controllers/Resources/ResourceScreenFly.ts @@ -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(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); + } + } +} diff --git a/src/controllers/Resources/ResourceSpawnC.ts b/src/controllers/Resources/ResourceSpawnC.ts index 457fabb..e6992d1 100644 --- a/src/controllers/Resources/ResourceSpawnC.ts +++ b/src/controllers/Resources/ResourceSpawnC.ts @@ -10,7 +10,7 @@ export class ResourceSpawnC { const origin = prop.object.getWorldPosition(new Vector3()); for (let i = 0; i < count; i++) { - ResourceFlyC.launch(origin, type, i); + ResourceFlyC.launch(origin, type, i, count); } } } diff --git a/src/controllers/Resources/ResourceUIC.ts b/src/controllers/Resources/ResourceUIC.ts index 3448417..831f650 100644 --- a/src/controllers/Resources/ResourceUIC.ts +++ b/src/controllers/Resources/ResourceUIC.ts @@ -1,16 +1,15 @@ +import { UI_IMAGES } from "../../resources/images/uiImages"; import { ResourceInventoryC } from "./ResourceInventoryC"; import { ResourceType } from "./ResourceType"; +import { RESOURCE_PLACEHOLDER_COLORS } from "./ResourceConfig"; type ResourceUIEntry = { type: ResourceType; + counter: HTMLElement; icon: HTMLElement; count: HTMLElement; }; -const PLACEHOLDER_COLORS: Record = { - [ResourceType.Wood]: "#6b4423", -}; - export class ResourceUIC { private static entries = new Map(); private static root: HTMLElement | null = null; @@ -19,34 +18,78 @@ export class ResourceUIC { const uiRoot = document.getElementById("ui"); if (!uiRoot) return; + this.createTopRightHud(uiRoot); + this.createBottomLeftWeapon(uiRoot); + } + + private static createTopRightHud(uiRoot: HTMLElement) { + const topRight = document.createElement("div"); + topRight.className = "hud-top-right"; + + const avatar = document.createElement("img"); + avatar.className = "hud-avatar"; + avatar.src = UI_IMAGES.avatarTop; + avatar.alt = ""; + topRight.appendChild(avatar); + this.root = document.createElement("div"); this.root.id = "resource-bar"; this.root.className = "resource-bar"; - uiRoot.appendChild(this.root); + topRight.appendChild(this.root); + uiRoot.appendChild(topRight); this.register(ResourceType.Wood); } + private static createBottomLeftWeapon(uiRoot: HTMLElement) { + const bottomLeft = document.createElement("div"); + bottomLeft.className = "hud-bottom-left"; + + const weapon = document.createElement("div"); + weapon.className = "hud-weapon"; + + const bg = document.createElement("img"); + bg.className = "hud-weapon__bg"; + bg.src = UI_IMAGES.toolBg; + bg.alt = ""; + + const tool = document.createElement("img"); + tool.className = "hud-weapon__tool"; + tool.src = UI_IMAGES.toolBat; + tool.alt = ""; + + const level = document.createElement("span"); + level.className = "hud-weapon__level"; + level.textContent = "LVL 0"; + + weapon.appendChild(bg); + weapon.appendChild(tool); + weapon.appendChild(level); + bottomLeft.appendChild(weapon); + uiRoot.appendChild(bottomLeft); + } + static register(type: ResourceType) { if (!this.root || this.entries.has(type)) return; const counter = document.createElement("div"); counter.className = "resource-counter"; counter.dataset.resource = type; - - const icon = document.createElement("div"); - icon.className = "resource-icon"; - icon.style.backgroundColor = PLACEHOLDER_COLORS[type]; + counter.style.backgroundImage = `url(${UI_IMAGES.woodCounterBg})`; const count = document.createElement("span"); count.className = "resource-count"; 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(icon); this.root.appendChild(counter); - this.entries.set(type, { type, icon, count }); + this.entries.set(type, { type, counter, icon, count }); this.refresh(type); } @@ -54,7 +97,7 @@ export class ResourceUIC { const entry = this.entries.get(type); if (!entry) return null; - const rect = entry.icon.getBoundingClientRect(); + const rect = entry.counter.getBoundingClientRect(); return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, diff --git a/src/controllers/TestSceneC.ts b/src/controllers/TestSceneC.ts index 5da0dcf..1ea824f 100644 --- a/src/controllers/TestSceneC.ts +++ b/src/controllers/TestSceneC.ts @@ -1,22 +1,31 @@ -import { Vector3 } from "three"; +import { ThreeC } from "./ThreeC"; import { Player } from "./Presets/Player"; import { Map } from "./Map/Map"; import { GatherC } from "./Map/GatherC"; +import { DepositZoneC } from "./Map/DepositZoneC"; import { ResourceUIC } from "./Resources/ResourceUIC"; import { ResourceFlyC } from "./Resources/ResourceFlyC"; +import { ResourceDepositC } from "./Resources/ResourceDepositC"; +import { PropHpUIC } from "./Map/PropHpUIC"; +import { PropVfxC } from "./Map/PropVfxC"; +import { InvasionProgressUIC } from "./UI/InvasionProgressUIC"; export class TestSceneC { static init() { + InvasionProgressUIC.init(); ResourceUIC.init(); ResourceFlyC.init(); - Map.Init(); - this.InitPlayer(); + PropHpUIC.init(); + PropVfxC.Init(); + Map.init(); GatherC.init(); - } + Player.init(); - private static InitPlayer() { - const playerSpawnPoint = new Vector3(4, 0, 27); - Player.SetSpawnPosition(playerSpawnPoint); - Player.Init(); + const mapObject = ThreeC.getObject("map"); + if (mapObject) { + DepositZoneC.init(mapObject); + } + + ResourceDepositC.init(); } } diff --git a/src/controllers/ThreeC.ts b/src/controllers/ThreeC.ts index 87b8428..e62213e 100644 --- a/src/controllers/ThreeC.ts +++ b/src/controllers/ThreeC.ts @@ -65,42 +65,4 @@ export class ThreeC extends ThreeC_internal { dirLight.shadow.camera.near = 0.5; 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); - // } } diff --git a/src/controllers/Timers/TimeC.ts b/src/controllers/Timers/TimeC.ts new file mode 100644 index 0000000..d17e57e --- /dev/null +++ b/src/controllers/Timers/TimeC.ts @@ -0,0 +1,3 @@ +export class TimeC { + static TimeScale = 1; +} diff --git a/src/controllers/UI/InvasionProgressUIC.ts b/src/controllers/UI/InvasionProgressUIC.ts new file mode 100644 index 0000000..f433623 --- /dev/null +++ b/src/controllers/UI/InvasionProgressUIC.ts @@ -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)); + } +} diff --git a/src/css/main.css b/src/css/main.css index 8a0f7e4..c0d73bb 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -95,18 +95,18 @@ canvas { padding: 20px; } -#editor { - pointer-events: none; -} -#ui, -#editor { + +#ui { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); - width: calc(100vh * 9 / 16); + width: 100%; height: 100vh; + pointer-events: none; + padding: 2vw; + box-sizing: border-box; } #modal { diff --git a/src/css/ui.css b/src/css/ui.css index 151a334..290e6b1 100644 --- a/src/css/ui.css +++ b/src/css/ui.css @@ -1,37 +1,176 @@ -.resource-bar { - position: fixed; +.hud-top-center { + position: absolute; top: 2vh; - left: 12vw; + left: 50%; + transform: translateX(-50%); + width: 100%; display: flex; flex-direction: column; - gap: 1vh; + align-items: center; + gap: 2vh; + overflow: visible; pointer-events: none; z-index: 10; } +.hud-invasion-title { + margin: 0; + font-size: 3.2vh; + font-weight: 700; + color: #ffffff; + text-transform: uppercase; + letter-spacing: 0.05em; + text-align: center; + line-height: 1; + -webkit-text-stroke: 0.12vh #000000; + paint-order: stroke fill; +} + +.hud-progress { + position: relative; + width: 100%; + height: 3.2vh; + max-width: 55vw; +} + +.hud-progress__track { + width: 100%; + height: 100%; + border: 0.35vh solid #000000; + border-radius: 0.5vh; + background: linear-gradient(180deg, #5fe86a 0%, #2db83a 100%); + overflow: hidden; + box-sizing: border-box; +} + +.hud-progress__fill { + height: 100%; + width: 0%; + background: #1a1a1a; + border-radius: 0.15vh; +} + +.hud-progress__icon { + position: absolute; + left: clamp( + 2.75vh, + calc(var(--progress, 0) * 100%), + calc(100% - 2.75vh) + ); + top: 50%; + transform: translate(-50%, -50%); + height: 5.5vh; + width: auto; + object-fit: contain; + z-index: 3; + pointer-events: none; +} + +.hud-top-right { + position: absolute; + top: 5.5vh; + right: 2vw; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3vh; + pointer-events: none; + z-index: 10; +} + +.hud-avatar { + width: 8vh; + height: auto; + object-fit: contain; + display: block; +} + +.resource-bar { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1vh; + pointer-events: none; +} + .resource-counter { + position: relative; display: flex; align-items: center; - gap: 1vw; - padding: 0.6vh 1.2vw; - background: rgba(0, 0, 0, 0.45); - border-radius: 0.8vh; + justify-content: flex-start; + width: 12vh; + height: 4vh; + padding: 0 9vh 0 3vh; + background-color: transparent; + background-size: 100% 100%; + background-repeat: no-repeat; + background-position: center; + box-sizing: border-box; } .resource-icon { - width: 4vh; - height: 4vh; - border-radius: 0.5vh; - border: 0.2vh solid rgba(255, 255, 255, 0.35); + position: absolute; + right: 1.2vh; + top: 50%; + transform: translateY(-50%); + width: 4.5vh; + height: 4.5vh; + border-radius: 0.4vh; flex-shrink: 0; } .resource-count { min-width: 2ch; color: #ffffff; - font-size: 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; line-height: 1; + white-space: nowrap; + -webkit-text-stroke: 0.12vh #000000; + paint-order: stroke fill; } .resource-fly-pickup { @@ -44,3 +183,115 @@ pointer-events: none; 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; + } +} diff --git a/src/fonts/customFont.ts b/src/fonts/customFont.ts index da1041a..40be5da 100644 --- a/src/fonts/customFont.ts +++ b/src/fonts/customFont.ts @@ -1,3 +1,6 @@ import { FontFamily, formFontFamily } from "@24tools/ads_common"; -export const customFont: undefined | Promise = undefined +export const customFont: Promise = formFontFamily( + "Passion One", + "./PassionOne-Black.otf" +); diff --git a/src/index.html b/src/index.html index 877f0ef..7b6c863 100644 --- a/src/index.html +++ b/src/index.html @@ -33,7 +33,6 @@
-