Quest editor now has undo/redo.

This commit is contained in:
Daan Vanden Bosch 2019-07-18 15:39:23 +02:00
parent 7b7daa29ac
commit 8c21ea59c9
16 changed files with 474 additions and 182 deletions

View File

@ -16,6 +16,10 @@ export class Vec2 {
clone(): Vec2 {
return new Vec2(this.x, this.y);
}
equals(v: Vec2): boolean {
return this.x === v.x && this.y === v.y;
}
}
export class Vec3 {
@ -39,4 +43,8 @@ export class Vec3 {
clone(): Vec3 {
return new Vec3(this.x, this.y, this.z);
}
equals(v: Vec3): boolean {
return this.x === v.x && this.y === v.y && this.z === v.z;
}
}

View File

@ -1,8 +1,9 @@
import { runInAction } from "mobx";
import { autorun, runInAction } from "mobx";
import { Intersection, Mesh, MeshLambertMaterial, Plane, Raycaster, Vector2, Vector3 } from "three";
import { Vec3 } from "../data_formats/vector";
import { QuestEntity, QuestNpc, Section } from "../domain";
import { QuestEntity, QuestNpc, QuestObject, Section } from "../domain";
import { quest_editor_store } from "../stores/QuestEditorStore";
import { AreaUserData } from "./conversion/areas";
import {
EntityUserData,
NPC_COLOR,
@ -13,44 +14,84 @@ import {
OBJECT_SELECTED_COLOR,
} from "./conversion/entities";
import { QuestRenderer } from "./QuestRenderer";
import { AreaUserData } from "./conversion/areas";
type Selection = {
entity: QuestEntity;
mesh: Mesh;
};
type Pick = {
object: Mesh;
entity: QuestEntity;
initial_position: Vec3;
grab_offset: Vector3;
drag_adjust: Vector3;
drag_y: number;
};
type PickResult = Pick & {
entity: QuestEntity;
mesh: Mesh;
};
enum ColorType {
Normal,
Highlighted,
Selected,
}
export class EntityControls {
export class QuestEntityControls {
private raycaster = new Raycaster();
private selected?: Pick;
private highlighted?: Pick;
private transforming = false;
private selected?: Selection;
private highlighted?: Selection;
/**
* Iff defined, the user is transforming the selected entity.
*/
private pick?: Pick;
private last_pointer_position = new Vector2(0, 0);
private moved_since_last_mouse_down = false;
constructor(private renderer: QuestRenderer) {}
constructor(private renderer: QuestRenderer) {
autorun(() => {
const entity = quest_editor_store.selected_entity;
if (!this.selected || this.selected.entity !== entity) {
this.stop_transforming();
if (entity) {
// Mesh might not be loaded yet.
this.try_highlight_selected();
} else {
this.deselect();
}
}
});
}
/**
* Highlights the selected entity if its mesh has been loaded.
*/
try_highlight_selected = () => {
const entity = quest_editor_store.selected_entity!;
const mesh = this.renderer.get_entity_mesh(entity);
if (mesh) {
this.select({ entity, mesh });
}
};
on_mouse_down = (e: MouseEvent) => {
this.process_event(e);
this.stop_transforming();
const new_pick = this.pick_entity(this.renderer.pointer_pos_to_device_coords(e));
if (new_pick) {
this.transforming = new_pick != null;
// Disable camera controls while the user is transforming an entity.
this.renderer.controls.enabled = !this.transforming;
this.renderer.controls.enabled = false;
this.pick = new_pick;
this.select(new_pick);
quest_editor_store.set_selected_entity(new_pick.entity);
} else {
this.renderer.controls.enabled = true;
this.pick = undefined;
}
this.renderer.schedule_render();
@ -59,15 +100,14 @@ export class EntityControls {
on_mouse_up = (e: MouseEvent) => {
this.process_event(e);
// If the user clicks on nothing, deselect the currently selected entity.
if (!this.moved_since_last_mouse_down && !this.transforming) {
if (!this.moved_since_last_mouse_down && !this.pick) {
// If the user clicks on nothing, deselect the currently selected entity.
this.deselect();
quest_editor_store.set_selected_entity(undefined);
}
this.transforming = false;
this.stop_transforming();
// Enable camera controls again after transforming an entity.
this.renderer.controls.enabled = !this.transforming;
this.renderer.controls.enabled = true;
this.renderer.schedule_render();
};
@ -77,15 +117,16 @@ export class EntityControls {
const pointer_device_pos = this.renderer.pointer_pos_to_device_coords(e);
if (this.selected && this.transforming) {
if (this.selected && this.pick) {
// User is tranforming selected entity.
if (e.buttons === 1) {
// User is dragging a selected entity.
// User is dragging selected entity.
if (e.shiftKey) {
// Vertical movement.
this.translate_vertically(this.selected, pointer_device_pos);
this.translate_vertically(this.selected, this.pick, pointer_device_pos);
} else {
// Horizontal movement accross terrain.
this.translate_horizontally(this.selected, pointer_device_pos);
this.translate_horizontally(this.selected, this.pick, pointer_device_pos);
}
}
@ -118,45 +159,46 @@ export class EntityControls {
/**
* @returns true if a render is required.
*/
private highlight(pick?: Pick): boolean {
private highlight(selection?: Selection): boolean {
let render_required = false;
if (!this.selected || !picks_equal(pick, this.selected)) {
if (!picks_equal(pick, this.highlighted)) {
this.unhighlight();
if (!this.selected || !selection_equals(selection, this.selected)) {
if (!selection_equals(selection, this.highlighted)) {
if (this.highlighted) {
set_color(this.highlighted, ColorType.Normal);
this.highlighted = undefined;
}
if (pick) {
set_color(pick, ColorType.Highlighted);
if (selection) {
set_color(selection, ColorType.Highlighted);
}
render_required = true;
}
this.highlighted = pick;
this.highlighted = selection;
}
return render_required;
}
private unhighlight(): void {
if (this.highlighted) {
set_color(this.highlighted, ColorType.Normal);
private select(selection: Selection): void {
if (selection_equals(selection, this.highlighted)) {
this.highlighted = undefined;
}
}
private select(pick: Pick): void {
this.unhighlight();
if (!picks_equal(pick, this.selected)) {
if (!selection_equals(selection, this.selected)) {
if (this.selected) {
set_color(this.selected, ColorType.Normal);
}
set_color(pick, ColorType.Selected);
}
set_color(selection, ColorType.Selected);
this.selected = pick;
this.selected = selection;
quest_editor_store.set_selected_entity(selection.entity);
} else {
this.selected = selection;
}
}
private deselect(): void {
@ -165,9 +207,14 @@ export class EntityControls {
}
this.selected = undefined;
quest_editor_store.set_selected_entity(undefined);
}
private translate_vertically(pick: Pick, pointer_position: Vector2): void {
private translate_vertically(
selection: Selection,
pick: Pick,
pointer_position: Vector2
): void {
// We intersect with a plane that's oriented toward the camera and that's coplanar with the point where the entity was grabbed.
this.raycaster.setFromCamera(pointer_position, this.renderer.camera);
const ray = this.raycaster.ray;
@ -175,32 +222,40 @@ export class EntityControls {
const negative_world_dir = this.renderer.camera.getWorldDirection(new Vector3()).negate();
const plane = new Plane().setFromNormalAndCoplanarPoint(
new Vector3(negative_world_dir.x, 0, negative_world_dir.z).normalize(),
pick.object.position.sub(pick.grab_offset)
selection.mesh.position.sub(pick.grab_offset)
);
const intersection_point = new Vector3();
if (ray.intersectPlane(plane, intersection_point)) {
const y = intersection_point.y + pick.grab_offset.y;
const y_delta = y - pick.entity.position.y;
const y_delta = y - selection.entity.position.y;
pick.drag_y += y_delta;
pick.drag_adjust.y -= y_delta;
pick.entity.position = new Vec3(pick.entity.position.x, y, pick.entity.position.z);
selection.entity.position = new Vec3(
selection.entity.position.x,
y,
selection.entity.position.z
);
}
}
private translate_horizontally(pick: Pick, pointer_position: Vector2): void {
private translate_horizontally(
selection: Selection,
pick: Pick,
pointer_position: Vector2
): void {
// Cast ray adjusted for dragging entities.
const { intersection, section } = this.pick_terrain(pointer_position, pick);
if (intersection) {
runInAction(() => {
pick.entity.position = new Vec3(
selection.entity.position = new Vec3(
intersection.point.x,
intersection.point.y + pick.drag_y,
intersection.point.z
);
pick.entity.section = section;
selection.entity.section = section;
});
} else {
// If the cursor is not over any terrain, we translate the entity accross the horizontal plane in which the entity's origin lies.
@ -209,39 +264,58 @@ export class EntityControls {
// ray.origin.add(data.dragAdjust);
const plane = new Plane(
new Vector3(0, 1, 0),
-pick.entity.position.y + pick.grab_offset.y
-selection.entity.position.y + pick.grab_offset.y
);
const intersection_point = new Vector3();
if (ray.intersectPlane(plane, intersection_point)) {
pick.entity.position = new Vec3(
selection.entity.position = new Vec3(
intersection_point.x + pick.grab_offset.x,
pick.entity.position.y,
selection.entity.position.y,
intersection_point.z + pick.grab_offset.z
);
}
}
}
private stop_transforming = () => {
if (this.moved_since_last_mouse_down && this.selected && this.pick) {
const entity = this.selected.entity;
const initial_position = this.pick.initial_position;
const new_position = entity.position;
const entity_type =
entity instanceof QuestNpc ? entity.type.name : (entity as QuestObject).type.name;
quest_editor_store.undo_stack.push_action(
`Move ${entity_type}`,
() => {
entity.position = initial_position;
quest_editor_store.set_selected_entity(entity);
},
() => {
entity.position = new_position;
quest_editor_store.set_selected_entity(entity);
}
);
}
this.pick = undefined;
};
/**
* @param pointer_position pointer coordinates in normalized device space
*/
private pick_entity(pointer_position: Vector2): Pick | undefined {
private pick_entity(pointer_position: Vector2): PickResult | undefined {
// Find the nearest object and NPC under the pointer.
this.raycaster.setFromCamera(pointer_position, this.renderer.camera);
const [nearest_object] = this.raycaster.intersectObjects(
this.renderer.obj_geometry.children
const [intersection] = this.raycaster.intersectObjects(
this.renderer.entity_models.children
);
const [nearest_npc] = this.raycaster.intersectObjects(this.renderer.npc_geometry.children);
if (!nearest_object && !nearest_npc) {
if (!intersection) {
return undefined;
}
const object_dist = nearest_object ? nearest_object.distance : Infinity;
const npc_dist = nearest_npc ? nearest_npc.distance : Infinity;
const intersection = object_dist < npc_dist ? nearest_object : nearest_npc;
const entity = (intersection.object.userData as EntityUserData).entity;
// Vector that points from the grabbing point to the model's origin.
const grab_offset = intersection.object.position.clone().sub(intersection.point);
@ -263,8 +337,9 @@ export class EntityControls {
}
return {
object: intersection.object as Mesh,
mesh: intersection.object as Mesh,
entity,
initial_position: entity.position,
grab_offset,
drag_adjust,
drag_y,
@ -312,19 +387,21 @@ export class EntityControls {
}
}
function set_color(pick: Pick, type: ColorType): void {
const color = get_color(pick.entity, type);
function set_color({ entity, mesh }: Selection, type: ColorType): void {
const color = get_color(entity, type);
for (const material of pick.object.material as MeshLambertMaterial[]) {
if (type === ColorType.Normal && material.map) {
material.color.set(0xffffff);
} else {
material.color.set(color);
if (mesh) {
for (const material of mesh.material as MeshLambertMaterial[]) {
if (type === ColorType.Normal && material.map) {
material.color.set(0xffffff);
} else {
material.color.set(color);
}
}
}
}
function picks_equal(a?: Pick, b?: Pick): boolean {
function selection_equals(a?: Selection, b?: Selection): boolean {
return a && b ? a.entity === b.entity : a === b;
}

View File

@ -1,16 +1,16 @@
import { QuestRenderer } from "./QuestRenderer";
import { Quest, Area, QuestEntity } from "../domain";
import { IReactionDisposer, autorun } from "mobx";
import { Object3D, Group, Vector3 } from "three";
import Logger from "js-logger";
import { autorun, IReactionDisposer } from "mobx";
import { Mesh, Object3D, Vector3 } from "three";
import { Area, Quest, QuestEntity } from "../domain";
import { load_area_collision_geometry, load_area_render_geometry } from "../loading/areas";
import {
load_object_geometry,
load_object_tex as load_object_textures,
load_npc_geometry,
load_npc_tex as load_npc_textures,
load_object_geometry,
load_object_tex as load_object_textures,
} from "../loading/entities";
import { create_object_mesh, create_npc_mesh } from "./conversion/entities";
import Logger from "js-logger";
import { create_npc_mesh, create_object_mesh } from "./conversion/entities";
import { QuestRenderer } from "./QuestRenderer";
const logger = Logger.get("rendering/QuestModelManager");
@ -63,10 +63,7 @@ export class QuestModelManager {
this.renderer.reset_camera(CAMERA_POSITION, CAMERA_LOOKAT);
// Load entity models.
const npc_group = new Group();
const obj_group = new Group();
this.renderer.npc_geometry = npc_group;
this.renderer.obj_geometry = obj_group;
this.renderer.reset_entity_models();
for (const npc of quest.npcs) {
if (npc.area_id === area.id) {
@ -76,7 +73,7 @@ export class QuestModelManager {
if (this.quest !== quest || this.area !== area) return;
const model = create_npc_mesh(npc, npc_geom, npc_tex);
this.update_entity_geometry(npc, npc_group, model);
this.update_entity_geometry(npc, model);
}
}
@ -88,26 +85,24 @@ export class QuestModelManager {
if (this.quest !== quest || this.area !== area) return;
const model = create_object_mesh(object, object_geom, object_tex);
this.update_entity_geometry(object, obj_group, model);
this.update_entity_geometry(object, model);
}
}
} catch (e) {
logger.error(`Couldn't load models for quest ${quest.id}, ${area.name}.`, e);
this.renderer.collision_geometry = DUMMY_OBJECT;
this.renderer.render_geometry = DUMMY_OBJECT;
this.renderer.obj_geometry = DUMMY_OBJECT;
this.renderer.npc_geometry = DUMMY_OBJECT;
this.renderer.reset_entity_models();
}
} else {
this.renderer.collision_geometry = DUMMY_OBJECT;
this.renderer.render_geometry = DUMMY_OBJECT;
this.renderer.obj_geometry = DUMMY_OBJECT;
this.renderer.npc_geometry = DUMMY_OBJECT;
this.renderer.reset_entity_models();
}
}
private update_entity_geometry(entity: QuestEntity, group: Group, model: Object3D): void {
group.add(model);
private update_entity_geometry(entity: QuestEntity, model: Mesh): void {
this.renderer.add_entity_model(model);
this.entity_reaction_disposers.push(
autorun(() => {

View File

@ -1,9 +1,11 @@
import { autorun } from "mobx";
import { Object3D, PerspectiveCamera } from "three";
import { Mesh, Object3D, PerspectiveCamera, Group } from "three";
import { QuestEntity } from "../domain";
import { quest_editor_store } from "../stores/QuestEditorStore";
import { EntityControls } from "./EntityControls";
import { QuestEntityControls } from "./QuestEntityControls";
import { QuestModelManager } from "./QuestModelManager";
import { Renderer } from "./Renderer";
import { EntityUserData } from "./conversion/entities";
let renderer: QuestRenderer | undefined;
@ -37,29 +39,14 @@ export class QuestRenderer extends Renderer<PerspectiveCamera> {
// this.scene.add(render_geometry);
}
private _obj_geometry = new Object3D();
private _entity_models = new Object3D();
get obj_geometry(): Object3D {
return this._obj_geometry;
get entity_models(): Object3D {
return this._entity_models;
}
set obj_geometry(obj_geometry: Object3D) {
this.scene.remove(this._obj_geometry);
this._obj_geometry = obj_geometry;
this.scene.add(obj_geometry);
}
private _npc_geometry = new Object3D();
get npc_geometry(): Object3D {
return this._npc_geometry;
}
set npc_geometry(npc_geometry: Object3D) {
this.scene.remove(this._npc_geometry);
this._npc_geometry = npc_geometry;
this.scene.add(npc_geometry);
}
private entity_to_mesh = new Map<QuestEntity, Mesh>();
private entity_controls: QuestEntityControls;
constructor() {
super(new PerspectiveCamera(60, 1, 10, 10000));
@ -73,11 +60,11 @@ export class QuestRenderer extends Renderer<PerspectiveCamera> {
);
});
const entity_controls = new EntityControls(this);
this.entity_controls = new QuestEntityControls(this);
this.dom_element.addEventListener("mousedown", entity_controls.on_mouse_down);
this.dom_element.addEventListener("mouseup", entity_controls.on_mouse_up);
this.dom_element.addEventListener("mousemove", entity_controls.on_mouse_move);
this.dom_element.addEventListener("mousedown", this.entity_controls.on_mouse_down);
this.dom_element.addEventListener("mouseup", this.entity_controls.on_mouse_up);
this.dom_element.addEventListener("mousemove", this.entity_controls.on_mouse_move);
}
set_size(width: number, height: number): void {
@ -85,4 +72,25 @@ export class QuestRenderer extends Renderer<PerspectiveCamera> {
this.camera.updateProjectionMatrix();
super.set_size(width, height);
}
reset_entity_models(): void {
this.scene.remove(this._entity_models);
this._entity_models = new Group();
this.scene.add(this._entity_models);
this.entity_to_mesh.clear();
}
add_entity_model(model: Mesh): void {
const entity = (model.userData as EntityUserData).entity;
this._entity_models.add(model);
this.entity_to_mesh.set(entity, model);
if (entity === quest_editor_store.selected_entity) {
this.entity_controls.try_highlight_selected();
}
}
get_entity_mesh(entity: QuestEntity): Mesh | undefined {
return this.entity_to_mesh.get(entity);
}
}

View File

@ -3,6 +3,29 @@ import { Server } from "../domain";
class ApplicationStore {
@observable current_server: Server = Server.Ephinea;
@observable current_tool: string = this.init_tool();
private key_event_handlers = new Map<string, (e: KeyboardEvent) => void>();
on_global_keyup = (tool: string, handler: (e: KeyboardEvent) => void) => {
this.key_event_handlers.set(tool, handler);
};
dispatch_global_keyup = (e: KeyboardEvent) => {
const handler = this.key_event_handlers.get(this.current_tool);
if (handler) {
handler(e);
}
};
private init_tool(): string {
const param = window.location.search
.slice(1)
.split("&")
.find(p => p.startsWith("tool="));
return param ? param.slice(5) : "viewer";
}
}
export const application_store = new ApplicationStore();

View File

@ -1,4 +1,4 @@
import { Area, AreaVariant, Section } from "../domain";
import { Area, AreaVariant, Section, Episode } from "../domain";
import { load_area_sections } from "../loading/areas";
function area(id: number, name: string, order: number, variants: number): Area {
@ -11,12 +11,12 @@ function area(id: number, name: string, order: number, variants: number): Area {
}
class AreaStore {
readonly areas: Area[][] = [];
private areas: Area[][] = [];
constructor() {
// The IDs match the PSO IDs for areas.
let order = 0;
this.areas[1] = [
this.areas[Episode.I] = [
area(0, "Pioneer II", order++, 1),
area(1, "Forest 1", order++, 1),
area(2, "Forest 2", order++, 1),
@ -37,7 +37,7 @@ class AreaStore {
area(17, "Lobby", order++, 15),
];
order = 0;
this.areas[2] = [
this.areas[Episode.II] = [
area(0, "Lab", order++, 1),
area(1, "VR Temple Alpha", order++, 3),
area(2, "VR Temple Beta", order++, 3),
@ -58,7 +58,7 @@ class AreaStore {
area(17, "Control Tower", order++, 5),
];
order = 0;
this.areas[4] = [
this.areas[Episode.IV] = [
area(0, "Pioneer II (Ep. IV)", order++, 1),
area(1, "Crater Route 1", order++, 1),
area(2, "Crater Route 2", order++, 1),
@ -72,12 +72,14 @@ class AreaStore {
];
}
get_variant = (episode: number, area_id: number, variant_id: number): AreaVariant => {
if (episode !== 1 && episode !== 2 && episode !== 4)
throw new Error(`Expected episode to be 1, 2 or 4, got ${episode}.`);
get_area = (episode: Episode, area_id: number): Area => {
const area = this.areas[episode].find(a => a.id === area_id);
if (!area) throw new Error(`Area id ${area_id} for episode ${episode} is invalid.`);
return area;
};
get_variant = (episode: Episode, area_id: number, variant_id: number): AreaVariant => {
const area = this.get_area(episode, area_id);
const area_variant = area.area_variants[variant_id];
if (!area_variant)
@ -89,7 +91,7 @@ class AreaStore {
};
get_area_sections = (
episode: number,
episode: Episode,
area_id: number,
variant_id: number
): Promise<Section[]> => {

View File

@ -31,6 +31,7 @@ const logger = Logger.get("stores/ModelViewerStore");
const nj_object_cache: Map<string, Promise<NjObject<NjModel>>> = new Map();
const nj_motion_cache: Map<number, Promise<NjMotion>> = new Map();
// TODO: move all Three.js stuff into the renderer.
class ModelViewerStore {
readonly models: PlayerModel[] = [
new PlayerModel("HUmar", 1, 10, new Set([6])),

View File

@ -7,34 +7,40 @@ import { Vec3 } from "../data_formats/vector";
import { Area, Quest, QuestEntity, Section } from "../domain";
import { read_file } from "../read_file";
import { area_store } from "./AreaStore";
import { UndoStack } from "../undo";
const logger = Logger.get("stores/QuestEditorStore");
class QuestEditorStore {
readonly undo_stack = new UndoStack();
@observable current_quest?: Quest;
@observable current_area?: Area;
@observable selected_entity?: QuestEntity;
set_quest = action("set_quest", (quest?: Quest) => {
this.reset_quest_state();
@action
set_quest = (quest?: Quest) => {
this.undo_stack.clear();
this.selected_entity = undefined;
this.current_quest = quest;
if (quest && quest.area_variants.length) {
this.current_area = quest.area_variants[0].area;
} else {
this.current_area = undefined;
}
});
private reset_quest_state(): void {
this.current_quest = undefined;
this.current_area = undefined;
this.selected_entity = undefined;
}
};
@action
set_selected_entity = (entity?: QuestEntity) => {
if (entity) {
this.set_current_area_id(entity.area_id);
}
this.selected_entity = entity;
};
set_current_area_id = action("set_current_area_id", (area_id?: number) => {
@action
set_current_area_id = (area_id?: number) => {
this.selected_entity = undefined;
if (area_id == null) {
@ -45,7 +51,7 @@ class QuestEditorStore {
);
this.current_area = area_variant && area_variant.area;
}
});
};
// TODO: notify user of problems.
load_file = async (file: File) => {
@ -64,7 +70,6 @@ class QuestEditorStore {
);
variant.sections = sections;
// Generate object geometry.
for (const object of quest.objects.filter(o => o.area_id === variant.area.id)) {
try {
this.set_section_on_visible_quest_entity(object, sections);
@ -73,7 +78,6 @@ class QuestEditorStore {
}
}
// Generate NPC geometry.
for (const npc of quest.npcs.filter(npc => npc.area_id === variant.area.id)) {
try {
this.set_section_on_visible_quest_entity(npc, sections);

View File

@ -1,7 +1,7 @@
import { Menu, Select } from "antd";
import { ClickParam } from "antd/lib/menu";
import { observer } from "mobx-react";
import React, { ReactNode } from "react";
import React, { ReactNode, Component } from "react";
import { Server } from "../domain";
import "./ApplicationComponent.less";
import { DpsCalcComponent } from "./dps_calc/DpsCalcComponent";
@ -9,6 +9,7 @@ import { with_error_boundary } from "./ErrorBoundary";
import { HuntOptimizerComponent } from "./hunt_optimizer/HuntOptimizerComponent";
import { QuestEditorComponent } from "./quest_editor/QuestEditorComponent";
import { ViewerComponent } from "./viewer/ViewerComponent";
import { application_store } from "../stores/ApplicationStore";
const Viewer = with_error_boundary(ViewerComponent);
const QuestEditor = with_error_boundary(QuestEditorComponent);
@ -16,13 +17,19 @@ const HuntOptimizer = with_error_boundary(HuntOptimizerComponent);
const DpsCalc = with_error_boundary(DpsCalcComponent);
@observer
export class ApplicationComponent extends React.Component {
state = { tool: this.init_tool() };
export class ApplicationComponent extends Component {
componentDidMount(): void {
window.addEventListener("keyup", this.keyup);
}
componentWillUnmount(): void {
window.removeEventListener("keyup", this.keyup);
}
render(): ReactNode {
let tool_component;
switch (this.state.tool) {
switch (application_store.current_tool) {
case "viewer":
tool_component = <Viewer />;
break;
@ -44,7 +51,7 @@ export class ApplicationComponent extends React.Component {
<Menu
className="ApplicationComponent-heading-menu"
onClick={this.menu_clicked}
selectedKeys={[this.state.tool]}
selectedKeys={[application_store.current_tool]}
mode="horizontal"
>
<Menu.Item key="viewer">
@ -71,14 +78,10 @@ export class ApplicationComponent extends React.Component {
}
private menu_clicked = (e: ClickParam) => {
this.setState({ tool: e.key });
application_store.current_tool = e.key;
};
private init_tool(): string {
const param = window.location.search
.slice(1)
.split("&")
.find(p => p.startsWith("tool="));
return param ? param.slice(5) : "viewer";
}
private keyup = (e: KeyboardEvent) => {
application_store.dispatch_global_keyup(e);
};
}

View File

@ -28,10 +28,10 @@ export class ErrorBoundary extends Component<{}, State> {
}
}
export function with_error_boundary(Component: ComponentType): ComponentType {
const ComponentErrorBoundary = (): JSX.Element => (
export function with_error_boundary<P>(Component: ComponentType<P>): ComponentType<P> {
const ComponentErrorBoundary = (props: P): JSX.Element => (
<ErrorBoundary>
<Component />
<Component {...props} />
</ErrorBoundary>
);
ComponentErrorBoundary.displayName = `${Component.displayName}ErrorBoundary`;

View File

@ -1,7 +1,7 @@
import { InputNumber } from "antd";
import { observer } from "mobx-react";
import React, { ReactNode, Component } from "react";
import { WeaponItemType, ArmorItemType, ShieldItemType } from "../../domain";
import React, { Component, ReactNode } from "react";
import { ArmorItemType, ShieldItemType, WeaponItemType } from "../../domain";
import { dps_calc_store } from "../../stores/DpsCalcStore";
import { item_type_stores } from "../../stores/ItemTypeStore";
import { BigSelect } from "../BigSelect";

View File

@ -1,8 +1,8 @@
import { Tabs } from "antd";
import React from "react";
import "./HuntOptimizerComponent.css";
import { OptimizerComponent } from "./OptimizerComponent";
import { MethodsComponent } from "./MethodsComponent";
import { OptimizerComponent } from "./OptimizerComponent";
const TabPane = Tabs.TabPane;

View File

@ -9,6 +9,7 @@ import "./QuestEditorComponent.css";
import { QuestInfoComponent } from "./QuestInfoComponent";
import { RendererComponent } from "../RendererComponent";
import { get_quest_renderer } from "../../rendering/QuestRenderer";
import { application_store } from "../../stores/ApplicationStore";
@observer
export class QuestEditorComponent extends Component<
@ -24,12 +25,16 @@ export class QuestEditorComponent extends Component<
save_dialog_filename: "Untitled",
};
componentDidMount(): void {
application_store.on_global_keyup("quest_editor", this.keyup);
}
render(): ReactNode {
const quest = quest_editor_store.current_quest;
return (
<div className="qe-QuestEditorComponent">
<Toolbar onSaveAsClicked={this.save_as_clicked} />
<Toolbar on_save_as_clicked={this.save_as_clicked} />
<div className="qe-QuestEditorComponent-main">
<QuestInfoComponent quest={quest} />
<RendererComponent renderer={get_quest_renderer()} />
@ -71,17 +76,26 @@ export class QuestEditorComponent extends Component<
private save_dialog_cancelled = () => {
this.setState({ save_dialog_open: false });
};
private keyup = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === "z" && !e.altKey) {
quest_editor_store.undo_stack.undo();
} else if (e.ctrlKey && e.key === "Z" && !e.altKey) {
quest_editor_store.undo_stack.redo();
}
};
}
@observer
class Toolbar extends Component<{ onSaveAsClicked: (filename?: string) => void }> {
class Toolbar extends Component<{ on_save_as_clicked: (filename?: string) => void }> {
state = {
filename: undefined,
};
render(): ReactNode {
const undo = quest_editor_store.undo_stack;
const quest = quest_editor_store.current_quest;
const areas = quest && Array.from(quest.area_variants).map(a => a.area);
const areas = quest ? Array.from(quest.area_variants).map(a => a.area) : [];
const area = quest_editor_store.current_area;
const area_id = area && area.id;
@ -96,24 +110,33 @@ class Toolbar extends Component<{ onSaveAsClicked: (filename?: string) => void }
>
<Button icon="file">{this.state.filename || "Open file..."}</Button>
</Upload>
{areas && (
<Select
onChange={quest_editor_store.set_current_area_id}
value={area_id}
style={{ width: 200 }}
>
{areas.map(area => (
<Select.Option key={area.id} value={area.id}>
{area.name}
</Select.Option>
))}
</Select>
)}
{quest && (
<Button icon="save" onClick={this.save_as_clicked}>
Save as...
</Button>
)}
<Select
onChange={quest_editor_store.set_current_area_id}
value={area_id}
style={{ width: 200 }}
disabled={!quest}
>
{areas.map(area => (
<Select.Option key={area.id} value={area.id}>
{area.name}
</Select.Option>
))}
</Select>
<Button icon="save" onClick={this.save_as} disabled={!quest}>
Save as...
</Button>
<Button
icon="undo"
onClick={this.undo}
title={"Undo" + (undo.first_undo ? ` "${undo.first_undo.description}"` : "")}
disabled={!undo.can_undo}
/>
<Button
icon="redo"
onClick={this.redo}
title={"Redo" + (undo.first_redo ? ` "${undo.first_redo.description}"` : "")}
disabled={!quest_editor_store.undo_stack.can_redo}
/>
</div>
);
}
@ -125,8 +148,16 @@ class Toolbar extends Component<{ onSaveAsClicked: (filename?: string) => void }
}
};
private save_as_clicked = () => {
this.props.onSaveAsClicked(this.state.filename);
private save_as = () => {
this.props.on_save_as_clicked(this.state.filename);
};
private undo = () => {
quest_editor_store.undo_stack.undo();
};
private redo = () => {
quest_editor_store.undo_stack.redo();
};
}

View File

@ -1,8 +1,8 @@
import React, { Component, ReactNode } from "react";
import { Tabs } from "antd";
import React, { Component, ReactNode } from "react";
import { ModelViewerComponent } from "./models/ModelViewerComponent";
import "./ViewerComponent.less";
import { TextureViewerComponent } from "./textures/TextureViewerComponent";
import "./ViewerComponent.less";
export class ViewerComponent extends Component {
render(): ReactNode {

69
src/undo.test.ts Normal file
View File

@ -0,0 +1,69 @@
import { UndoStack, Action } from "./undo";
test("simple properties and invariants", () => {
const stack = new UndoStack();
expect(stack.can_undo).toBe(false);
expect(stack.can_redo).toBe(false);
stack.push(new Action("", () => {}, () => {}));
stack.push(new Action("", () => {}, () => {}));
stack.push(new Action("", () => {}, () => {}));
expect(stack.can_undo).toBe(true);
expect(stack.can_redo).toBe(false);
stack.undo();
expect(stack.can_undo).toBe(true);
expect(stack.can_redo).toBe(true);
stack.undo();
stack.undo();
expect(stack.can_undo).toBe(false);
expect(stack.can_redo).toBe(true);
});
test("undo", () => {
const stack = new UndoStack();
// Pretend value started and 3 and we've set it to 7 and then 13.
let value = 13;
stack.push(new Action("X", () => (value = 3), () => (value = 7)));
stack.push(new Action("Y", () => (value = 7), () => (value = 13)));
expect(stack.undo()).toBe(true);
expect(value).toBe(7);
expect(stack.undo()).toBe(true);
expect(value).toBe(3);
expect(stack.undo()).toBe(false);
expect(value).toBe(3);
});
test("redo", () => {
const stack = new UndoStack();
// Pretend value started and 3 and we've set it to 7 and then 13.
let value = 13;
stack.push(new Action("X", () => (value = 3), () => (value = 7)));
stack.push(new Action("Y", () => (value = 7), () => (value = 13)));
stack.undo();
stack.undo();
expect(value).toBe(3);
expect(stack.redo()).toBe(true);
expect(value).toBe(7);
expect(stack.redo()).toBe(true);
expect(value).toBe(13);
expect(stack.redo()).toBe(false);
expect(value).toBe(13);
});

71
src/undo.ts Normal file
View File

@ -0,0 +1,71 @@
import { computed, observable } from "mobx";
export class Action {
constructor(
readonly description: string,
readonly undo: () => void,
readonly redo: () => void
) {}
}
export class UndoStack {
@observable.ref private stack: Action[] = [];
/**
* The index where new actions are inserted.
*/
@observable private index = 0;
@computed get can_undo(): boolean {
return this.index > 0;
}
@computed get can_redo(): boolean {
return this.index < this.stack.length;
}
/**
* The first action that will be undone when calling undo().
*/
@computed get first_undo(): Action | undefined {
return this.stack[this.index - 1];
}
/**
* The first action that will be redone when calling redo().
*/
@computed get first_redo(): Action | undefined {
return this.stack[this.index];
}
push_action(description: string, undo: () => void, redo: () => void): void {
this.push(new Action(description, undo, redo));
}
push(action: Action): void {
this.stack.splice(this.index, this.stack.length - this.index, action);
this.index++;
}
undo(): boolean {
if (this.can_undo) {
this.stack[--this.index].undo();
return true;
} else {
return false;
}
}
redo(): boolean {
if (this.can_redo) {
this.stack[this.index++].redo();
return true;
} else {
return false;
}
}
clear(): void {
this.stack = [];
this.index = 0;
}
}