Compare commits

..

3 Commits

Author SHA1 Message Date
c713d14491 assign unique node names to each instance; rely on FF_Vector for maths;
properly unbind before deletion (avoid error messages when developping)
2026-07-08 11:28:12 +02:00
21a7a4a48d Create README 2026-07-08 02:08:45 +02:00
33722a6250 rename helpfile 2026-07-08 02:03:21 +02:00
4 changed files with 67 additions and 98 deletions

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
# tc.fpcontrol
Easy first person control for camera and objects in "view space" with gamepad or keyboard in Max.
This is a small Max package consisting of two objects:
- [gamepad.drive] a simple abstraction that takes the output of a [gamepad] and converts it to move and turn messages in a typical jit.anim fashion. Left stick to move around, right stick to rotate, triggers to move up/down, featuring scaled radial deadzones for your quirky always-shifting Xbox sticks.
- [tc.fpcontrol]: a v8 object that:
- takes as input move and turn messages, like the ones from [gamepad.drive], but also [jit.anim.drive] (with a small trick), or any other custom source
- for controlling objects in camera space: if you move the left stick to the right, your object will go to the right of the screen, regardless of its orientation or the position of the camera. Like what you're generally used to in video games. You can always switch to a more classic object space mode where movements are applied relatively to the objects orientation.
- for controlling cameras, either at the same elevation (use triggers to move up/down), either in "flymode" (goes forward in the direction you're pointing to, without elevation lock)
- for controlling [jit.anim.node], either single ones, or within a hierarchy, allowing to control multiple objects at once.
- featuring a customizable bounds box indicating the target object/jit.anim.node
- gives you a list of all controllable objects in your patch. You can populate a menu from that and easily cycle through every object/anim.node to control them one by one.
## How to install
[Download](https://github.com/Teufeuleu/tc.fpcontrol/archive/refs/heads/main.zip) this repository, unzip the archive in your Max packages folder, and restart Max.

View File

@@ -9,49 +9,18 @@
"modernui": 1
},
"classnamespace": "box",
"rect": [ 400.0, 100.0, 967.0, 848.0 ],
"rect": [ 34.0, 100.0, 1444.0, 848.0 ],
"openrect": [ 0.0, 0.0, 1181.43359375, 950.1171875 ],
"openrectmode": 0,
"integercoordinates": 1,
"boxes": [
{
"box": {
"id": "obj-46",
"maxclass": "message",
"numinlets": 2,
"numoutlets": 1,
"outlettype": [ "" ],
"patching_rect": [ 160.0, 70.0, 48.0, 22.0 ],
"text": "fps 120"
}
},
{
"box": {
"id": "obj-25",
"maxclass": "message",
"numinlets": 2,
"numoutlets": 1,
"outlettype": [ "" ],
"patching_rect": [ 287.0, 86.0, 107.0, 22.0 ],
"text": "sendnode getanim"
}
},
{
"box": {
"id": "obj-22",
"maxclass": "message",
"numinlets": 2,
"numoutlets": 1,
"outlettype": [ "" ],
"patching_rect": [ 203.0, 78.0, 41.0, 22.0 ],
"text": "fps 30"
}
},
{
"box": {
"id": "obj-21",
"maxclass": "newobj",
"numinlets": 1,
"numoutlets": 0,
"patching_rect": [ 1408.0, 188.0, 88.0, 22.0 ],
"patching_rect": [ 935.0, 222.0, 88.0, 22.0 ],
"text": "s object_select"
}
},
@@ -83,7 +52,7 @@
"maxclass": "newobj",
"numinlets": 1,
"numoutlets": 0,
"patching_rect": [ 1312.0, 188.0, 93.0, 22.0 ],
"patching_rect": [ 839.0, 222.0, 93.0, 22.0 ],
"text": "s to_tc.fpcontrol"
}
},
@@ -893,7 +862,7 @@
}
]
},
"patching_rect": [ 1312.0, 155.0, 115.0, 22.0 ],
"patching_rect": [ 839.0, 189.0, 115.0, 22.0 ],
"text": "p gamepad_buttons"
}
},
@@ -1192,7 +1161,7 @@
"modernui": 1
},
"classnamespace": "box",
"rect": [ 573.0, 140.0, 866.0, 755.0 ],
"rect": [ 746.0, 143.0, 866.0, 755.0 ],
"integercoordinates": 1,
"boxes": [
{
@@ -1974,7 +1943,7 @@
"maxclass": "comment",
"numinlets": 1,
"numoutlets": 0,
"patching_rect": [ 36.0, 654.0, 257.0, 66.0 ],
"patching_rect": [ 61.0, 658.0, 257.0, 66.0 ],
"text": "'control' outputs the given object name to be controlled (jit.gl or jit.anim.node), followed by the actual one being controlled (jit.anim.node)"
}
},
@@ -2511,11 +2480,12 @@
"box": {
"color": [ 1.0, 0.612, 0.0, 1.0 ],
"id": "obj-189",
"linecount": 6,
"maxclass": "newobj",
"numinlets": 1,
"numoutlets": 2,
"outlettype": [ "", "" ],
"patching_rect": [ 720.0, 121.0, 611.0, 22.0 ],
"patching_rect": [ 720.0, 77.0, 138.0, 89.0 ],
"text": "gamepad.drive @l_deadzone 0.15 @r_deadzone 0.15 @l_sensitivity 0.4 @r_sensitivity 3 @trigger_sensitivity 0.4"
}
},
@@ -2579,12 +2549,14 @@
{
"box": {
"dontreplace": 1,
"fontface": 1,
"fontsize": 16.0,
"id": "obj-60",
"maxclass": "message",
"numinlets": 2,
"numoutlets": 1,
"outlettype": [ "" ],
"patching_rect": [ 53.0, 630.0, 222.0, 22.0 ]
"patching_rect": [ 53.0, 630.0, 415.0, 26.0 ]
}
},
{
@@ -2618,7 +2590,7 @@
"numinlets": 1,
"numoutlets": 3,
"outlettype": [ "", "", "" ],
"patching_rect": [ 720.0, 81.0, 59.0, 22.0 ],
"patching_rect": [ 720.0, 36.0, 59.0, 22.0 ],
"text": "gamepad"
}
},
@@ -2781,18 +2753,6 @@
"source": [ "obj-202", 0 ]
}
},
{
"patchline": {
"destination": [ "obj-3", 0 ],
"source": [ "obj-22", 0 ]
}
},
{
"patchline": {
"destination": [ "obj-3", 0 ],
"source": [ "obj-25", 0 ]
}
},
{
"patchline": {
"destination": [ "obj-56", 0 ],
@@ -2836,12 +2796,6 @@
"source": [ "obj-4", 0 ]
}
},
{
"patchline": {
"destination": [ "obj-3", 0 ],
"source": [ "obj-46", 0 ]
}
},
{
"patchline": {
"destination": [ "obj-109", 0 ],

View File

@@ -19,13 +19,24 @@ GNU General Public License for more details:
*/
autowatch = 1;
outlets = 2;
const g = new Global("tc.fpcontrol");
const instance = (() => {
if (g.instance_count) {
g.instance_count += 1
} else {
g.instance_count = 1;
}
return g.instance_count
})();
let world_bangs = false; // is the world banging?
const ctx_finder = require("implicit.context.js");
ctx_finder.register_drawto(this, dosetdrawto, set_root);
include("FF_Vector.js");
var ease = 0.1;
declareattribute({
name: "ease",
@@ -105,14 +116,14 @@ class Controller {
this.root_anim_node = null; // The implicit drawto context anim node
this.world_up = [0, 1, 0];
this.world_up = new FF_Vector(0, 1, 0);
this.camera_target = {
name: '',
class: '',
anim: '',
}
this.camera_node_implicit = new JitterObject("jit.anim.node"); // Used if camera isn't attached to an anim.node (using the pre-made anim node of a jit.gl.camera doesn't work for some reason, so we use our own "implicit" one)
this.camera_node_implicit.name = "camera_node_implicit";
this.camera_node_implicit.name = "camera_node_implicit_" + instance;
this.camera_node = new JitterObject("jit.proxy"); // Either proxy of a jit.anim.node defined by user, or of this.camera_node_implicit
this.cam_direction, this.cam_up, this.cam_right;
@@ -133,7 +144,7 @@ class Controller {
// pitch_node used with pitch_drive for pitch rotation (over the objects local x axis)
this.pitch_node = new JitterObject("jit.anim.node");
this.pitch_node.name = "fp_pitch_node";
this.pitch_node.name = "fp_pitch_node_" + instance;
this.pitch_node.anim = this.main_node.name;
this.pitch_node.turnmode = "parent";
this.pitch_node.movemode = "parent";
@@ -168,7 +179,7 @@ class Controller {
// Taking control over a new target
control(obj_name, obj_type) {
if (!this.camera_target.name) {
if (obj_name && !this.camera_target.name) {
error("No camera defined. Cannot control object.\n");
return false;
}
@@ -348,11 +359,15 @@ class Controller {
get_cam_base_matrix() {
if (this.camera_node.name != "") {
this.cam_direction = this.camera_node.send("getdirection");
this.cam_direction[1] = 0;
this.cam_direction = normalize(this.cam_direction);
this.cam_right = normalize(cross(this.cam_direction, this.world_up));
this.cam_up = normalize(cross(this.cam_right, this.cam_direction));
this.cam_direction = new FF_Vector(this.camera_node.send("getdirection"));
this.cam_direction.y = 0;
this.cam_direction.normalize();
this.cam_right = this.cam_direction.crossNew(this.world_up);
this.cam_right.normalize();
// this.cam_right = normalize(cross(this.cam_direction, this.world_up));
this.cam_up = this.cam_right.crossNew(this.cam_direction);
this.cam_up.normalize();
// this.cam_up = normalize(cross(this.cam_right, this.cam_direction));
this.camera_base_matrix = [
this.cam_right,
this.cam_up,
@@ -371,7 +386,7 @@ class Controller {
x *= -1;
z *= -1;
}
translat_vec = multiplyMatrixVector(this.camera_base_matrix, [x, 0, -z]);
translat_vec = multiplyMatrixVector(this.camera_base_matrix, new FF_Vector(x, 0, -z));
}
this.main_drive.move(translat_vec[0], y, translat_vec[2]);
}
@@ -380,7 +395,7 @@ class Controller {
let rot_vec = [x, y, z];
if (this.control_obj_type == "obj") {
this.get_cam_base_matrix();
rot_vec = multiplyMatrixVector(this.camera_base_matrix, [-x, 0, 0]);
rot_vec = multiplyMatrixVector(this.camera_base_matrix, new FF_Vector(-x, 0, 0));
rot_vec[1] = -y;
}
if (this.flymode && this.control_obj_type == "cam") {
@@ -619,6 +634,8 @@ function loadbang() {
}
function notifydeleted() {
control();
set_camera();
ctlr.destroy();
ctx_finder.dispose();
if (ctx_lstnr) ctx_lstnr.freepeer();
@@ -650,32 +667,11 @@ get_top_level_patcher.local = 1;
// MATHS
/////////////////////////////////////////////
function normalize(v) {
const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
if (len === 0) return v;
return [v[0] / len, v[1] / len, v[2] / len];
}
normalize.local = 1;
function cross(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
}
cross.local = 1;
function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
dot.local = 1;
function multiplyMatrixVector(matrix, vector) {
return [
dot(matrix[0], vector),
dot(matrix[1], vector),
dot(matrix[2], vector),
matrix[0].dot(vector),
matrix[1].dot(vector),
matrix[2].dot(vector),
];
}
multiplyMatrixVector.local = 1;

View File

@@ -1,5 +1,5 @@
{
"name" : "tc.jit.anim.control",
"name" : "tc.fpcontrol",
"displayname" : "",
"version" : "0.0.1",
"author" : "Théophile Clet",
@@ -9,7 +9,7 @@
"website" : "",
"extends" : "",
"extensible" : 0,
"max_version_min" : "9.1.0",
"max_version_min" : "9.1.4",
"max_version_max" : "none",
"os" : {
"macintosh" : {