diff --git a/src/controllers/Map/GatherC.ts b/src/controllers/Map/GatherC.ts new file mode 100644 index 0000000..eee13b3 --- /dev/null +++ b/src/controllers/Map/GatherC.ts @@ -0,0 +1,96 @@ +import { 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 { + private static activeProps = new Set(); + private static pendingAutoAttack = false; + + static init() { + PhysicsTriggerC.onTriggerEnter.addDelegate((payload) => { + const prop = PropRegistry.get(payload.lootableObject); + if (!prop || prop.isBroken) return; + + this.activeProps.add(prop); + this.scheduleAutoAttackCheck(); + }); + + PhysicsTriggerC.onTriggerExit.addDelegate((payload) => { + const prop = PropRegistry.get(payload.lootableObject); + if (prop) this.activeProps.delete(prop); + + this.removeBrokenProps(); + + if (this.getAttackableProps().length === 0) { + Player.stopAutoAttack(); + } + }); + + JoystickC.onJoysticEnd.addDelegate(() => { + this.scheduleAutoAttackCheck(); + }); + + UpdateController.Instance.onUpdate.addDelegate(() => { + if (!this.pendingAutoAttack) return; + this.pendingAutoAttack = false; + this.tryStartAutoAttack(); + }); + } + + private static scheduleAutoAttackCheck() { + this.pendingAutoAttack = true; + } + + private static onAttackHit() { + for (const prop of this.getAttackableProps()) { + prop.takeDamage(1); + } + + this.removeBrokenProps(); + + if (this.getAttackableProps().length === 0) { + Player.stopAutoAttack(); + return; + } + + Player.playAttack(() => this.onAttackHit()); + } + + private static tryStartAutoAttack() { + const props = this.getAttackableProps(); + if (props.length === 0) return; + if (Player.isMoving()) return; + + const targetPosition = this.getTargetPosition(props); + Player.startAutoAttack(() => this.onAttackHit(), targetPosition); + } + + private static getTargetPosition(props: PropC[]): Vector3 { + const playerPosition = Player.getWorldPosition(); + const target = new Vector3(); + let closestDistanceSq = Infinity; + + for (const prop of props) { + const propPosition = prop.object.getWorldPosition(new Vector3()); + const distanceSq = playerPosition.distanceToSquared(propPosition); + if (distanceSq >= closestDistanceSq) continue; + + closestDistanceSq = distanceSq; + target.copy(propPosition); + } + + return target; + } + + private static getAttackableProps() { + return [...this.activeProps].filter((prop) => !prop.isBroken); + } + + private static removeBrokenProps() { + for (const prop of this.activeProps) { + if (prop.isBroken) this.activeProps.delete(prop); + } + } +} diff --git a/src/controllers/Map/Map.ts b/src/controllers/Map/Map.ts index 3f1726d..b55f11e 100644 --- a/src/controllers/Map/Map.ts +++ b/src/controllers/Map/Map.ts @@ -1,6 +1,12 @@ -import { Object3D } from "three"; +import { Box3, BoxGeometry, Mesh, Object3D, Vector3 } from "three"; import { ThreeC } from "../ThreeC"; import { PhysicsBody, PhysicsLayer } from "../PhysicsC"; +import { PhysicsTriggerC } from "./PhysicsTriggerC"; +import { PropC } from "./PropC"; +import { resolvePropType } from "../Resources/PropDropTable"; + +const MAP_PHYSICS_LAYERS = ["Colliders", "Lootable"]; +const GATHER_TRIGGER_PADDING = 0.2; export class Map { static Init() { @@ -14,19 +20,109 @@ export class Map { ThreeC.setShadowsStateForChildren(mapObject, true, true); ThreeC.addToScene(mapObject); - const colliders = mapObject.getObjectByName("Colliders"); - if (colliders) { - colliders.visible = false; - this.buildColliders(colliders); + PhysicsTriggerC.init(); + this.buildPhysics(mapObject); + } + + private static buildPhysics(mapObject: Object3D) { + for (const layerName of MAP_PHYSICS_LAYERS) { + const layer = mapObject.getObjectByName(layerName); + if (!layer) { + console.warn(`Map physics layer not found: ${layerName}`); + continue; + } + + if (layerName === "Colliders") { + layer.visible = false; + this.addWallPhysics(layer); + continue; + } + + layer.children.forEach((lootableObject) => { + this.setupLootableObject(lootableObject); + }); } } - private static buildColliders(collidersRoot: Object3D) { - collidersRoot.traverse((child) => { - if (child === collidersRoot) return; - if ((child as any).isMesh) { + private static addWallPhysics(root: Object3D) { + root.traverse((child) => { + if (child === root) return; + if ((child as Mesh).isMesh) { new PhysicsBody(child, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player); } }); } + + private static setupLootableObject(lootableObject: Object3D) { + const propType = resolvePropType(lootableObject.name); + const prop = new PropC(lootableObject, propType); + const colliderMesh = this.findMesh(lootableObject, (name) => name.includes("collider")); + const visualMeshes = this.getVisualMeshes(lootableObject); + + if (colliderMesh) { + colliderMesh.visible = false; + prop.addWallBody( + new PhysicsBody(colliderMesh, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player), + ); + } else { + visualMeshes.forEach((mesh) => { + prop.addWallBody( + new PhysicsBody(mesh, false, 0, PhysicsLayer.Wall, PhysicsLayer.Player), + ); + }); + } + + PhysicsTriggerC.register(this.createGatherTrigger(lootableObject), lootableObject); + } + + private static findMesh( + root: Object3D, + matcher: (name: string) => boolean, + ): Mesh | null { + let found: Mesh | null = null; + + root.traverse((child) => { + if (found) return; + if ((child as Mesh).isMesh && matcher(child.name.toLowerCase())) { + found = child as Mesh; + } + }); + + return found; + } + + private static getVisualMeshes(root: Object3D): Mesh[] { + const visualMeshes: Mesh[] = []; + + root.traverse((child) => { + if (!(child as Mesh).isMesh) return; + + const name = child.name.toLowerCase(); + if (name.includes("collider") || name.includes("trigger") || name.includes("gathertrigger")) return; + + visualMeshes.push(child as Mesh); + }); + + return visualMeshes; + } + + private static createGatherTrigger(lootableObject: Object3D): Mesh { + lootableObject.updateWorldMatrix(true, true); + + const size = new Vector3(); + new Box3().setFromObject(lootableObject).getSize(size); + + const triggerMesh = new Mesh( + new BoxGeometry( + size.x + GATHER_TRIGGER_PADDING * 2, + size.y + GATHER_TRIGGER_PADDING * 2, + size.z + GATHER_TRIGGER_PADDING * 2, + ), + ); + triggerMesh.visible = false; + triggerMesh.name = "GatherTrigger"; + lootableObject.add(triggerMesh); + + return triggerMesh; + } } diff --git a/src/controllers/Map/PhysicsTriggerC.ts b/src/controllers/Map/PhysicsTriggerC.ts new file mode 100644 index 0000000..cbe2914 --- /dev/null +++ b/src/controllers/Map/PhysicsTriggerC.ts @@ -0,0 +1,108 @@ +import { + EasyEvent, + Physics_internal, + UpdateController, +} from "@24tools/playable_template"; +import { Object3D } from "three"; +import { Body, Box } from "cannon-es"; +import { PhysicsBody, PhysicsLayer } from "../PhysicsC"; + +const PLAYER_RADIUS = 0.3; + +export type TriggerEventPayload = { + lootableObject: Object3D; + triggerObject: Object3D; + playerBody: Body; +}; + +type TriggerRecord = { + lootableObject: Object3D; + triggerObject: Object3D; + radius: number; + position: Body["position"]; + physicsBody: PhysicsBody; +}; + +export class PhysicsTriggerC { + private static inited = false; + private static triggers: TriggerRecord[] = []; + private static activeTriggers = new Set(); + + static onTriggerEnter = new EasyEvent(); + static onTriggerExit = new EasyEvent(); + + static init() { + if (this.inited) return; + this.inited = true; + + UpdateController.Instance.onUpdate.addDelegate(() => this.update()); + } + + static register(triggerObject: Object3D, lootableObject: Object3D) { + const physicsBody = new PhysicsBody( + triggerObject, + true, + 0, + PhysicsLayer.Trigger, + PhysicsLayer.Player, + ); + + const body = physicsBody.getPhysicsBody(); + body.allowSleep = false; + + const shape = body.shapes[0] as Box; + const halfExtents = shape.halfExtents; + + this.triggers.push({ + lootableObject, + triggerObject, + radius: Math.max(halfExtents.x, halfExtents.y, halfExtents.z), + position: body.position, + physicsBody, + }); + } + + static unregister(lootableObject: Object3D) { + this.triggers = this.triggers.filter((record) => { + if (record.lootableObject !== lootableObject) return true; + + record.physicsBody.destroy(); + this.activeTriggers.delete(record); + return false; + }); + } + + private static update() { + const playerBody = Physics_internal.physicsWorld?.bodies.find( + (body) => body.collisionFilterGroup === PhysicsLayer.Player, + ); + 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 distanceSq = dx * dx + dy * dy + dz * dz; + const isInside = distanceSq <= (trigger.radius + PLAYER_RADIUS) ** 2; + const wasInside = this.activeTriggers.has(trigger); + + if (isInside === wasInside) continue; + + const payload: TriggerEventPayload = { + lootableObject: trigger.lootableObject, + triggerObject: trigger.triggerObject, + playerBody, + }; + + if (isInside) { + this.activeTriggers.add(trigger); + this.onTriggerEnter.Invoke(payload); + 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 new file mode 100644 index 0000000..0a415e5 --- /dev/null +++ b/src/controllers/Map/PropC.ts @@ -0,0 +1,96 @@ +import { Object3D } 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"; + +export class PropRegistry { + private static props = new Map(); + + static register(prop: PropC) { + this.props.set(prop.object, prop); + } + + static get(object: Object3D) { + return this.props.get(object); + } +} + +export class PropC { + readonly object: Object3D; + readonly propType: PropType; + health: number; + readonly maxHealth: number; + isBroken = false; + + private wallBodies: PhysicsBody[] = []; + private dropPlan: PropDropPlan | null = null; + + constructor(object: Object3D, propType: PropType, maxHealth = 3) { + this.object = object; + this.propType = propType; + this.maxHealth = maxHealth; + this.health = maxHealth; + PropRegistry.register(this); + } + + addWallBody(physicsBody: PhysicsBody) { + this.wallBodies.push(physicsBody); + } + + takeDamage(amount: number): boolean { + if (this.isBroken) return false; + + const hitIndex = this.maxHealth - this.health; + this.ensureDropPlan(); + + this.health -= amount; + this.spawnForHit(hitIndex); + + if (this.health > 0) return false; + + this.break(); + return true; + } + + break() { + if (this.isBroken) return; + + this.isBroken = true; + this.object.visible = false; + + this.wallBodies.forEach((body) => body.destroy()); + this.wallBodies = []; + + PhysicsTriggerC.unregister(this.object); + } + + private ensureDropPlan() { + if (this.dropPlan) return; + + const rules = PROP_DROPS[this.propType]; + if (!rules.length) return; + + this.dropPlan = createDropPlan(this.maxHealth, rules[0]); + } + + private spawnForHit(hitIndex: number) { + if (!this.dropPlan) return; + + let spawnCount = this.dropPlan.hitAmounts[hitIndex] ?? 0; + + if (this.health <= 0) { + const remaining = this.dropPlan.totalDrop - this.dropPlan.spawnedDrop - spawnCount; + if (remaining > 0) { + spawnCount += remaining; + } + } + + if (spawnCount <= 0) return; + + ResourceSpawnC.spawnFromProp(this, this.dropPlan.resource, spawnCount); + this.dropPlan.spawnedDrop += spawnCount; + } +} diff --git a/src/controllers/Map/PropType.ts b/src/controllers/Map/PropType.ts new file mode 100644 index 0000000..88bd1ad --- /dev/null +++ b/src/controllers/Map/PropType.ts @@ -0,0 +1,3 @@ +export enum PropType { + Box = "box", +} diff --git a/src/controllers/Presets/Character/Character.ts b/src/controllers/Presets/Character/Character.ts index 4f2b343..d8ef841 100644 --- a/src/controllers/Presets/Character/Character.ts +++ b/src/controllers/Presets/Character/Character.ts @@ -51,6 +51,26 @@ export class Character { this.curClipAction.weight = weight; } + setObjectVisible(name: string, visible: boolean) { + this.tObj.traverse((child) => { + if (child.name === name) { + child.visible = visible; + } + }); + } + + setDefaultWeapons() { + this.setObjectVisible("Tool_1", false); + this.setObjectVisible("Tool_2", true); + this.setObjectVisible("Character_Pistol", false); + } + + setBatEquipped(equipped: boolean) { + this.setObjectVisible("Tool_1", equipped); + this.setObjectVisible("Tool_2", !equipped); + 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]); diff --git a/src/controllers/Presets/Enums/BaseAnimation.ts b/src/controllers/Presets/Enums/BaseAnimation.ts index c4a7f90..7347fcc 100644 --- a/src/controllers/Presets/Enums/BaseAnimation.ts +++ b/src/controllers/Presets/Enums/BaseAnimation.ts @@ -1,5 +1,10 @@ -export enum BaseAnimation { - Nan = -1, - Idle = 0, - Run = 1, -} \ No newline at end of file +export enum BaseAnimation { + Nan = -1, + Idle = 0, + Run = 1, + WeaponTakeOut = 2, + WeaponPutDown = 3, + PistolShoot = 4, + Death = 5, + Loot = 6, +} diff --git a/src/controllers/Presets/Input/PlayerInput.ts b/src/controllers/Presets/Input/PlayerInput.ts index 9b5b690..54d151b 100644 --- a/src/controllers/Presets/Input/PlayerInput.ts +++ b/src/controllers/Presets/Input/PlayerInput.ts @@ -46,6 +46,7 @@ export class PlayerInput implements IMoveInput { private static threshold = 0.25; get CurrentDirection() { return this.currentDirection.clone(); }; + get IsActive() { return this.inputaActive; }; inputaActive: boolean = false; diff --git a/src/controllers/Presets/Movment/RotationC.ts b/src/controllers/Presets/Movment/RotationC.ts index f778f33..c3cadda 100644 --- a/src/controllers/Presets/Movment/RotationC.ts +++ b/src/controllers/Presets/Movment/RotationC.ts @@ -22,12 +22,14 @@ export class RotationC { private update(delta: number) { const loockAtStep = this.input.CurrentDirection; - if (loockAtStep.length() != 0) { - const loockAtPoint = this.target.position.clone().add(loockAtStep); - this.target.lookAt(loockAtPoint); - this.targetQ.copy(this.target.quaternion); - this.target.quaternion.copy(this.currentQ); - } + if (loockAtStep.length() == 0) return; + + this.currentQ.copy(this.target.quaternion); + + const loockAtPoint = this.target.position.clone().add(loockAtStep); + this.target.lookAt(loockAtPoint); + this.targetQ.copy(this.target.quaternion); + this.target.quaternion.copy(this.currentQ); this.target.quaternion.slerp(this.targetQ, delta * this.speed); this.currentQ.copy(this.target.quaternion); diff --git a/src/controllers/Presets/Player.ts b/src/controllers/Presets/Player.ts index 26164da..cbef845 100644 --- a/src/controllers/Presets/Player.ts +++ b/src/controllers/Presets/Player.ts @@ -5,22 +5,27 @@ 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 } from "three"; +import { Object3D, Quaternion, Vector3 } from "three"; import { ThreeC } from "../ThreeC"; import { PlayerInput } from "./Input/PlayerInput"; import { MoveC } from "./Movment/MoveC"; -import { Vec3 } from "cannon-es"; -import { contain } from "three/src/extras/TextureUtils"; import { Vector3CToT, Vector3TToC } from "./Helper"; import { RotationC } from "./Movment/RotationC"; import { FollowCameraC } from "./Movment/CameraMovment/FollowCamera"; -import { Vector3 } from "three"; 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 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 input: PlayerInput; @@ -28,6 +33,9 @@ export class Player { private static rotation: RotationC; private static spawnPosition: Vector3 = new Vector3(0, 0, 0); + private static readonly turnSpeed = 8; + private static readonly turnCompleteAngle = 0.05; + static character: Character; static physics: PhysicsBody; @@ -37,21 +45,24 @@ export class Player { static Init() { if (this.inited) return; - this.inited = true - const asset = ResourcesC.getResource(ResourcesType.Mesh, MeshType.Character) + this.inited = true; + const asset = ResourcesC.getResource(ResourcesType.Mesh, MeshType.Character); this.character = new Character(asset); this.container.position.copy(this.spawnPosition); - this.character.playAnimation(BaseAnimation.Idle) + this.character.setDefaultWeapons(); + this.character.playAnimation(BaseAnimation.Idle); this.container.add(this.character.tObj); ThreeC.addToScene(this.container); this.InitPhisic(); - const input = new PlayerInput(); + this.input = new PlayerInput(); const moveSpeed = 3; const rotationSpeed = 8; - this.movement = new MoveC(input, moveSpeed); - this.rotation = new RotationC(this.container, input, rotationSpeed); + this.movement = new MoveC(this.input, moveSpeed); + this.rotation = new RotationC(this.container, this.input, rotationSpeed); + + this.character.onAnimFinish.addDelegate(() => this.OnCombatAnimationFinished()); this.updateDelegate = new Delegate((delta) => this.Update(delta)); UpdateController.Instance.onUpdate.addListener(this.updateDelegate); @@ -59,23 +70,136 @@ export class Player { FollowCameraC.Init(this.container); } + static getWorldPosition() { + return this.container.getWorldPosition(new Vector3()); + } + + static isMoving() { + return this.input.IsActive; + } + + static startAutoAttack(onHit: () => void, targetWorldPosition: Vector3) { + if (this.isAutoAttacking) return; + + this.isAutoAttacking = true; + this.beginTurnToTarget(targetWorldPosition, () => this.playAttack(onHit)); + } + + static stopAutoAttack() { + this.isAutoAttacking = false; + this.onAttackComplete = null; + this.cancelTurnToTarget(); + this.resetCombatState(); + + if (!this.isAttacking) { + this.StopRunning(); + } + } + + 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(); + } + private static InitPhisic() { this.physics = new PhysicsBody( this.container, false, 1, PhysicsLayer.Player, - PhysicsLayer.Wall, + PhysicsLayer.Wall | PhysicsLayer.Trigger, ); } + 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) { + 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) return; + if (this.isRunning || this.isAttacking || this.isTurningToTarget) return; this.character.playAnimation(BaseAnimation.Run); this.isRunning = true; } + private static StopRunning() { - if (!this.isRunning) return; + if (!this.isRunning || this.isAttacking || this.isTurningToTarget) return; this.character.playAnimation(BaseAnimation.Idle); this.AnimationValue = 1; this.isRunning = false; @@ -87,6 +211,23 @@ export class Player { } private static Update(delta: number) { + if (this.input.IsActive && (this.isAutoAttacking || this.isTurningToTarget)) { + this.stopAutoAttack(); + } + + if (this.isTurningToTarget) { + this.physics.getPhysicsBody().velocity.set(0, 0, 0); + this.updateTurnToTarget(delta); + this.MoveVisual(delta); + return; + } + + if (this.isAttacking) { + this.physics.getPhysicsBody().velocity.set(0, 0, 0); + this.MoveVisual(delta); + return; + } + const diraction = this.movement.Diraction; const weight = this.movement.Weight; @@ -98,20 +239,17 @@ export class Player { this.StopRunning(); } - - const cPos = Vector3TToC(diraction); this.physics.getPhysicsBody().velocity.copy(cPos); + this.physics.getPhysicsBody().wakeUp(); this.MoveVisual(delta); } private static MoveVisual(delta: number) { const lerpSpeed = 10; - const targetPos = (Vector3CToT(this.physics.getPhysicsBody().position)); + const targetPos = Vector3CToT(this.physics.getPhysicsBody().position); - this.container.position.lerp(targetPos, delta * lerpSpeed) + this.container.position.lerp(targetPos, delta * lerpSpeed); } - - -} \ No newline at end of file +} diff --git a/src/controllers/Resources/PropDropPlanner.ts b/src/controllers/Resources/PropDropPlanner.ts new file mode 100644 index 0000000..da75e63 --- /dev/null +++ b/src/controllers/Resources/PropDropPlanner.ts @@ -0,0 +1,65 @@ +function randomInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function shuffle(items: T[]) { + const result = [...items]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +function splitRandom(total: number, parts: number) { + if (parts <= 0) return []; + if (parts === 1) return [total]; + + const result = new Array(parts).fill(1); + let remaining = total - parts; + + while (remaining > 0) { + result[Math.floor(Math.random() * parts)]++; + remaining--; + } + + return result; +} + +import { ResourceType } from "./ResourceType"; + +export type PropDropPlan = { + resource: ResourceType; + totalDrop: number; + spawnedDrop: number; + hitAmounts: number[]; +}; + +export function createDropPlan( + maxHealth: number, + rule: { + resource: ResourceType; + minTotal: number; + maxTotal: number; + minSpawnHits: number; + maxSpawnHits: number; + }, +): PropDropPlan { + const totalDrop = randomInt(rule.minTotal, rule.maxTotal); + const spawnEvents = randomInt(rule.minSpawnHits, rule.maxSpawnHits); + const hitAmounts = new Array(maxHealth).fill(0); + + const hitIndexes = shuffle([...Array(maxHealth).keys()]).slice(0, spawnEvents); + const amounts = splitRandom(totalDrop, spawnEvents); + + hitIndexes.forEach((hitIndex, index) => { + hitAmounts[hitIndex] = amounts[index]; + }); + + return { + resource: rule.resource, + totalDrop, + spawnedDrop: 0, + hitAmounts, + }; +} diff --git a/src/controllers/Resources/PropDropTable.ts b/src/controllers/Resources/PropDropTable.ts new file mode 100644 index 0000000..ad97e3d --- /dev/null +++ b/src/controllers/Resources/PropDropTable.ts @@ -0,0 +1,31 @@ +import { PropType } from "../Map/PropType"; +import { ResourceType } from "./ResourceType"; + +export type DropRule = { + resource: ResourceType; + minTotal: number; + maxTotal: number; + minSpawnHits: number; + maxSpawnHits: number; +}; + +export const PROP_DROPS: Record = { + [PropType.Box]: [ + { + resource: ResourceType.Wood, + minTotal: 1, + maxTotal: 3, + minSpawnHits: 1, + maxSpawnHits: 2, + }, + ], +}; + +export function resolvePropType(objectName: string): PropType { + const name = objectName.toLowerCase(); + if (name.includes("box") || name.includes("crate") || name.includes("wood")) { + return PropType.Box; + } + + return PropType.Box; +} diff --git a/src/controllers/Resources/ResourceFlyC.ts b/src/controllers/Resources/ResourceFlyC.ts new file mode 100644 index 0000000..adb4d81 --- /dev/null +++ b/src/controllers/Resources/ResourceFlyC.ts @@ -0,0 +1,121 @@ +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 { ThreeC } from "../ThreeC"; +import { ResourceInventoryC } from "./ResourceInventoryC"; +import { ResourceType } from "./ResourceType"; +import { ResourceUIC } from "./ResourceUIC"; + +const POP_DURATION_MS = 220; +const FLY_DURATION_MS = 520; +const PICKUP_SIZE = 0.14; +const STAGGER_MS = 70; + +const PLACEHOLDER_COLORS: Record = { + [ResourceType.Wood]: "#6b4423", +}; + +export class ResourceFlyC { + private static inited = false; + private static tweenGroup = new Group(); + + static init() { + if (this.inited) return; + this.inited = true; + + 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, + ), + ); + + window.setTimeout(() => { + this.startPickup(spawnPos, type); + }, index * STAGGER_MS); + } + + private static startPickup(worldPos: Vector3, type: ResourceType) { + const mesh = this.createWorldMesh(type); + mesh.position.copy(worldPos); + ThreeC.addToScene(mesh); + + const popTarget = worldPos.clone().add(new Vector3(0, 0.35, 0)); + + const popTween = new Tween(mesh.position) + .to({ x: popTarget.x, y: popTarget.y, z: popTarget.z }, POP_DURATION_MS) + .easing(Easing.Quadratic.Out) + .onComplete(() => { + ThreeC.removeFromScene(mesh); + mesh.geometry.dispose(); + (mesh.material as MeshBasicMaterial).dispose(); + this.flyToUI(popTarget, type); + }); + + 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) + .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})`; + }) + .onComplete(() => { + element.remove(); + ResourceInventoryC.add(type, 1); + ResourceUIC.refresh(type); + }); + + this.tweenGroup.add(flyTween); + flyTween.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 worldToScreen(worldPos: Vector3) { + const camera = CameraC.camera; + const projected = worldPos.clone().project(camera); + + return { + x: (projected.x * 0.5 + 0.5) * window.innerWidth, + y: (-projected.y * 0.5 + 0.5) * window.innerHeight, + }; + } +} diff --git a/src/controllers/Resources/ResourceInventoryC.ts b/src/controllers/Resources/ResourceInventoryC.ts new file mode 100644 index 0000000..8b60bec --- /dev/null +++ b/src/controllers/Resources/ResourceInventoryC.ts @@ -0,0 +1,14 @@ +import { ResourceType } from "./ResourceType"; + +export class ResourceInventoryC { + private static amounts = new Map(); + + static get(type: ResourceType) { + return this.amounts.get(type) ?? 0; + } + + static add(type: ResourceType, amount: number) { + if (amount <= 0) return; + this.amounts.set(type, this.get(type) + amount); + } +} diff --git a/src/controllers/Resources/ResourceSpawnC.ts b/src/controllers/Resources/ResourceSpawnC.ts new file mode 100644 index 0000000..457fabb --- /dev/null +++ b/src/controllers/Resources/ResourceSpawnC.ts @@ -0,0 +1,16 @@ +import { Vector3 } from "three"; +import { PropC } from "../Map/PropC"; +import { ResourceFlyC } from "./ResourceFlyC"; +import { ResourceType } from "./ResourceType"; + +export class ResourceSpawnC { + static spawnFromProp(prop: PropC, type: ResourceType, count: number) { + if (count <= 0) return; + + const origin = prop.object.getWorldPosition(new Vector3()); + + for (let i = 0; i < count; i++) { + ResourceFlyC.launch(origin, type, i); + } + } +} diff --git a/src/controllers/Resources/ResourceType.ts b/src/controllers/Resources/ResourceType.ts new file mode 100644 index 0000000..65bc579 --- /dev/null +++ b/src/controllers/Resources/ResourceType.ts @@ -0,0 +1,3 @@ +export enum ResourceType { + Wood = "wood", +} diff --git a/src/controllers/Resources/ResourceUIC.ts b/src/controllers/Resources/ResourceUIC.ts new file mode 100644 index 0000000..3448417 --- /dev/null +++ b/src/controllers/Resources/ResourceUIC.ts @@ -0,0 +1,70 @@ +import { ResourceInventoryC } from "./ResourceInventoryC"; +import { ResourceType } from "./ResourceType"; + +type ResourceUIEntry = { + type: ResourceType; + 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; + + static init() { + const uiRoot = document.getElementById("ui"); + if (!uiRoot) return; + + this.root = document.createElement("div"); + this.root.id = "resource-bar"; + this.root.className = "resource-bar"; + uiRoot.appendChild(this.root); + + this.register(ResourceType.Wood); + } + + 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]; + + const count = document.createElement("span"); + count.className = "resource-count"; + count.textContent = "0"; + + counter.appendChild(icon); + counter.appendChild(count); + this.root.appendChild(counter); + + this.entries.set(type, { type, icon, count }); + this.refresh(type); + } + + static getIconCenter(type: ResourceType) { + const entry = this.entries.get(type); + if (!entry) return null; + + const rect = entry.icon.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + static refresh(type: ResourceType) { + const entry = this.entries.get(type); + if (!entry) return; + + entry.count.textContent = String(ResourceInventoryC.get(type)); + } +} diff --git a/src/controllers/TestSceneC.ts b/src/controllers/TestSceneC.ts index 8501e71..5da0dcf 100644 --- a/src/controllers/TestSceneC.ts +++ b/src/controllers/TestSceneC.ts @@ -1,52 +1,22 @@ -import { BoxGeometry, Mesh, MeshStandardMaterial, Vector3 } from "three"; -import { ThreeC } from "./ThreeC"; -import { InputC, JoystickC } from "@24tools/playable_template"; +import { Vector3 } from "three"; import { Player } from "./Presets/Player"; -import { PhysicsBody, PhysicsLayer } from "./PhysicsC"; import { Map } from "./Map/Map"; +import { GatherC } from "./Map/GatherC"; +import { ResourceUIC } from "./Resources/ResourceUIC"; +import { ResourceFlyC } from "./Resources/ResourceFlyC"; export class TestSceneC { static init() { - // this.createPrimitive(); + ResourceUIC.init(); + ResourceFlyC.init(); Map.Init(); this.InitPlayer(); - - // example of using InputC events - // InputC.onTouchDown.addDelegate((event) => { - // console.log("onMouseDown", event); - // }); - - // if you have update in your controller - // UpdateController.Instance.onUpdate.addDelegate(() => { - // this.update(); - // }); + GatherC.init(); } - // private static createPrimitive() { - // const planeSize = new Vector3(10, 0.1, 10); - // const geometryPlane = new BoxGeometry(planeSize.x, planeSize.y, planeSize.z); - // const materialPlane = new MeshStandardMaterial({ color: 0xaaaaaa }); - // const plane = new Mesh(geometryPlane, materialPlane); - - // ThreeC.setShadowsStateForChildren(plane, false, true); - - // plane.position.y = -planeSize.y / 2; - - // new PhysicsBody( - // plane, - // false, - // 0, - // PhysicsLayer.Wall, - // PhysicsLayer.Player, - // ); - - // ThreeC.addToScene(plane); - // } - private static InitPlayer() { const playerSpawnPoint = new Vector3(4, 0, 27); Player.SetSpawnPosition(playerSpawnPoint); Player.Init(); } - } diff --git a/src/css/ui.css b/src/css/ui.css index a30a41c..151a334 100644 --- a/src/css/ui.css +++ b/src/css/ui.css @@ -1,4 +1,46 @@ -#ui { - /* flex-basis: 60%; */ - flex-grow: 1; +.resource-bar { + position: fixed; + top: 2vh; + left: 12vw; + display: flex; + flex-direction: column; + gap: 1vh; + pointer-events: none; + z-index: 10; +} + +.resource-counter { + display: flex; + align-items: center; + gap: 1vw; + padding: 0.6vh 1.2vw; + background: rgba(0, 0, 0, 0.45); + border-radius: 0.8vh; +} + +.resource-icon { + width: 4vh; + height: 4vh; + border-radius: 0.5vh; + border: 0.2vh solid rgba(255, 255, 255, 0.35); + flex-shrink: 0; +} + +.resource-count { + min-width: 2ch; + color: #ffffff; + font-size: 3vh; + font-weight: 700; + line-height: 1; +} + +.resource-fly-pickup { + position: fixed; + width: 3vh; + height: 3vh; + border-radius: 0.4vh; + border: 0.15vh solid rgba(255, 255, 255, 0.5); + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 20; } diff --git a/src/index.ts b/src/index.ts index 3b658bb..4eb2600 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ window.setupConfig = async function (config) { redirectOptions: {}, ticker: Template3d.ticker, debug: { - physics: true, + physics: false, // set true if you want to enable physics debugger logger: false // set true if you want to enable logger }