Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions server/classes/tacticalMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ class TacticalItem {
this.flash = params.flash || false;
this.icon = params.icon || null;
this.size = params.size || 1;
this.iconWidth = params.iconWidth || 0;
this.iconHeight = params.iconHeight || 0;
this.keepOnScreen = params.keepOnScreen || false;
this.speed = params.speed || 1000;
this.velocity = params.velocity || {x: 0, y: 0, z: 0};
this.location = params.location || {x: 0, y: 0, z: 0};
Expand All @@ -60,6 +63,9 @@ class TacticalItem {
flash,
icon,
size,
iconWidth,
iconHeight,
keepOnScreen,
speed,
velocity,
location,
Expand All @@ -79,6 +85,9 @@ class TacticalItem {
if (flash || flash === false) this.flash = flash;
if (icon || icon === "") this.icon = icon;
if (size) this.size = size;
if (iconWidth) this.iconWidth = iconWidth;
if (iconHeight) this.iconHeight = iconHeight;
if (keepOnScreen || keepOnScreen === false) this.keepOnScreen = keepOnScreen;
if (speed || speed === 0) this.speed = speed;
if (velocity) this.velocity = velocity;
if (location) this.location = location;
Expand Down
47 changes: 47 additions & 0 deletions server/helpers/tacticalBounds.js

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could have sworn there was a way to share logic between the server and the client. Alas.

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Shared "keep on screen" clamp math for Tactical Map objects (server copy).
//
// Tactical items store their position as normalized {x, y, z} fractions where 0 is
// the left/top edge and 1 is the right/bottom edge. When an item has `keepOnScreen`
// enabled we constrain the position so the *entire* scaled icon stays within [0, 1].
//
// The footprint is computed against the canonical 1920x1080 viewscreen so the clamp is
// identical on the server (authoritative) and on every client, regardless of the actual
// canvas size.
//
// NOTE: This file is intentionally duplicated at
// `src/components/views/TacticalMap/preview/layerComps/clampToBounds.js`. The client
// (Vite, tsconfig include: src) and the server (tsconfig include: server) cannot import
// across that boundary, so keep the two copies in sync.

export const CANONICAL_WIDTH = 1920;
export const CANONICAL_HEIGHT = 1080;

export function getFootprint(
item,
canvasWidth = CANONICAL_WIDTH,
canvasHeight = CANONICAL_HEIGHT,
) {
const size = item.size || 1;
const w = ((item.iconWidth || 0) * size) / canvasWidth;
const h = ((item.iconHeight || 0) * size) / canvasHeight;
return {w, h};
}

export function clampToBounds(position, footprint) {
const maxX = Math.max(0, 1 - footprint.w);
const maxY = Math.max(0, 1 - footprint.h);
return {
x: Math.min(Math.max(position.x, 0), maxX),
y: Math.min(Math.max(position.y, 0), maxY),
z: position.z,
};
}

export function clampItemPosition(
item,
position,
canvasWidth = CANONICAL_WIDTH,
canvasHeight = CANONICAL_HEIGHT,
) {
return clampToBounds(position, getFootprint(item, canvasWidth, canvasHeight));
}
32 changes: 23 additions & 9 deletions server/processes/tacticalMapMove.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import App from "../app";
import * as THREE from "three";
import {pubsub} from "../helpers/subscriptionManager";
import {clampItemPosition} from "../helpers/tacticalBounds";

const interval = 1000 / 5;
let lastTime = Date.now();
Expand Down Expand Up @@ -42,15 +43,28 @@ const moveTacticalMap = () => {
App.tacticalMaps.forEach(m => {
m.layers.forEach(l => {
l.items.forEach(i => {
i.update({
location: moveContact(
i.destination,
i.location,
i.speed,
m.frozen,
delta,
),
});
let location = moveContact(
i.destination,
i.location,
i.speed,
m.frozen,
delta,
);
// Authoritative backstop: never let the animated location settle
// off screen when the contact is constrained. Also pull a (possibly
// newly-constrained) destination back on screen so the persisted value
// converges instead of drifting.
if (i.keepOnScreen) {
location = clampItemPosition(i, location);
const destination = clampItemPosition(i, i.destination);
if (
destination.x !== i.destination.x ||
destination.y !== i.destination.y
) {
i.update({destination});
}
}
i.update({location});
});
});
m.interval = interval;
Expand Down
10 changes: 10 additions & 0 deletions server/processes/thrusters.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import App from "../app";
import {pubsub} from "../helpers/subscriptionManager";
import {clampItemPosition} from "../helpers/tacticalBounds";

function getMovementDirection(direction, movement) {
if (movement === "up") return Math.abs(direction.z < 0 ? direction.z : 0);
Expand Down Expand Up @@ -190,6 +191,15 @@ const updateThrusters = () => {
item.location.x += movement.x;
item.location.y += movement.y;
}
// Keep the icon on screen if requested. Clamping the stored
// values (rather than only the rendered position) cancels the
// "drift" at the edge: holding thrust into a wall no longer
// accumulates an off-screen position, so reversing thrust moves
// the contact immediately with no dead-zone.
if (item.keepOnScreen) {
item.destination = clampItemPosition(item, item.destination);
item.location = clampItemPosition(item, item.location);
}
});
});
});
Expand Down
9 changes: 9 additions & 0 deletions server/typeDefs/tacticalMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ const schema = gql`
icon: String
size: Float
opacity: Float
# Intrinsic pixel dimensions of the icon image, measured by the client.
# Used to compute the icon footprint for the keepOnScreen clamp.
iconWidth: Float
iconHeight: Float

#Animation
speed: Float
Expand All @@ -116,6 +120,8 @@ const schema = gql`
thrusters: Boolean
rotationMatch: Boolean
thrusterControls: ThrusterControls
# When true, the object is constrained so its full icon stays on screen.
keepOnScreen: Boolean
}

input TacticalItemInput {
Expand All @@ -132,6 +138,8 @@ const schema = gql`
icon: String
size: Float
opacity: Float
iconWidth: Float
iconHeight: Float

#Animation
speed: Float
Expand All @@ -145,6 +153,7 @@ const schema = gql`
thrusters: Boolean
rotationMatch: Boolean
thrusterControls: ThrusterControlsInput
keepOnScreen: Boolean
}

type TacticalPath {
Expand Down
14 changes: 14 additions & 0 deletions src/components/views/TacticalMap/objectConfig.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ const ObjectSettings = ({
flash,
ijkl,
wasd,
keepOnScreen,
opacity,
updateObject,
//thrusters,
Expand Down Expand Up @@ -441,6 +442,19 @@ const ObjectSettings = ({
IJKL Keys
</Label>
</FormGroup>
<FormGroup check>
<Label check>
<Input
type="checkbox"
checked={keepOnScreen}
onChange={evt =>
updateObject("keepOnScreen", evt.target.checked)
}
/>
Keep on screen{" "}
<small>Stops the icon from moving off the edge of the screen.</small>
</Label>
</FormGroup>
<Button size="sm" color="danger" onClick={() => removeObject()}>
Remove Item
</Button>
Expand Down
19 changes: 10 additions & 9 deletions src/components/views/TacticalMap/preview/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, {Component} from "react";
import layerComps from "./layerComps";
import {withApollo} from "react-apollo";
import gql from "graphql-tag.macro";
import {clampItemPosition} from "./layerComps/clampToBounds";
class TacticalMapPreview extends Component {
keypress = evt => {
const distance = 0.005;
Expand Down Expand Up @@ -39,15 +40,15 @@ class TacticalMapPreview extends Component {
(wasd.indexOf(evt.code) > -1 && i.wasd) ||
(ijkl.indexOf(evt.code) > -1 && i.ijkl)
) {
this.props.updateObject(
"destination",
{
x: i.destination.x + movement.x,
y: i.destination.y + movement.y,
z: i.destination.z,
},
i,
);
let destination = {
x: i.destination.x + movement.x,
y: i.destination.y + movement.y,
z: i.destination.z,
};
if (i.keepOnScreen) {
destination = clampItemPosition(i, destination);
}
this.props.updateObject("destination", destination, i);
}
});
}
Expand Down
22 changes: 22 additions & 0 deletions src/components/views/TacticalMap/preview/layerComps/IconMarkup.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from "react";
import {clampItemPosition} from "./clampToBounds";

const IconMarkup = ({
mouseDown,
Expand All @@ -20,10 +21,30 @@ const IconMarkup = ({
core,
isSelected,
interval,
keepOnScreen,
iconWidth,
iconHeight,
onIconLoad,
}) => {
if (core) {
opacity = Math.max(0.5, opacity);
}
// Defensive render-time clamp: even if a stored position somehow drifted off
// screen, a keepOnScreen icon is never displayed clipping past the edge.
if (keepOnScreen) {
const footprintItem = {size, iconWidth, iconHeight};
if (location) {
location = clampItemPosition(footprintItem, location);
}
if (destination) {
destination = clampItemPosition(footprintItem, {
x: destination.x + movement.x,
y: destination.y + movement.y,
z: destination.z,
});
movement = {x: 0, y: 0, z: 0};
}
}
return [
location ? (
<div
Expand All @@ -49,6 +70,7 @@ const IconMarkup = ({
draggable={false}
src={src}
style={{transform: `rotate(${rotation}deg)`}}
onLoad={onIconLoad ? evt => onIconLoad(evt.target) : undefined}
/>
)}
<pre
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, {Component} from "react";
import IconMarkup from "./IconMarkup";
import {clampItemPosition} from "./clampToBounds";

export default class TacticalIcon extends Component {
constructor(props) {
Expand Down Expand Up @@ -38,7 +39,10 @@ export default class TacticalIcon extends Component {
if (this.props.isSelected) {
this.props.moveMultiple("cancel");
} else {
const {x, y, z} = this.state.destination;
let {x, y, z} = this.state.destination;
if (this.props.keepOnScreen) {
({x, y, z} = clampItemPosition(this.props, {x, y, z}));
}
this.props.updateObject("destination", {x, y, z});

this.dragging = false;
Expand All @@ -57,13 +61,29 @@ export default class TacticalIcon extends Component {
this.props.moveMultiple(evt, bounds);
} else {
const {destination} = this.state;
const x = destination.x + evt.movementX / bounds.width;
const y = destination.y + evt.movementY / bounds.height;
let x = destination.x + evt.movementX / bounds.width;
let y = destination.y + evt.movementY / bounds.height;
if (this.props.keepOnScreen) {
({x, y} = clampItemPosition(this.props, {x, y, z: destination.z}));
}
this.setState({
destination: Object.assign({}, this.state.destination, {x, y}),
});
}
};
// Measure the icon's intrinsic dimensions once and persist them so the server
// (and every client) can compute the keepOnScreen footprint. Stored only when
// missing or changed, so this fires at most once per icon.
handleIconLoad = ({naturalWidth, naturalHeight}) => {
if (!naturalWidth || !naturalHeight) return;
const {id, layerId, iconWidth, iconHeight} = this.props;
if (iconWidth !== naturalWidth) {
this.props.updateObject("iconWidth", naturalWidth, {id, layerId});
}
if (iconHeight !== naturalHeight) {
this.props.updateObject("iconHeight", naturalHeight, {id, layerId});
}
};
render() {
const {destination} = this.state;
const {
Expand All @@ -84,6 +104,9 @@ export default class TacticalIcon extends Component {
interval,
movement = {x: 0, y: 0, z: 0},
isSelected,
keepOnScreen,
iconWidth,
iconHeight,
} = this.props;
if (icon) {
return (
Expand All @@ -107,6 +130,10 @@ export default class TacticalIcon extends Component {
fontSize={fontSize}
label={label}
core={core}
keepOnScreen={keepOnScreen}
iconWidth={iconWidth}
iconHeight={iconHeight}
onIconLoad={this.handleIconLoad}
/>
);
}
Expand Down
Loading
Loading