feat: Add VFX JSON files and loader implementation
- Created a new JSON file for VFX configuration (`test.json`) containing geometries, materials, textures, and particle emitters. - Implemented a TypeScript module (`vfx_json.ts`) to define VFX resources and a loader function using QuarksLoader for parsing the JSON data. - Included the new test VFX in the resource list for loading.
This commit is contained in:
+30
-52
@@ -4,38 +4,25 @@ import { PlayerC } from "./PlayerC";
|
||||
import { Crate, LootableC } from "./LootableC";
|
||||
import { Trigger } from "./TriggerC";
|
||||
|
||||
// Health removed from a crate per strike (the bat touches it during a swing).
|
||||
const ATTACK_DAMAGE = 10;
|
||||
// Bat-tip → crate horizontal distance under which the bat is "touching" it.
|
||||
const HIT_DIST = 0.8;
|
||||
// The swing only reaches crates within this asymmetric arc of the facing dir
|
||||
// (negative = player's left, positive = right): left cut at 90°, right 130°.
|
||||
const ARC_LEFT = (90 * Math.PI) / 180;
|
||||
const ARC_RIGHT = (130 * Math.PI) / 180;
|
||||
const ATTACK_DAMAGE = 10; // damage per bat-tip touch
|
||||
const CONTACT_DIST = 0.8; // bat tip → crate distance that counts as a touch
|
||||
|
||||
const _tmp = new Vector3();
|
||||
const _center = new Vector3();
|
||||
const _fwd = new Vector3();
|
||||
const _pos = new Vector3();
|
||||
const _tip = new Vector3();
|
||||
|
||||
/**
|
||||
* "Stand near crates → auto-attack" loop.
|
||||
*
|
||||
* Each crate has a Trigger zone (= which crates are in reach). While the player
|
||||
* is stopped, the Loot swing plays and the player faces the crates' centre.
|
||||
* The Loot clip has TWO strikes (left swing, then right). Per STRIKE, every
|
||||
* crate the bat tip actually reaches takes one hit (front arc only) — so one
|
||||
* swing damages all the crates it sweeps over, and a crate caught by both
|
||||
* swings takes two sequential hits, rather than being hit at random times.
|
||||
* Auto-attack: while the player stands near crates, swing the bat and damage a
|
||||
* crate when the bat tip touches it. Hits are gated by PlayerC's swing window
|
||||
* (no damage during the wind-up) and limited to once per crate per swing.
|
||||
*/
|
||||
export class CombatC {
|
||||
private static inRange = new Set<Crate>();
|
||||
private static hitThisStrike = new Set<Crate>(); // crates already hit in the current strike
|
||||
private static lastStrike = -1;
|
||||
private static inRange = new Set<Crate>(); // crates currently in reach
|
||||
private static hitThisSwing = new Set<Crate>(); // crates already hit this swing
|
||||
private static lastSwingId = 0;
|
||||
|
||||
static init() {
|
||||
// A proximity trigger around every crate. Its size = the attack reach.
|
||||
// Proximity trigger around every crate → decides which crates are in reach.
|
||||
for (const crate of LootableC.crates) {
|
||||
crate.root.getWorldPosition(_tmp);
|
||||
crate.trigger = new Trigger(
|
||||
@@ -54,16 +41,16 @@ export class CombatC {
|
||||
private static update() {
|
||||
this.pruneBroken();
|
||||
|
||||
// Moving → stop at once.
|
||||
// Moving → stop attacking immediately.
|
||||
if (PlayerC.isMoving()) {
|
||||
PlayerC.setAttacking(false);
|
||||
this.resetCycle();
|
||||
this.endSwing();
|
||||
return;
|
||||
}
|
||||
// Nothing left in reach → let the current swing finish, then idle.
|
||||
// No crates left → let the current swing finish, then idle.
|
||||
if (this.inRange.size === 0) {
|
||||
PlayerC.finishAttack();
|
||||
this.resetCycle();
|
||||
this.endSwing();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,51 +63,42 @@ export class CombatC {
|
||||
_center.divideScalar(this.inRange.size);
|
||||
PlayerC.setAttacking(true, _center);
|
||||
|
||||
this.applyBatContact();
|
||||
this.applyContact();
|
||||
}
|
||||
|
||||
// Per strike: damage every in-reach crate the bat tip reaches (front arc).
|
||||
private static applyBatContact() {
|
||||
// New strike → all crates can be hit once again.
|
||||
const strike = PlayerC.getSwingCycle();
|
||||
if (strike !== this.lastStrike) {
|
||||
this.hitThisStrike.clear();
|
||||
this.lastStrike = strike;
|
||||
// Damage each touched crate once per swing. Nothing happens during the wind-up.
|
||||
private static applyContact() {
|
||||
const swingId = PlayerC.getActiveSwingId();
|
||||
if (swingId === 0) return; // bat not live (wind-up / between swings)
|
||||
|
||||
// New swing → every crate can be hit again.
|
||||
if (swingId !== this.lastSwingId) {
|
||||
this.hitThisSwing.clear();
|
||||
this.lastSwingId = swingId;
|
||||
}
|
||||
|
||||
const tip = PlayerC.getBatTip(_tip);
|
||||
if (!tip) return;
|
||||
PlayerC.getForward(_fwd);
|
||||
PlayerC.getPosition(_pos);
|
||||
const rx = _fwd.z, rz = -_fwd.x; // player's right
|
||||
|
||||
for (const c of [...this.inRange]) {
|
||||
if (c.broken || this.hitThisStrike.has(c)) continue;
|
||||
if (c.broken || this.hitThisSwing.has(c)) continue;
|
||||
c.root.getWorldPosition(_tmp);
|
||||
|
||||
// Front-arc gate (the bat can't reach behind the player).
|
||||
const dx = _tmp.x - _pos.x, dz = _tmp.z - _pos.z;
|
||||
const len = Math.hypot(dx, dz) || 1;
|
||||
const angle = Math.atan2((rx * dx + rz * dz) / len, (_fwd.x * dx + _fwd.z * dz) / len);
|
||||
if (angle < -ARC_LEFT || angle > ARC_RIGHT) continue;
|
||||
|
||||
// Bat tip actually reached this crate → hit it once this strike.
|
||||
if (Math.hypot(tip.x - _tmp.x, tip.z - _tmp.z) <= HIT_DIST) {
|
||||
this.hitThisStrike.add(c);
|
||||
if (Math.hypot(tip.x - _tmp.x, tip.z - _tmp.z) <= CONTACT_DIST) {
|
||||
this.hitThisSwing.add(c);
|
||||
LootableC.damageCrate(c, ATTACK_DAMAGE);
|
||||
}
|
||||
}
|
||||
this.pruneBroken();
|
||||
}
|
||||
|
||||
private static resetCycle() {
|
||||
this.hitThisStrike.clear();
|
||||
this.lastStrike = -1;
|
||||
private static endSwing() {
|
||||
this.hitThisSwing.clear();
|
||||
this.lastSwingId = 0;
|
||||
}
|
||||
|
||||
private static pruneBroken() {
|
||||
for (const c of this.inRange) {
|
||||
if (c.broken) { this.inRange.delete(c); this.hitThisStrike.delete(c); }
|
||||
if (c.broken) { this.inRange.delete(c); this.hitThisSwing.delete(c); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
-52
@@ -24,7 +24,7 @@ const LAND_SQUASH = 0.6; // squash on the final landing
|
||||
const LAND_POP_MS = 160; // duration of the final "pop"
|
||||
|
||||
// Collect (#8): delay after landing before flying to the corner, UI icon size, etc.
|
||||
const COLLECT_DELAY_MS = 100; // almost immediately after the bounces
|
||||
const COLLECT_DELAY_MS = 40; // almost immediately after the bounces (flows into the collect)
|
||||
const UI_ICON_SIZE = 28; // wood UI icon size (px) — smaller than loot on the ground, but not tiny
|
||||
const UI_RIGHT = 16; // offset from the right edge (px)
|
||||
const UI_TOP = 110; // offset from the top (px) — lower, like in the REF
|
||||
@@ -37,10 +37,11 @@ const _topV = new Vector3();
|
||||
|
||||
export class LootC {
|
||||
static pieces: Sprite[] = [];
|
||||
static balance = 0; // collected wood the player can spend
|
||||
private static texture: Texture | null = null;
|
||||
private static uiIcon: HTMLImageElement | null = null;
|
||||
// Our own tween group. In tween.js v25 `new Tween(obj)` does NOT join the
|
||||
// default group automatically, so we keep and update our own (else tweens freeze).
|
||||
private static countEl: HTMLElement | null = null; // the "×N" label next to the icon
|
||||
// Our tween group, pumped each frame (tween.js v25 needs an explicit group).
|
||||
private static tweens = new TWEEN.Group();
|
||||
|
||||
static init() {
|
||||
@@ -58,10 +59,45 @@ export class LootC {
|
||||
document.body.appendChild(icon);
|
||||
this.uiIcon = icon;
|
||||
|
||||
// The numeric balance, just left of the icon.
|
||||
const count = document.createElement("div");
|
||||
count.id = "wood-count";
|
||||
count.style.cssText =
|
||||
`position:fixed; top:${UI_TOP}px; right:${UI_RIGHT + UI_ICON_SIZE + 6}px;` +
|
||||
`height:${UI_ICON_SIZE}px; line-height:${UI_ICON_SIZE}px;` +
|
||||
`font-family:sans-serif; font-weight:700; font-size:18px; color:#fff;` +
|
||||
`text-shadow:0 1px 2px rgba(0,0,0,0.6); z-index:1001; pointer-events:none;` +
|
||||
`transition:transform 0.12s ease-out;`;
|
||||
document.body.appendChild(count);
|
||||
this.countEl = count;
|
||||
this.renderCount();
|
||||
|
||||
// ⚠️ Key: pump our group every frame, otherwise the tweens don't advance.
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => this.tweens.update());
|
||||
}
|
||||
|
||||
/** Current spendable wood. */
|
||||
static getBalance(): number {
|
||||
return this.balance;
|
||||
}
|
||||
|
||||
/** Spend up to `n` wood; returns how much was actually taken (clamped to balance). */
|
||||
static spend(n: number): number {
|
||||
const taken = Math.min(n, this.balance);
|
||||
this.balance -= taken;
|
||||
this.renderCount();
|
||||
return taken;
|
||||
}
|
||||
|
||||
/** Screen-pixel center of the UI wood icon (used as a fly source/target). */
|
||||
static uiIconScreenCenter(): { x: number; y: number } {
|
||||
return this.uiIconCenter();
|
||||
}
|
||||
|
||||
private static renderCount() {
|
||||
if (this.countEl) this.countEl.textContent = `×${this.balance}`;
|
||||
}
|
||||
|
||||
/** Spawn loot at a point. If count is omitted → random PIECES_MIN..PIECES_MAX. */
|
||||
static spawn(origin: Vector3, count?: number) {
|
||||
const n = count ?? (PIECES_MIN + Math.floor(Math.random() * (PIECES_MAX - PIECES_MIN + 1)));
|
||||
@@ -94,14 +130,11 @@ export class LootC {
|
||||
return piece;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scatter: a tall first arc (stretched along its motion), then a few decaying
|
||||
* bounces off the ground, and a final "pop" (squash → springs back to normal).
|
||||
*/
|
||||
/** A piece flies in an arc, bounces a couple times, then pops on landing. */
|
||||
private static animatePiece(piece: Sprite, from: Vector3, to: Vector3) {
|
||||
const restY = to.y; // sprite center at rest (= groundY + PIECE_SIZE/2)
|
||||
const restY = to.y; // sprite center at rest
|
||||
|
||||
// One "hop": parabola fx,fz→tx,tz peaking at peak; stretched by speed.
|
||||
// One hop: an arc from (fx,fz) to (tx,tz), stretched while moving fast.
|
||||
const hop = (fx: number, fz: number, tx: number, tz: number, peak: number, ms: number) =>
|
||||
new TWEEN.Tween({ t: 0 }, this.tweens)
|
||||
.to({ t: 1 }, ms)
|
||||
@@ -121,8 +154,8 @@ export class LootC {
|
||||
const totalDist = Math.hypot(dx, dz) || 1e-4;
|
||||
const dirX = dx / totalDist, dirZ = dz / totalDist;
|
||||
|
||||
// Split the horizontal distance between the flight and the bounces (geometric
|
||||
// decay) so the plank also moves forward on bounces, not just up; sum = totalDist.
|
||||
// Share the horizontal distance across the flight + bounces (so it also
|
||||
// moves forward on each bounce, not just up).
|
||||
const hops = BOUNCES + 1;
|
||||
const series = (1 - Math.pow(BOUNCE_FORWARD, hops)) / (1 - BOUNCE_FORWARD);
|
||||
let step = totalDist / series;
|
||||
@@ -140,7 +173,7 @@ export class LootC {
|
||||
step *= BOUNCE_FORWARD; peak *= BOUNCE_HEIGHT; ms *= BOUNCE_TIME;
|
||||
}
|
||||
|
||||
// 3) final impact: sharp squash (bottom on the ground) → springs back to normal
|
||||
// Landing pop: squash on the ground, then spring back to normal.
|
||||
const groundY = restY - PIECE_SIZE / 2;
|
||||
const pop = new TWEEN.Tween({ k: 0 }, this.tweens)
|
||||
.to({ k: 1 }, LAND_POP_MS)
|
||||
@@ -151,8 +184,7 @@ export class LootC {
|
||||
piece.position.y = groundY + (PIECE_SIZE * s) / 2; // bottom stays on the ground
|
||||
})
|
||||
.onComplete(() => {
|
||||
// After resting briefly → flies into the UI icon.
|
||||
setTimeout(() => this.collect(piece), COLLECT_DELAY_MS);
|
||||
setTimeout(() => this.collect(piece), COLLECT_DELAY_MS); // then fly to the UI
|
||||
});
|
||||
prev!.chain(pop);
|
||||
|
||||
@@ -160,9 +192,8 @@ export class LootC {
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect (#8): project the piece into screen pixels, swap the 3D sprite for an
|
||||
* HTML image of the same size, shrink it to the UI icon size and fly it to the
|
||||
* top-right corner — sizes match there, so the "arrival" is seamless.
|
||||
* Collect: swap the 3D sprite for an HTML image at the same screen spot, then
|
||||
* shrink it and fly it into the top-right UI counter.
|
||||
*/
|
||||
private static collect(piece: Sprite) {
|
||||
if (!this.pieces.includes(piece)) return; // already collected/removed
|
||||
@@ -171,12 +202,12 @@ export class LootC {
|
||||
if (!cam || !canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
// sprite center and top → screen pixels (for on-screen position and size)
|
||||
// Sprite center + size in screen pixels.
|
||||
const center = this.toScreen(piece.position, cam, rect);
|
||||
_topV.copy(piece.position); _topV.y += piece.scale.y / 2;
|
||||
const sizePx = Math.max(8, Math.abs(center.y - this.toScreen(_topV, cam, rect).y) * 2);
|
||||
|
||||
// drop the 3D sprite, replace it with an HTML image at the same point/size
|
||||
// Drop the 3D sprite; the HTML image takes over from the same spot.
|
||||
this.remove(piece);
|
||||
|
||||
const flier = document.createElement("img");
|
||||
@@ -186,50 +217,53 @@ export class LootC {
|
||||
`z-index:1000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top,width,height;`;
|
||||
document.body.appendChild(flier);
|
||||
|
||||
const st = { x: center.x, y: center.y, size: sizePx };
|
||||
const apply = () => {
|
||||
flier.style.left = `${st.x}px`;
|
||||
flier.style.top = `${st.y}px`;
|
||||
flier.style.width = `${st.size}px`;
|
||||
flier.style.height = `${st.size}px`;
|
||||
};
|
||||
apply();
|
||||
// White "glint" copy that rides on top of the flier and fades out as it moves.
|
||||
const flash = document.createElement("img");
|
||||
flash.src = woodIconUrl;
|
||||
flash.style.cssText = flier.style.cssText;
|
||||
flash.style.filter = "brightness(0) invert(1)"; // solid white silhouette
|
||||
flash.style.zIndex = "1002";
|
||||
document.body.appendChild(flash);
|
||||
|
||||
const target = this.uiIconCenter();
|
||||
// 1) shrink to UI size in place → 2) fly to the corner
|
||||
const shrink = new TWEEN.Tween(st, this.tweens)
|
||||
.to({ size: UI_ICON_SIZE }, SHRINK_MS)
|
||||
.easing(TWEEN.Easing.Quadratic.Out)
|
||||
.onUpdate(apply);
|
||||
|
||||
// Shared state. The flight, shrink and blink below all run at once, so they
|
||||
// blend into one smooth motion instead of separate steps.
|
||||
const st = { x: center.x, y: center.y, size: sizePx, o: 0 };
|
||||
const place = (el: HTMLElement) => {
|
||||
el.style.left = `${st.x}px`;
|
||||
el.style.top = `${st.y}px`;
|
||||
el.style.width = `${st.size}px`;
|
||||
el.style.height = `${st.size}px`;
|
||||
};
|
||||
const apply = () => { place(flier); place(flash); flash.style.opacity = `${st.o}`; };
|
||||
apply();
|
||||
|
||||
// Flight (position) — the longest tween, so it owns the cleanup.
|
||||
const fly = new TWEEN.Tween(st, this.tweens)
|
||||
.to({ x: target.x, y: target.y }, FLY_MS)
|
||||
.easing(TWEEN.Easing.Quadratic.In)
|
||||
.onUpdate(apply)
|
||||
.onComplete(() => { flier.remove(); this.pulseUiIcon(); });
|
||||
shrink.chain(fly);
|
||||
|
||||
// Smooth white blink before the flight: a white copy of the plank fades in
|
||||
// and out on top ("collected" feedback), then the shrink + flight.
|
||||
const flash = document.createElement("img");
|
||||
flash.src = woodIconUrl;
|
||||
flash.style.cssText = flier.style.cssText; // same position/size
|
||||
flash.style.filter = "brightness(0) invert(1)"; // solid white silhouette
|
||||
flash.style.opacity = "0";
|
||||
flash.style.zIndex = "1002";
|
||||
document.body.appendChild(flash);
|
||||
|
||||
const fl = { o: 0 };
|
||||
const setO = () => { flash.style.opacity = `${fl.o}`; };
|
||||
const flashIn = new TWEEN.Tween(fl, this.tweens)
|
||||
.onComplete(() => { flier.remove(); flash.remove(); this.balance++; this.renderCount(); this.pulseUiIcon(); });
|
||||
// Shrink (size) — runs alongside the flight, eases out so it shrinks early.
|
||||
const shrink = new TWEEN.Tween(st, this.tweens)
|
||||
.to({ size: UI_ICON_SIZE }, SHRINK_MS)
|
||||
.easing(TWEEN.Easing.Quadratic.Out)
|
||||
.onUpdate(apply);
|
||||
// Blink (opacity) — a quick glint that overlaps the start of the motion.
|
||||
const flashIn = new TWEEN.Tween(st, this.tweens)
|
||||
.to({ o: 1 }, BLINK_MS)
|
||||
.easing(TWEEN.Easing.Quadratic.Out)
|
||||
.onUpdate(setO);
|
||||
const flashOut = new TWEEN.Tween(fl, this.tweens)
|
||||
.onUpdate(apply);
|
||||
const flashOut = new TWEEN.Tween(st, this.tweens)
|
||||
.to({ o: 0 }, BLINK_MS * 1.6)
|
||||
.easing(TWEEN.Easing.Quadratic.In)
|
||||
.onUpdate(setO)
|
||||
.onComplete(() => { flash.remove(); shrink.start(); });
|
||||
.onUpdate(apply);
|
||||
flashIn.chain(flashOut);
|
||||
|
||||
// Kick them all off together → blended, fluid collect.
|
||||
fly.start();
|
||||
shrink.start();
|
||||
flashIn.start();
|
||||
}
|
||||
|
||||
|
||||
+117
-18
@@ -1,37 +1,47 @@
|
||||
import { Object3D, Vector3 } from "three";
|
||||
import { Mesh, Object3D, Vector3 } from "three";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import { UpdateController } from "@24tools/playable_template";
|
||||
import { PhysicsBody, PhysicsLayer } from "./PhysicsC";
|
||||
import { Trigger } from "./TriggerC";
|
||||
import { LootC } from "./LootC";
|
||||
import { VfxManager } from "../resources/vfx/VfxManager";
|
||||
|
||||
// A full crate's health. There are 3 damage levels (S1/S2/S3) splitting it
|
||||
// evenly: S1 = 100–67%, S2 = 66–34%, S3 = 33–1%, broken at 0. A crate that
|
||||
// ships already damaged (no S1 node, etc.) starts at the matching lower health.
|
||||
// Crate health, split evenly across 3 damage states (S1/S2/S3), broken at 0.
|
||||
const CRATE_MAX_HEALTH = 100;
|
||||
const LEVELS = 3;
|
||||
|
||||
// --- Hit/break animation tunables ---
|
||||
const HIT_PUNCH = 0.30; // how hard a hit squashes the crate
|
||||
const HIT_PUNCH_MS = 280; // squash → spring-back time
|
||||
const FLASH_MS = 200; // white blink time
|
||||
const BREAK_MS = 300; // shrink-to-nothing time on break
|
||||
const BREAK_SPIN = Math.PI * 0.66; // how far it spins while vanishing
|
||||
const VFX_CENTER_Y = 0.5; // VFX height (crate middle, not its base)
|
||||
|
||||
/** One breakable crate. Damage states are indexed by level: 0=S1, 1=S2, 2=S3. */
|
||||
export interface Crate {
|
||||
root: Object3D;
|
||||
statesByLevel: (Object3D | null)[]; // length 3; null where that state isn't authored
|
||||
startLevel: number; // lowest authored state = how damaged it starts
|
||||
statesByLevel: (Object3D | null)[]; // the 3 damage meshes; null where not authored
|
||||
startLevel: number; // how damaged it starts
|
||||
level: number; // currently shown level
|
||||
collider: PhysicsBody;
|
||||
trigger: Trigger | null;
|
||||
health: number;
|
||||
maxHealth: number;
|
||||
broken: boolean;
|
||||
baseScale: Vector3; // resting scale (tweens multiply this)
|
||||
baseRotY: number; // resting Y rotation
|
||||
hitTween: TWEEN.Tween<{ k: number }> | null; // live hit-punch tween (killed before re-firing)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the crates baked into the map's "Lootable" group.
|
||||
*
|
||||
* Each Wooden_Box ships its damage states (S1/S2/S3) all visible at once, but
|
||||
* NOT every crate has all three — some start at S2 or S3, i.e. pre-damaged. We
|
||||
* read the lowest authored state, show only it, set health to the matching
|
||||
* percentage, and turn the collider proxy into a STATIC cannon box.
|
||||
* Builds the breakable crates from the map's "Lootable" group: shows one damage
|
||||
* state per crate, sets its health, and gives it a static collider.
|
||||
*/
|
||||
export class LootableC {
|
||||
static crates: Crate[] = [];
|
||||
private static tweens = new TWEEN.Group(); // our tween group, pumped each frame
|
||||
private static flashedStates = new WeakSet<Object3D>(); // states whose materials we've cloned for flashing
|
||||
|
||||
static init(lootableGroup: Object3D | null) {
|
||||
if (!lootableGroup) {
|
||||
@@ -39,6 +49,9 @@ export class LootableC {
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive our juice tweens (hit punch, flash, break) every frame.
|
||||
UpdateController.Instance.onUpdate.addDelegate(() => this.tweens.update());
|
||||
|
||||
lootableGroup.visible = true;
|
||||
lootableGroup.updateWorldMatrix(true, true); // collider world positions must be current
|
||||
|
||||
@@ -80,6 +93,7 @@ export class LootableC {
|
||||
this.crates.push({
|
||||
root: crate, statesByLevel, startLevel, level: startLevel,
|
||||
collider, trigger: null, health, maxHealth: CRATE_MAX_HEALTH, broken: false,
|
||||
baseScale: crate.scale.clone(), baseRotY: crate.rotation.y, hitTween: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,6 +110,10 @@ export class LootableC {
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-hit juice: a quick squash-punch + a white blink.
|
||||
this.punchCrate(crate);
|
||||
this.flashCrate(crate);
|
||||
|
||||
// Map remaining health to a level, never below where this crate started.
|
||||
let level = Math.floor((1 - crate.health / crate.maxHealth) * LEVELS);
|
||||
level = Math.max(crate.startLevel, Math.min(LEVELS - 1, level));
|
||||
@@ -107,20 +125,101 @@ export class LootableC {
|
||||
}
|
||||
}
|
||||
|
||||
/** Crate destroyed: hide it and remove its physics + trigger from the world. */
|
||||
/** Crate destroyed: drop loot, then shrink-and-spin away. */
|
||||
static breakCrate(crate: Crate) {
|
||||
if (crate.broken) return;
|
||||
crate.broken = true;
|
||||
|
||||
crate.statesByLevel.forEach(s => { if (s) s.visible = false; });
|
||||
// Stop the hit-punch so it doesn't fight the break animation.
|
||||
crate.hitTween?.stop();
|
||||
crate.hitTween = null;
|
||||
|
||||
// Remove physics + trigger now so the player walks through right away.
|
||||
crate.collider.destroy();
|
||||
crate.trigger?.destroy();
|
||||
crate.trigger = null;
|
||||
|
||||
// TODO #7/#8: spawn wood loot at crate.root world position, scatter with a
|
||||
// bounce, then tween it into the resource counter.
|
||||
console.log("[Lootable] crate broken");
|
||||
|
||||
// Drop loot at the crate's spot.
|
||||
LootC.spawn(crate.root.getWorldPosition(new Vector3()));
|
||||
|
||||
// Shrink to nothing while spinning, then hide the meshes.
|
||||
const s = { k: 0 };
|
||||
new TWEEN.Tween(s, this.tweens)
|
||||
.to({ k: 1 }, BREAK_MS)
|
||||
.easing(TWEEN.Easing.Back.In)
|
||||
.onUpdate(({ k }) => {
|
||||
const sc = Math.max(0, 1 - k);
|
||||
crate.root.scale.set(crate.baseScale.x * sc, crate.baseScale.y * sc, crate.baseScale.z * sc);
|
||||
crate.root.rotation.y = crate.baseRotY + k * BREAK_SPIN;
|
||||
})
|
||||
.onComplete(() => {
|
||||
crate.statesByLevel.forEach(st => { if (st) st.visible = false; });
|
||||
})
|
||||
.start();
|
||||
|
||||
const breakFxPos = crate.root.getWorldPosition(new Vector3());
|
||||
breakFxPos.y += VFX_CENTER_Y;
|
||||
VfxManager.Play("DestroyEffect", null, breakFxPos);
|
||||
}
|
||||
|
||||
// Squash the crate, then spring back (with a small overshoot pop).
|
||||
private static punchCrate(crate: Crate) {
|
||||
crate.hitTween?.stop(); // drop the previous punch so rapid hits don't stack
|
||||
const s = { k: 0 };
|
||||
crate.hitTween = new TWEEN.Tween(s, this.tweens)
|
||||
.to({ k: 1 }, HIT_PUNCH_MS)
|
||||
.easing(TWEEN.Easing.Back.Out)
|
||||
.onUpdate(({ k }) => {
|
||||
const q = (1 - k) * HIT_PUNCH; // HIT_PUNCH (squashed) → 0 (rest)
|
||||
crate.root.scale.set(
|
||||
crate.baseScale.x * (1 + q),
|
||||
crate.baseScale.y * (1 - q),
|
||||
crate.baseScale.z * (1 + q),
|
||||
);
|
||||
})
|
||||
.onComplete(() => {
|
||||
crate.root.scale.copy(crate.baseScale); // snap back exactly
|
||||
crate.hitTween = null;
|
||||
})
|
||||
.start();
|
||||
|
||||
// Hit spark at the crate's middle (world position, no parent).
|
||||
const hitPos = crate.root.getWorldPosition(new Vector3());
|
||||
hitPos.y += VFX_CENTER_Y;
|
||||
VfxManager.Play("HitEffect", null, hitPos);
|
||||
}
|
||||
|
||||
// Flash the crate white, then fade out. Materials are cloned per state so the
|
||||
// flash doesn't bleed onto other crates sharing the same material.
|
||||
private static flashCrate(crate: Crate) {
|
||||
const state = crate.statesByLevel[crate.level];
|
||||
if (!state) return;
|
||||
|
||||
if (!this.flashedStates.has(state)) {
|
||||
state.traverse(o => {
|
||||
const mesh = o as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
mesh.material = Array.isArray(mesh.material)
|
||||
? mesh.material.map(m => m.clone())
|
||||
: (mesh.material as any).clone();
|
||||
});
|
||||
this.flashedStates.add(state);
|
||||
}
|
||||
|
||||
const mats: any[] = [];
|
||||
state.traverse(o => {
|
||||
const mesh = o as Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
(Array.isArray(mesh.material) ? mesh.material : [mesh.material]).forEach(m => mats.push(m));
|
||||
});
|
||||
mats.forEach(m => { if (m.emissive) m.emissive.setRGB(1, 1, 1); });
|
||||
|
||||
const s = { k: 1 };
|
||||
new TWEEN.Tween(s, this.tweens)
|
||||
.to({ k: 0 }, FLASH_MS)
|
||||
.easing(TWEEN.Easing.Quadratic.Out)
|
||||
.onUpdate(({ k }) => { mats.forEach(m => { if (m.emissive) m.emissiveIntensity = k; }); })
|
||||
.onComplete(() => { mats.forEach(m => { if (m.emissive) m.emissiveIntensity = 0; }); })
|
||||
.start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Object3D, Raycaster, Vector3 } from "three";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import { UpdateController, CameraC_internal } from "@24tools/playable_template";
|
||||
import { Trigger } from "./TriggerC";
|
||||
import { TestSceneC } from "./TestSceneC";
|
||||
import { LootC } from "./LootC";
|
||||
import { woodIconUrl } from "../resources/images/woodIcon";
|
||||
|
||||
// --- Tunables ---
|
||||
const COST = 15; // wood needed to fully pay the zone
|
||||
const ZONE_FALLBACK = new Vector3(0, 0, 0); // zone spot if UI_Interactive_Zone_02 is missing
|
||||
const ZONE_OFFSET = new Vector3(0, 0, -4); // shift the zone toward the player (off the crates)
|
||||
const ZONE_SCALE = 1.6; // scale the pad up for presence
|
||||
const ICON_LIFT = 0.04; // wood icon height above the pad (local)
|
||||
const TRIGGER_HALF = 1.0; // enter-trigger half size (x/z)
|
||||
const PAY_INTERVAL = 0.09; // seconds between flying planks
|
||||
const FLY_MS = 420; // UI → zone flight time
|
||||
const FLY_ARC_PX = 90; // height of the flight arc (px)
|
||||
const PLANK_PX = 26; // flying plank size (px)
|
||||
const FILL_GROW = 0.15; // how much the pad grows when full
|
||||
const PULSE_MS = 160; // per-plank pulse time
|
||||
|
||||
const _v = new Vector3();
|
||||
const _ndc = new Vector3();
|
||||
|
||||
/**
|
||||
* Pay zone: the player stands on the UI_Tool_Zone pad and the wood collected in
|
||||
* the top-right counter flies into it, plank by plank. When COST is reached the
|
||||
* pad pops and disappears.
|
||||
*/
|
||||
export class PayZoneC {
|
||||
private static zone: Object3D | null = null;
|
||||
private static trigger: Trigger | null = null;
|
||||
private static tweens = new TWEEN.Group(); // our tween group, pumped each frame
|
||||
|
||||
private static inside = false; // is the player standing on the pad
|
||||
private static done = false; // already paid in full
|
||||
private static paid = 0; // wood delivered so far
|
||||
private static payCd = 0; // cooldown until the next plank
|
||||
|
||||
private static baseScale = new Vector3(1, 1, 1); // pad resting scale (tweens multiply it)
|
||||
private static pulseTween: TWEEN.Tween<{ k: number }> | null = null;
|
||||
|
||||
static init() {
|
||||
const zone = TestSceneC.payZone;
|
||||
if (!zone) { console.warn("[PayZone] UI_Tool_Zone not found"); return; }
|
||||
this.zone = zone;
|
||||
|
||||
// Where to put the pad: UI_Interactive_Zone_02's spot, shifted toward the
|
||||
// player, sitting on the real surface (raycast — the road is above the sand).
|
||||
const target = _v.copy(ZONE_FALLBACK);
|
||||
if (TestSceneC.interactiveZone) TestSceneC.interactiveZone.getWorldPosition(target);
|
||||
target.x += ZONE_OFFSET.x;
|
||||
target.z += ZONE_OFFSET.z;
|
||||
target.y = this.sampleSurfaceY(target.x, target.z) + 0.02;
|
||||
|
||||
// Make the wood icon a child of the pad so it moves/vanishes with it.
|
||||
const icon = TestSceneC.payZoneIcon;
|
||||
if (icon) {
|
||||
zone.attach(icon);
|
||||
icon.position.set(0, ICON_LIFT, 0);
|
||||
icon.visible = true;
|
||||
}
|
||||
|
||||
// Place, scale, remember the resting scale, show.
|
||||
zone.position.copy(target);
|
||||
zone.scale.multiplyScalar(ZONE_SCALE);
|
||||
this.baseScale.copy(zone.scale);
|
||||
zone.visible = true;
|
||||
|
||||
// Trigger that tells us when the player is on the pad.
|
||||
this.trigger = new Trigger(
|
||||
{ x: target.x, y: target.y + 0.5, z: target.z },
|
||||
{ x: TRIGGER_HALF, y: 1.0, z: TRIGGER_HALF },
|
||||
{ onEnter: () => { this.inside = true; }, onExit: () => { this.inside = false; } },
|
||||
);
|
||||
|
||||
UpdateController.Instance.onUpdate.addDelegate((d) => this.update(d));
|
||||
}
|
||||
|
||||
private static update(delta: number) {
|
||||
this.tweens.update();
|
||||
if (this.done || !this.inside) return;
|
||||
|
||||
// One plank at a time: wait out the cooldown, stop if paid or out of wood.
|
||||
this.payCd -= delta;
|
||||
if (this.payCd > 0) return;
|
||||
if (this.paid >= COST || LootC.getBalance() <= 0) return;
|
||||
|
||||
// Spend one wood and fly it into the zone.
|
||||
LootC.spend(1);
|
||||
this.paid++;
|
||||
this.payCd = PAY_INTERVAL;
|
||||
this.flyOnePlank();
|
||||
|
||||
if (this.paid >= COST) this.complete();
|
||||
}
|
||||
|
||||
// Fly one HTML plank from the UI counter to the zone along an arc.
|
||||
private static flyOnePlank() {
|
||||
const src = LootC.uiIconScreenCenter();
|
||||
const tgt = this.zoneScreenCenter();
|
||||
if (!tgt) return;
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = woodIconUrl;
|
||||
img.style.cssText =
|
||||
`position:fixed; left:0; top:0; width:${PLANK_PX}px; height:${PLANK_PX}px;` +
|
||||
`z-index:1000; pointer-events:none; transform:translate(-50%,-50%); will-change:left,top;`;
|
||||
document.body.appendChild(img);
|
||||
|
||||
const st = { t: 0 };
|
||||
new TWEEN.Tween(st, this.tweens)
|
||||
.to({ t: 1 }, FLY_MS)
|
||||
.easing(TWEEN.Easing.Quadratic.In)
|
||||
.onUpdate(({ t }) => {
|
||||
const x = src.x + (tgt.x - src.x) * t;
|
||||
const y = src.y + (tgt.y - src.y) * t - Math.sin(Math.PI * t) * FLY_ARC_PX; // arc
|
||||
img.style.left = `${x}px`;
|
||||
img.style.top = `${y}px`;
|
||||
})
|
||||
.onComplete(() => { img.remove(); this.onPlankArrived(); })
|
||||
.start();
|
||||
}
|
||||
|
||||
// A plank reached the zone → pulse the pad (bigger the fuller it is).
|
||||
private static onPlankArrived() {
|
||||
const zone = this.zone;
|
||||
if (!zone || this.done) return;
|
||||
|
||||
const fill = FILL_GROW * (this.paid / COST);
|
||||
this.pulseTween?.stop(); // drop the previous pulse so they don't fight
|
||||
const s = { k: 0 };
|
||||
this.pulseTween = new TWEEN.Tween(s, this.tweens)
|
||||
.to({ k: 1 }, PULSE_MS)
|
||||
.easing(TWEEN.Easing.Back.Out)
|
||||
.onUpdate(({ k }) => {
|
||||
const g = 1 + fill + (1 - k) * 0.12; // settle at fill size with a small punch
|
||||
zone.scale.set(this.baseScale.x * g, this.baseScale.y * g, this.baseScale.z * g);
|
||||
})
|
||||
.onComplete(() => { this.pulseTween = null; })
|
||||
.start();
|
||||
}
|
||||
|
||||
// Paid in full: grow, then shrink to nothing.
|
||||
private static complete() {
|
||||
this.done = true;
|
||||
this.trigger?.destroy();
|
||||
this.trigger = null;
|
||||
this.pulseTween?.stop();
|
||||
this.pulseTween = null;
|
||||
|
||||
const zone = this.zone;
|
||||
if (!zone) return;
|
||||
|
||||
const setScale = (g: number) => zone.scale.set(this.baseScale.x * g, this.baseScale.y * g, this.baseScale.z * g);
|
||||
|
||||
const grow = new TWEEN.Tween({ k: 0 }, this.tweens)
|
||||
.to({ k: 1 }, 180)
|
||||
.easing(TWEEN.Easing.Back.Out)
|
||||
.onUpdate(({ k }) => setScale(1 + 0.45 * k));
|
||||
const vanish = new TWEEN.Tween({ k: 0 }, this.tweens)
|
||||
.to({ k: 1 }, 260)
|
||||
.easing(TWEEN.Easing.Back.In)
|
||||
.onUpdate(({ k }) => setScale(1.45 * Math.max(0, 1 - k)))
|
||||
.onComplete(() => {
|
||||
zone.visible = false;
|
||||
if (TestSceneC.payZoneIcon) TestSceneC.payZoneIcon.visible = false; // hide the icon too
|
||||
});
|
||||
|
||||
grow.chain(vanish);
|
||||
grow.start();
|
||||
console.log("[PayZone] paid in full");
|
||||
}
|
||||
|
||||
// Ground height (road/sand) at an x/z via a downward ray.
|
||||
private static sampleSurfaceY(x: number, z: number): number {
|
||||
const env = TestSceneC.environment;
|
||||
if (env) {
|
||||
const ray = new Raycaster(new Vector3(x, 5, z), new Vector3(0, -1, 0));
|
||||
const hits = ray.intersectObject(env, true);
|
||||
if (hits.length) return hits[0].point.y;
|
||||
}
|
||||
return TestSceneC.groundY;
|
||||
}
|
||||
|
||||
// Zone world center → screen pixels (target for the flying planks).
|
||||
private static zoneScreenCenter(): { x: number; y: number } | null {
|
||||
const cam = CameraC_internal.camera;
|
||||
const canvas = document.querySelector("canvas");
|
||||
const zone = this.zone;
|
||||
if (!cam || !canvas || !zone) return null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
_v.copy(zone.position); _v.y += 0.3;
|
||||
_ndc.copy(_v).project(cam);
|
||||
return {
|
||||
x: rect.left + (_ndc.x * 0.5 + 0.5) * rect.width,
|
||||
y: rect.top + (-_ndc.y * 0.5 + 0.5) * rect.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
+32
-31
@@ -13,15 +13,18 @@ enum MoveState { Idle = "idle", Walk = "walk", Run = "run" }
|
||||
|
||||
const CHAR_RADIUS = 0.35;
|
||||
|
||||
// Extra time the bat keeps swinging after the hit that breaks the last crate,
|
||||
// so the strike completes visually before returning to idle.
|
||||
// How long the bat keeps swinging after the killing hit, before going idle.
|
||||
const ATTACK_FOLLOW_THROUGH = 0.25;
|
||||
|
||||
// How fast the feet ease toward the sampled ground height (per second).
|
||||
// Higher = snappier / hugs the surface tighter; lower = smoother but lags more
|
||||
// on slopes and curbs. This is what kills the jolt when crossing a curb.
|
||||
// How fast the feet ease toward the ground height (per second). Higher = snappier.
|
||||
const GROUND_SMOOTH = 12;
|
||||
|
||||
// The two moments in the Loot clip where each swing connects (0..1 of the clip).
|
||||
const IMPACT_PHASES = [0.38, 0.68];
|
||||
// The bat can hit only within ±this of an impact phase — outside it (wind-up /
|
||||
// follow-through) it deals no damage. Bigger = longer hit window.
|
||||
const CONTACT_WINDOW = 0.14;
|
||||
|
||||
const _inputTarget = new Vector3();
|
||||
|
||||
export class PlayerC {
|
||||
@@ -44,21 +47,17 @@ export class PlayerC {
|
||||
private static hasAttackTarget = false;
|
||||
private static attackAction: AnimationAction | null = null; // the "Loot" clip used as the swing
|
||||
|
||||
// Cached local ends of the bat mesh (computed once) → its swinging tip in
|
||||
// world space. CombatC reads getBatTip() to damage a crate only when the bat
|
||||
// actually reaches it (geometric contact, no animation-time markers).
|
||||
// Swing hit-window tracking. _activeWindow = which IMPACT_PHASES window we're in
|
||||
// (-1 = none). _swingId bumps on entering a window, so CombatC knows it's a new swing.
|
||||
private static _activeWindow = -1;
|
||||
private static _swingId = 0;
|
||||
|
||||
// Cached bat-mesh ends → its swinging tip in world space (used by getBatTip).
|
||||
private static _batEndA: Vector3 | null = null;
|
||||
private static _batEndB: Vector3 | null = null;
|
||||
private static _tipA = new Vector3();
|
||||
private static _tipB = new Vector3();
|
||||
|
||||
// Swing counter: the Loot clip contains TWO strikes (a left swing then a
|
||||
// right swing), so this bumps TWICE per loop — at the half-way point and at
|
||||
// the wrap. CombatC uses it to damage every touched crate once per strike,
|
||||
// so a crate the bat sweeps over in both swings takes two sequential hits.
|
||||
private static swingCycle = 0;
|
||||
private static _prevStrike = 0;
|
||||
|
||||
// When the killing blow lands we don't cut the swing at the impact frame —
|
||||
// we let the bat follow through for this long, then return to idle.
|
||||
private static finishing = false;
|
||||
@@ -117,8 +116,8 @@ export class PlayerC {
|
||||
this.currentAction?.fadeOut(0.15);
|
||||
this.attackAction.reset().fadeIn(0.15).play();
|
||||
this.currentAction = this.attackAction;
|
||||
this._prevStrike = 0;
|
||||
this.swingCycle++; // new attack = fresh strike
|
||||
this._activeWindow = -1; // not in a live window yet this attack
|
||||
this._swingId = 0;
|
||||
} else if (!active) {
|
||||
this.hasAttackTarget = false;
|
||||
this.attackAction?.fadeOut(0.15);
|
||||
@@ -293,15 +292,19 @@ export class PlayerC {
|
||||
// locomotion state machine below is suspended so it can't override it.
|
||||
if (this.attacking) {
|
||||
if (this.hasAttackTarget) this.faceTowards(this.attackTarget.x, this.attackTarget.z, delta);
|
||||
// Split the Loot clip into its two strikes (first half = left swing,
|
||||
// second half = right swing). Bump the counter on each → two hits/loop.
|
||||
// Which hit-window the Loot clip is in now (-1 = none → bat can't hit).
|
||||
const a = this.attackAction;
|
||||
if (a) {
|
||||
const dur = a.getClip().duration;
|
||||
const phase = dur > 0 ? (a.time % dur) / dur : 0; // 0..1 within the swing
|
||||
const strike = phase < 0.5 ? 0 : 1;
|
||||
if (strike !== this._prevStrike) this.swingCycle++;
|
||||
this._prevStrike = strike;
|
||||
const phase = dur > 0 ? (a.time % dur) / dur : 0; // 0..1 within the clip
|
||||
let wi = -1;
|
||||
for (let i = 0; i < IMPACT_PHASES.length; i++) {
|
||||
if (Math.abs(phase - IMPACT_PHASES[i]) <= CONTACT_WINDOW) { wi = i; break; }
|
||||
}
|
||||
if (wi !== this._activeWindow) {
|
||||
if (wi !== -1) this._swingId++; // entered a new window → new swing
|
||||
this._activeWindow = wi;
|
||||
}
|
||||
}
|
||||
if (this.finishing) {
|
||||
this.finishTimer -= delta;
|
||||
@@ -331,9 +334,12 @@ export class PlayerC {
|
||||
}
|
||||
}
|
||||
|
||||
// World position of the bat's swinging tip (the end farther from the body),
|
||||
// or null when the bat isn't in hand. CombatC uses this to damage a crate the
|
||||
// instant the bat actually reaches it.
|
||||
/** Current swing id while the bat is in a hit window, or 0 when it can't hit. */
|
||||
static getActiveSwingId(): number {
|
||||
return this._activeWindow === -1 ? 0 : this._swingId;
|
||||
}
|
||||
|
||||
/** World position of the bat tip, or null if the bat isn't in hand. */
|
||||
static getBatTip(out: Vector3): Vector3 | null {
|
||||
const bat = this.batInHand as Mesh | null;
|
||||
if (!bat || !bat.visible || !bat.geometry) return null;
|
||||
@@ -356,11 +362,6 @@ export class PlayerC {
|
||||
return out.copy(farther);
|
||||
}
|
||||
|
||||
/** Index of the current strike (bumps twice per Loot loop: left then right swing). */
|
||||
static getSwingCycle(): number {
|
||||
return this.swingCycle;
|
||||
}
|
||||
|
||||
// Smoothly rotate the mesh's Y so it faces the given world x/z point.
|
||||
private static faceTowards(x: number, z: number, delta: number) {
|
||||
const dx = x - this.mesh.position.x;
|
||||
|
||||
@@ -13,6 +13,9 @@ export class TestSceneC {
|
||||
static environment: Object3D | null = null; // Ground_ — visual ground/road/borders, the only thing rendered
|
||||
static colliderGroup: Object3D | null = null; // Colliders — invisible BoxCollider proxies → cannon bodies
|
||||
static lootableGroup: Object3D | null = null; // Lootable — interactive crates, hidden until activated per-crate
|
||||
static payZone: Object3D | null = null; // UI_Tool_Zone — the pay-zone pad
|
||||
static payZoneIcon: Object3D | null = null; // UI_Wood — wood icon shown on the pad
|
||||
static interactiveZone: Object3D | null = null; // UI_Interactive_Zone_02 — marks where the pad goes
|
||||
|
||||
// World Y of the walkable sand surface (the floor proxy is aligned to it).
|
||||
static groundY = 0;
|
||||
@@ -38,8 +41,10 @@ export class TestSceneC {
|
||||
this.environment = this.mapObject.getObjectByName("Ground_") ?? null;
|
||||
this.colliderGroup = this.mapObject.getObjectByName("Colliders") ?? null;
|
||||
this.lootableGroup = this.mapObject.getObjectByName("Lootable") ?? null;
|
||||
this.payZone = this.mapObject.getObjectByName("UI_Tool_Zone") ?? null;
|
||||
this.payZoneIcon = this.mapObject.getObjectByName("UI_Wood") ?? null;
|
||||
this.interactiveZone = this.mapObject.getObjectByName("UI_Interactive_Zone_02") ?? null;
|
||||
const uiGroup = this.mapObject.getObjectByName("UI") ?? null;
|
||||
const uiWood = this.mapObject.getObjectByName("UI_Wood") ?? null;
|
||||
|
||||
// Add the whole graph so every world transform stays intact (the collider
|
||||
// proxies' world positions depend on the full parent chain), then hide
|
||||
@@ -56,7 +61,14 @@ export class TestSceneC {
|
||||
if (this.colliderGroup) this.colliderGroup.visible = false; // physics-only, never rendered
|
||||
// Lootable crates are set up by LootableC (one state shown + colliders).
|
||||
if (uiGroup) uiGroup.visible = false; // playable UI is HTML/CSS, not in-world
|
||||
if (uiWood) uiWood.visible = false;
|
||||
if (this.interactiveZone) this.interactiveZone.visible = false; // only its position is used
|
||||
// payZone + its icon are placed and shown by PayZoneC.
|
||||
if (this.payZone) this.payZone.visible = false;
|
||||
if (this.payZoneIcon) this.payZoneIcon.visible = false;
|
||||
// Hide the unused duplicate wood icon (three.js drops the dot → "UI_Wood001").
|
||||
const strayWoodIcon = this.mapObject.getObjectByName("UI_Wood001")
|
||||
?? this.mapObject.getObjectByName("UI_Wood.001");
|
||||
if (strayWoodIcon) strayWoodIcon.visible = false;
|
||||
|
||||
// Only the environment is rendered with shadows.
|
||||
if (this.environment) {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum VFXType {
|
||||
HitEffect,
|
||||
DestroyEffect
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
|
||||
|
||||
// URL дерев'яної іконки лута. У релізі інлайниться в base64 (як меші/звуки).
|
||||
// Шлях відносно цього файлу.
|
||||
// URL of the wood loot icon. In a release build it's inlined as base64 (like
|
||||
// meshes/sounds). Path is relative to this file.
|
||||
export const woodIconUrl = ConvertToBase64WhenRelease("./Icon_Wood.webp");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ConvertResourcesType } from "@24tools/playable_template";
|
||||
import { meshes } from "./meshes/meshes";
|
||||
import { sounds } from "./sounds/sounds";
|
||||
import { vfx_json } from "./vfx/vfx_json";
|
||||
|
||||
export const resources: ConvertResourcesType = [meshes, sounds];
|
||||
export const resources: ConvertResourcesType = [meshes, sounds, vfx_json];
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
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 "../Enums/ResourcesType";
|
||||
import { vfxTest } from "./RunTimeTest";
|
||||
import { ThreeC } from "../../controllers/ThreeC";
|
||||
|
||||
// Resource "type" under which Quark VFX JSONs are registered (see resources.ts).
|
||||
const VFX_RESOURCE_TYPE = "vfx_json";
|
||||
|
||||
export class VfxManager {
|
||||
static batchRenderer: BatchedRenderer;
|
||||
static loader: QuarksLoader;
|
||||
|
||||
static Init() {
|
||||
static init() {
|
||||
this.batchRenderer = new BatchedRenderer();
|
||||
this.loader = new QuarksLoader();
|
||||
ThreeC.addToScene(this.batchRenderer);
|
||||
@@ -26,24 +25,30 @@ export class VfxManager {
|
||||
}
|
||||
|
||||
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) {
|
||||
let loaded = (ResourcesC.getResource(ResourcesType.VFX, type.toString()) as { obj: any }).obj;
|
||||
if (!loaded) return new Object3D();
|
||||
// console.error("Type non loaded " + loaded);
|
||||
const effect = loaded.clone(true) as Object3D;
|
||||
static Play(
|
||||
name: string,
|
||||
parent: Object3D | null = null,
|
||||
position: Vector3 | null = null,
|
||||
rotation: Euler | null = null,
|
||||
scale: Vector3 | null = null,
|
||||
renderOrder: number | null = null,
|
||||
): Object3D {
|
||||
const resource = ResourcesC.getResource<{ obj: Object3D } | undefined>(VFX_RESOURCE_TYPE, name);
|
||||
if (!resource?.obj) return new Object3D();
|
||||
|
||||
const effect = resource.obj.clone(true);
|
||||
QuarksUtil.setAutoDestroy(effect, true);
|
||||
QuarksUtil.addToBatchRenderer(effect, this.batchRenderer);
|
||||
|
||||
if (parent) parent.add(effect)
|
||||
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;
|
||||
if (renderOrder != null) effect.renderOrder = renderOrder;
|
||||
|
||||
return effect;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
|
||||
import { ConvertToBase64WhenRelease } from "@24tools/ads_common";
|
||||
import { ConvertResourceType, Template3d } from "@24tools/playable_template";
|
||||
import { QuarksLoader } from "three.quarks";
|
||||
|
||||
export const vfx_json: ConvertResourceType = {
|
||||
type: "vfx_json",
|
||||
resources: [
|
||||
{
|
||||
name: "HitEffect",
|
||||
value: ConvertToBase64WhenRelease("resources/vfx/files/VFX_Lootable_Hit.json"),
|
||||
},
|
||||
{
|
||||
name: "DestroyEffect",
|
||||
value: ConvertToBase64WhenRelease("resources/vfx/files/VFX_Lootable_Destroy.json"),
|
||||
},
|
||||
{
|
||||
name: "Test",
|
||||
value: ConvertToBase64WhenRelease("resources/vfx/files/test.json"),
|
||||
},
|
||||
],
|
||||
loader: quarksLoader,
|
||||
}
|
||||
|
||||
export function quarksLoader(base64String: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
new QuarksLoader(Template3d.manager).parse(
|
||||
JSON.parse(atob(base64String.split(",")[1])),
|
||||
(obj) => {
|
||||
resolve({ obj });
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
reject("Error loading vfx: " + error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { TriggerC } from "../controllers/TriggerC";
|
||||
import { CombatC } from "../controllers/CombatC";
|
||||
import { JoystickC, SoundC, Template } from "@24tools/playable_template";
|
||||
import { LootC } from "../controllers/LootC";
|
||||
import { PayZoneC } from "../controllers/PayZoneC";
|
||||
import { VfxManager } from "../resources/vfx/VfxManager";
|
||||
|
||||
export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
|
||||
TestSceneC.init();
|
||||
@@ -49,6 +51,12 @@ export const afterResourcesLoadedCb: (() => void) | undefined = async () => {
|
||||
// Loot: spawn loot pieces when a crate breaks.
|
||||
LootC.init();
|
||||
|
||||
// Pay zone: walk onto UI_Tool_Zone → spend collected wood into it, then it vanishes.
|
||||
PayZoneC.init();
|
||||
|
||||
// VFX: set up the quark particle renderer.
|
||||
VfxManager.init();
|
||||
|
||||
// if (import.meta.env.DEV) {
|
||||
// const { CameraDebugUI } = await import("../controllers/CameraDebugUI");
|
||||
// CameraDebugUI.init();
|
||||
|
||||
Reference in New Issue
Block a user