mirror of
https://github.com/DaanVandenBosch/phantasmal-world.git
synced 2025-04-04 22:58:29 +08:00
PSO v2 NJM files can now be parsed.
This commit is contained in:
parent
f19f2df2d2
commit
017eb3e3e0
@ -21,6 +21,7 @@ export type NinjaModel = NjModel | XjModel;
|
||||
|
||||
export class NinjaObject<M extends NinjaModel> {
|
||||
private bone_cache = new Map<number, NinjaObject<M> | null>();
|
||||
private _bone_count = -1;
|
||||
|
||||
constructor(
|
||||
public evaluation_flags: {
|
||||
@ -40,34 +41,47 @@ export class NinjaObject<M extends NinjaModel> {
|
||||
public children: NinjaObject<M>[]
|
||||
) { }
|
||||
|
||||
find_bone(bone_id: number): NinjaObject<M> | undefined {
|
||||
bone_count(): number {
|
||||
if (this._bone_count === -1) {
|
||||
const id_ref: [number] = [0];
|
||||
this.get_bone_internal(this, Infinity, id_ref);
|
||||
this._bone_count = id_ref[0];
|
||||
}
|
||||
|
||||
return this._bone_count;
|
||||
}
|
||||
|
||||
get_bone(bone_id: number): NinjaObject<M> | undefined {
|
||||
let bone = this.bone_cache.get(bone_id);
|
||||
|
||||
// Strict check because null means there's no bone with this id.
|
||||
if (bone === undefined) {
|
||||
bone = this.find_bone_internal(this, bone_id, [0]);
|
||||
bone = this.get_bone_internal(this, bone_id, [0]);
|
||||
this.bone_cache.set(bone_id, bone || null);
|
||||
}
|
||||
|
||||
return bone || undefined;
|
||||
}
|
||||
|
||||
private find_bone_internal(
|
||||
private get_bone_internal(
|
||||
object: NinjaObject<M>,
|
||||
bone_id: number,
|
||||
id_ref: [number]
|
||||
): NinjaObject<M> | undefined {
|
||||
if (!object.evaluation_flags.skip) {
|
||||
const id = id_ref[0]++;
|
||||
this.bone_cache.set(id, object);
|
||||
|
||||
if (id === bone_id) {
|
||||
return object;
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of object.children) {
|
||||
const bone = this.find_bone_internal(child, bone_id, id_ref);
|
||||
if (bone) return bone;
|
||||
if (!object.evaluation_flags.break_child_trace) {
|
||||
for (const child of object.children) {
|
||||
const bone = this.get_bone_internal(child, bone_id, id_ref);
|
||||
if (bone) return bone;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,11 +3,6 @@ import { Vec3 } from '../../../domain';
|
||||
|
||||
const ANGLE_TO_RAD = 2 * Math.PI / 0xFFFF;
|
||||
|
||||
export type NjAction = {
|
||||
object_offset: number,
|
||||
motion: NjMotion
|
||||
}
|
||||
|
||||
export type NjMotion = {
|
||||
motion_data: NjMotionData[],
|
||||
frame_count: number,
|
||||
@ -64,33 +59,44 @@ export type NjKeyframeA = {
|
||||
value: Vec3, // Euler angles in radians.
|
||||
}
|
||||
|
||||
export function parse_njm(cursor: BufferCursor, bone_count: number): NjMotion {
|
||||
if (cursor.string_ascii(4, false, true) === 'NMDM') {
|
||||
return parse_njm_v2(cursor, bone_count);
|
||||
} else {
|
||||
cursor.seek_start(0);
|
||||
return parse_njm_bb(cursor, bone_count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format used by PSO v2 and for the enemies in PSO:BB.
|
||||
*/
|
||||
function parse_njm_v2(cursor: BufferCursor, bone_count: number): NjMotion {
|
||||
const chunk_size = cursor.u32();
|
||||
return parse_motion(cursor.take(chunk_size), bone_count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format used by PSO:BB plymotiondata.rlc.
|
||||
*/
|
||||
export function parse_njm_4(cursor: BufferCursor): NjAction {
|
||||
function parse_njm_bb(cursor: BufferCursor, bone_count: number): NjMotion {
|
||||
cursor.seek_end(16);
|
||||
const offset1 = cursor.u32();
|
||||
cursor.seek_start(offset1);
|
||||
const action_offset = cursor.u32();
|
||||
cursor.seek_start(action_offset);
|
||||
return parse_action(cursor);
|
||||
return parse_action(cursor, bone_count);
|
||||
}
|
||||
|
||||
function parse_action(cursor: BufferCursor): NjAction {
|
||||
const object_offset = cursor.u32();
|
||||
function parse_action(cursor: BufferCursor, bone_count: number): NjMotion {
|
||||
cursor.seek(4); // Object pointer placeholder.
|
||||
const motion_offset = cursor.u32();
|
||||
cursor.seek_start(motion_offset);
|
||||
const motion = parse_motion(cursor);
|
||||
|
||||
return {
|
||||
object_offset,
|
||||
motion
|
||||
};
|
||||
return parse_motion(cursor, bone_count);
|
||||
}
|
||||
|
||||
function parse_motion(cursor: BufferCursor): NjMotion {
|
||||
const motion_offset = cursor.position;
|
||||
// Points to an array the size of the total amount of objects in the object tree.
|
||||
function parse_motion(cursor: BufferCursor, bone_count: number): NjMotion {
|
||||
// Points to an array the size of bone_count.
|
||||
let mdata_offset = cursor.u32();
|
||||
const frame_count = cursor.u32();
|
||||
const type = cursor.u16();
|
||||
@ -100,8 +106,7 @@ function parse_motion(cursor: BufferCursor): NjMotion {
|
||||
const element_count = inp_fn & 0b1111;
|
||||
const motion_data_list = [];
|
||||
|
||||
// The mdata array stops where the motion structure starts.
|
||||
while (mdata_offset < motion_offset) {
|
||||
for (let i = 0; i < bone_count; i++) {
|
||||
cursor.seek_start(mdata_offset);
|
||||
mdata_offset = mdata_offset += 8 * element_count;
|
||||
|
||||
@ -113,11 +118,11 @@ function parse_motion(cursor: BufferCursor): NjMotion {
|
||||
const keyframe_offsets: number[] = [];
|
||||
const keyframe_counts: number[] = [];
|
||||
|
||||
for (let i = 0; i < element_count; i++) {
|
||||
for (let j = 0; j < element_count; j++) {
|
||||
keyframe_offsets.push(cursor.u32());
|
||||
}
|
||||
|
||||
for (let i = 0; i < element_count; i++) {
|
||||
for (let j = 0; j < element_count; j++) {
|
||||
const count = cursor.u32();
|
||||
keyframe_counts.push(count);
|
||||
}
|
||||
@ -143,7 +148,7 @@ function parse_motion(cursor: BufferCursor): NjMotion {
|
||||
if (count) {
|
||||
motion_data.tracks.push({
|
||||
type: NjKeyframeTrackType.Rotation,
|
||||
keyframes: parse_motion_data_a(cursor, count)
|
||||
keyframes: parse_motion_data_a(cursor, count, frame_count)
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -202,10 +207,15 @@ function parse_motion_data_f(cursor: BufferCursor, count: number): NjKeyframeF[]
|
||||
return frames;
|
||||
}
|
||||
|
||||
function parse_motion_data_a(cursor: BufferCursor, count: number): NjKeyframeA[] {
|
||||
function parse_motion_data_a(
|
||||
cursor: BufferCursor,
|
||||
keyframe_count: number,
|
||||
frame_count: number
|
||||
): NjKeyframeA[] {
|
||||
const frames: NjKeyframeA[] = [];
|
||||
const start_pos = cursor.position;
|
||||
|
||||
for (let i = 0; i < count; ++i) {
|
||||
for (let i = 0; i < keyframe_count; ++i) {
|
||||
frames.push({
|
||||
frame: cursor.u16(),
|
||||
value: new Vec3(
|
||||
@ -216,5 +226,33 @@ function parse_motion_data_a(cursor: BufferCursor, count: number): NjKeyframeA[]
|
||||
});
|
||||
}
|
||||
|
||||
let prev = -1;
|
||||
|
||||
for (const { frame } of frames) {
|
||||
if (frame < prev || frame >= frame_count) {
|
||||
cursor.seek_start(start_pos);
|
||||
return parse_motion_data_a_wide(cursor, keyframe_count);
|
||||
}
|
||||
|
||||
prev = frame;
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
function parse_motion_data_a_wide(cursor: BufferCursor, keyframe_count: number): NjKeyframeA[] {
|
||||
const frames: NjKeyframeA[] = [];
|
||||
|
||||
for (let i = 0; i < keyframe_count; ++i) {
|
||||
frames.push({
|
||||
frame: cursor.u32(),
|
||||
value: new Vec3(
|
||||
cursor.u32() * ANGLE_TO_RAD,
|
||||
cursor.u32() * ANGLE_TO_RAD,
|
||||
cursor.u32() * ANGLE_TO_RAD
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { AnimationClip, Euler, InterpolateLinear, InterpolateSmooth, KeyframeTrack, Quaternion, QuaternionKeyframeTrack, VectorKeyframeTrack } from "three";
|
||||
import { NjAction, NjInterpolation, NjKeyframeTrackType } from "../data_formats/parsing/ninja/motion";
|
||||
import { NinjaObject, NinjaModel } from "../data_formats/parsing/ninja";
|
||||
import { NinjaModel, NinjaObject } from "../data_formats/parsing/ninja";
|
||||
import { NjInterpolation, NjKeyframeTrackType, NjMotion } from "../data_formats/parsing/ninja/motion";
|
||||
|
||||
export const PSO_FRAME_RATE = 30;
|
||||
|
||||
export function create_animation_clip(
|
||||
object: NinjaObject<NinjaModel>,
|
||||
action: NjAction
|
||||
motion: NjMotion
|
||||
): AnimationClip {
|
||||
const motion = action.motion;
|
||||
const interpolation = motion.interpolation === NjInterpolation.Spline
|
||||
? InterpolateSmooth
|
||||
: InterpolateLinear;
|
||||
@ -16,7 +15,7 @@ export function create_animation_clip(
|
||||
const tracks: KeyframeTrack[] = [];
|
||||
|
||||
motion.motion_data.forEach((motion_data, bone_id) => {
|
||||
const bone = object.find_bone(bone_id);
|
||||
const bone = object.get_bone(bone_id);
|
||||
|
||||
motion_data.tracks.forEach(({ type, keyframes }) => {
|
||||
const times: number[] = [];
|
||||
|
@ -3,7 +3,7 @@ import { action, observable } from "mobx";
|
||||
import { AnimationAction, AnimationClip, AnimationMixer, SkinnedMesh } from "three";
|
||||
import { BufferCursor } from "../data_formats/BufferCursor";
|
||||
import { NinjaModel, NinjaObject, parse_nj, parse_xj } from "../data_formats/parsing/ninja";
|
||||
import { parse_njm_4 } from "../data_formats/parsing/ninja/motion";
|
||||
import { parse_njm } from "../data_formats/parsing/ninja/motion";
|
||||
import { PlayerModel } from '../domain';
|
||||
import { create_animation_clip, PSO_FRAME_RATE } from "../rendering/animation";
|
||||
import { ninja_object_to_skinned_mesh } from "../rendering/models";
|
||||
@ -30,7 +30,8 @@ class ModelViewerStore {
|
||||
];
|
||||
|
||||
@observable.ref current_model?: NinjaObject<NinjaModel>;
|
||||
@observable.ref current_model_obj3d?: SkinnedMesh;
|
||||
@observable.ref current_bone_count: number = 0;
|
||||
@observable.ref current_obj3d?: SkinnedMesh;
|
||||
|
||||
@observable.ref animation?: {
|
||||
mixer: AnimationMixer,
|
||||
@ -64,6 +65,8 @@ class ModelViewerStore {
|
||||
load_model = async (model: PlayerModel) => {
|
||||
const object = await this.get_player_ninja_object(model);
|
||||
this.set_model(object);
|
||||
// Ignore the bones from the head parts.
|
||||
this.current_bone_count = 64;
|
||||
}
|
||||
|
||||
load_file = (file: File) => {
|
||||
@ -88,28 +91,22 @@ class ModelViewerStore {
|
||||
|
||||
private set_model = action('set_model',
|
||||
(model: NinjaObject<NinjaModel>, filename?: string) => {
|
||||
if (this.current_model_obj3d && this.animation) {
|
||||
if (this.current_obj3d && this.animation) {
|
||||
this.animation.mixer.stopAllAction();
|
||||
this.animation.mixer.uncacheRoot(this.current_model_obj3d);
|
||||
this.animation.mixer.uncacheRoot(this.current_obj3d);
|
||||
this.animation = undefined;
|
||||
}
|
||||
|
||||
if (this.current_model && filename && HEAD_PART_REGEX.test(filename)) {
|
||||
this.add_to_bone(this.current_model, model, 59);
|
||||
} else {
|
||||
this.current_model = model;
|
||||
this.current_bone_count = model.bone_count();
|
||||
}
|
||||
|
||||
const mesh = ninja_object_to_skinned_mesh(this.current_model);
|
||||
mesh.translateY(-mesh.geometry.boundingSphere.radius);
|
||||
this.current_model_obj3d = mesh;
|
||||
|
||||
if (this.animation) {
|
||||
this.animation.mixer = new AnimationMixer(mesh);
|
||||
this.animation.mixer.timeScale = this.animation_frame_rate / PSO_FRAME_RATE;
|
||||
this.animation.action = this.animation.mixer.clipAction(this.animation.clip);
|
||||
this.animation.action.paused = !this.animation_playing;
|
||||
this.animation.action.play();
|
||||
}
|
||||
this.current_obj3d = mesh;
|
||||
}
|
||||
)
|
||||
|
||||
@ -128,7 +125,10 @@ class ModelViewerStore {
|
||||
this.set_model(model, file.name);
|
||||
} else if (file.name.endsWith('.njm')) {
|
||||
if (this.current_model) {
|
||||
const njm = parse_njm_4(new BufferCursor(reader.result, true));
|
||||
const njm = parse_njm(
|
||||
new BufferCursor(reader.result, true),
|
||||
this.current_bone_count
|
||||
);
|
||||
this.add_animation(create_animation_clip(this.current_model, njm));
|
||||
}
|
||||
} else {
|
||||
@ -141,7 +141,7 @@ class ModelViewerStore {
|
||||
head_part: NinjaObject<NinjaModel>,
|
||||
bone_id: number
|
||||
) {
|
||||
const bone = object.find_bone(bone_id);
|
||||
const bone = object.get_bone(bone_id);
|
||||
|
||||
if (bone) {
|
||||
bone.evaluation_flags.hidden = false;
|
||||
@ -151,7 +151,7 @@ class ModelViewerStore {
|
||||
}
|
||||
|
||||
private add_animation = action('add_animation', (clip: AnimationClip) => {
|
||||
if (!this.current_model_obj3d) return;
|
||||
if (!this.current_obj3d) return;
|
||||
|
||||
let mixer: AnimationMixer;
|
||||
|
||||
@ -159,7 +159,7 @@ class ModelViewerStore {
|
||||
this.animation.mixer.stopAllAction();
|
||||
mixer = this.animation.mixer;
|
||||
} else {
|
||||
mixer = new AnimationMixer(this.current_model_obj3d)
|
||||
mixer = new AnimationMixer(this.current_obj3d)
|
||||
}
|
||||
|
||||
this.animation = {
|
||||
|
@ -23,7 +23,7 @@ export class ModelViewerComponent extends React.Component {
|
||||
<div className="mv-ModelViewerComponent-main">
|
||||
<ModelSelectionComponent />
|
||||
<RendererComponent
|
||||
model={model_viewer_store.current_model_obj3d}
|
||||
model={model_viewer_store.current_obj3d}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -81,15 +81,15 @@ class Toolbar extends React.Component {
|
||||
/ {model_viewer_store.animation_frame_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="group">
|
||||
<span>Show skeleton:</span>
|
||||
<Switch
|
||||
checked={model_viewer_store.show_skeleton}
|
||||
onChange={(value) => model_viewer_store.show_skeleton = value}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="group">
|
||||
<span>Show skeleton:</span>
|
||||
<Switch
|
||||
checked={model_viewer_store.show_skeleton}
|
||||
onChange={(value) => model_viewer_store.show_skeleton = value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user