Skip to content

Instantly share code, notes, and snippets.

@oauo
Last active September 4, 2020 14:20
Show Gist options
  • Save oauo/864b837baae9a1ee0f2f352c2f0a4abd to your computer and use it in GitHub Desktop.
Save oauo/864b837baae9a1ee0f2f352c2f0a4abd to your computer and use it in GitHub Desktop.
Observer Camera Tool Final #TF2
{
"scripts": [
"react",
"react-dom",
"https://cdnjs.cloudflare.com/ajax/libs/classnames/2.2.6/index.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/prop-types/15.7.2/prop-types.min.js"
],
"styles": [
"https://pro.fontawesome.com/releases/v5.13.1/css/all.css",
"https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css",
"https://fonts.googleapis.com/css2?family=Fira+Code&family=Open+Sans&display=swap"
]
}
const reduceTargetKeys = (target, keys, predicate) =>
Object.keys(target).reduce(predicate, {});
const omit = (target = {}, keys = []) => reduceTargetKeys(target, keys, (acc, key) =>
keys.some(omitKey => omitKey === key) ? acc : { ...acc, [key]: target[key] });
const isFunction = fn => Object.prototype.toString.call(fn) === '[object Function]';
const propTypes = {
content: PropTypes.string,
editable: PropTypes.bool,
focus: PropTypes.bool,
maxLength: PropTypes.number,
multiLine: PropTypes.bool,
sanitise: PropTypes.bool,
caretPosition: PropTypes.oneOf(['start', 'end']),
// The element to make contenteditable.
// Takes an element string ('div', 'span', 'h1') or a styled component
tagName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
innerRef: PropTypes.func,
onBlur: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyUp: PropTypes.func,
onPaste: PropTypes.func,
onChange: PropTypes.func,
styled: PropTypes.bool,
};
class ContentEditable extends React.Component {
static propTypes = propTypes;
static defaultProps = {
content: '',
editable: true,
focus: false,
maxLength: Infinity,
multiLine: false,
sanitise: true,
caretPosition: null,
tagName: 'div',
innerRef: null,
onBlur: null,
onKeyDown: null,
onKeyUp: null,
onPaste: null,
onChange: null,
styled: false,
};
constructor(props) {
super();
this.state = {
caretPosition: this.getCaretPositionFromProps(props),
value: this.sanitiseValue(props.content, props),
};
this.ref = null;
this.selection = document.getSelection();
}
componentDidMount() {
const { focus } = this.props;
if (focus && this.ref) {
this.setCaretPosition();
this.ref.focus();
}
}
componentDidUpdate(prevProps, prevState) {
const { caretPosition, content, focus } = this.props;
const updateCaretPosition = prevProps.caretPosition !== caretPosition
|| prevProps.focus !== focus;
const updateContent = prevProps.content !== content;
if (updateCaretPosition || updateContent) {
this.setState({
caretPosition: updateCaretPosition
? this.getCaretPositionFromProps() : prevState.caretPosition,
value: updateContent
? this.sanitiseValue(content) : prevState.value,
}, () => {
this.setCaretPosition();
});
}
}
getRange () {
return this.selection.rangeCount ? this.selection.getRangeAt(0) : document.createRange();
}
getCaret() {
const originalRange = this.getRange();
const range = originalRange.cloneRange();
range.selectNodeContents(this.ref);
range.setEnd(originalRange.endContainer, originalRange.endOffset);
return range.toString().length;
}
getSafeCaretPosition(position, nextValue) {
const { caretPosition, value } = this.state;
const val = nextValue || value;
let pos = position || caretPosition;
return Math.min(pos, val.length);
}
setCaretPosition() {
let range = this.getRange();
range.setStart(this.ref.childNodes[0] || this.ref, this.getSafeCaretPosition());
range.collapse();
this.selection.removeAllRanges();
this.selection.addRange(range);
}
getCaretPositionFromProps(props = this.props) {
const caretPosition = props.caretPosition === 'end' ? props.content.length : 0;
return props.focus ? caretPosition : null;
}
insertAtCaret = (prevValue, valueToInsert) => {
const { startOffset, endOffset } = this.getRange();
const prefix = prevValue.slice(0, startOffset);
const suffix = prevValue.slice(endOffset);
return [prefix, valueToInsert, suffix].join('')
};
sanitiseValue(value, props = this.props) {
const { maxLength, multiLine, sanitise } = props;
if (!sanitise) {
return value;
}
if (isFunction(sanitise)) {
return sanitise(value, this.getRange());
}
let nextValue = value
// Normalise whitespace
.replace(/[ \u00a0\u2000-\u200b\u2028-\u2029\u202e-\u202f\u3000]/g, ' ')
// Remove multiple whitespace chars and if not multiLine, remove lineBreaks
// FIXME This causes an issue when setting caret position
.replace(multiLine ? /[\t\v\f\r ]+/g : /\s+/g, ' ');
if (multiLine) {
nextValue = nextValue
// Replace 3+ line breaks with two
// FIXME This causes an issue when setting caret position
.replace(/\r|\n{3,}/g, '\n\n')
// Remove leading & trailing whitespace
// FIXME This causes an issue when setting caret position
.split('\n').map(line => line.trim()).join('\n');
}
return nextValue
// Ensure maxLength not exceeded
.substr(0, maxLength);
}
onBlur = (ev) => {
const { value } = this.state;
const { onBlur } = this.props;
if (isFunction(onBlur)) {
onBlur(ev, value);
}
};
onInput = (ev) => {
const { maxLength } = this.props;
const { innerText } = ev.target;
if (innerText.length >= maxLength) {
return;
}
this.setState({
caretPosition: this.getCaret(),
value: this.sanitiseValue(innerText),
}, () => {
const { onChange } = this.props;
if (isFunction(onChange)) {
const { value } = this.state;
onChange(ev, value);
}
});
};
onKeyDown = (ev) => {
const { innerText } = ev.target;
const { maxLength, multiLine, onKeyDown } = this.props;
let value = innerText;
// Return key
if (ev.keyCode === 13) {
ev.preventDefault();
if (multiLine) {
const caretPosition = this.getCaret();
const hasLineBreak = /\r|\n/g.test(innerText.charAt(caretPosition));
const hasCharAfter = !!innerText.charAt(caretPosition);
value = this.insertAtCaret(innerText, hasLineBreak || hasCharAfter ? '\n' : '\n\n');
this.setState({
caretPosition: caretPosition + 1,
value,
});
} else {
ev.currentTarget.blur();
}
}
// Ensure we don't exceed `maxLength` (keycode 8 === backspace)
if (maxLength && !ev.metaKey && ev.which !== 8 && innerText.length >= maxLength) {
ev.preventDefault();
}
if (isFunction(onKeyDown)) {
onKeyDown(ev, value);
}
};
onKeyUp = (ev) => {
const { innerText } = ev.target;
const { onKeyUp } = this.props;
if (isFunction(onKeyUp)) {
onKeyUp(ev, innerText);
}
};
onPaste = ev => {
ev.preventDefault();
const pastedValue = ev.clipboardData.getData('text');
const value = this.insertAtCaret(this.ref.innerText, pastedValue);
const { startOffset } = this.getRange();
this.setState({
caretPosition: this.getSafeCaretPosition(startOffset + pastedValue.length, value),
value: this.sanitiseValue(value),
}, () => {
const { onPaste } = this.props;
if (isFunction(onPaste)) {
onPaste(value);
}
});
};
setRef = (ref) => {
const { innerRef } = this.props;
this.ref = ref;
if (isFunction(innerRef)) {
innerRef(ref);
}
};
render() {
const { tagName: Element, editable, styled, ...props } = this.props;
return (
<Element
{...omit(props, Object.keys(propTypes))}
{...(styled ? { innerRef: this.setRef } : { ref: this.setRef })}
style={{ whiteSpace: 'pre-wrap', ...props.style }}
contentEditable={editable}
dangerouslySetInnerHTML={{ __html: this.state.value }}
onBlur={this.onBlur}
onInput={this.onInput}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onPaste={this.onPaste}
/>
);
}
}
const MapVMF = {
versioninfo: {
editorversion:420,
editorbuild:1337,
mapversion:1,
formatversion:666,
prefab:0
},
visgroups: {
visgroup:[
{
name:"Observer Points",
visgroupid: 2,
color: "236 135 192"
}
]
},
viewsettings: {
bSnapToGrid:1,
bShowGrid:1,
bShowLogicalGrid:0,
nGridSpacing:64,
bShow3DGrid:0
},
world: {
id:0,
mapversion:1,
classname:"worldspawn",
skyname:"sky_tf2_04",
maxpropscreenwidth:-1,
detailvbsp:"detail.vbsp",
detailmaterial:"detail/detailsprites",
solid: [], //| makeBrushes(processed_materials, material_families),
group: [{id:1, editor:{color: "236 135 192", visgroupshown:1, visgroupautoshown:1}}] // [{id:1, editor:{color: "172 146 236", visgroupshown:1, visgroupautoshown:1}}].concat(material_families.map(family => ({id:family.visgroupid, editor:{color: "236 135 192", visgroupshown:1, visgroupautoshown:1}})))
},
entity: [],
cameras: {
activecamera:-1
},
cordon: {
mins:"(-1024 -1024 -1024)",
maxs:"(1024 1024 1024)",
active:0
}
}
let dataId = 2
/*const ObserverVMF = {
id:"1",
classname:"info_observer_point",
angles:"90 0 0",
associated_team_entity:"",
defaultwelcome:"0",
fov:"0",
match_summary:"0",
StartDisabled:"0", //Inverse of enabled
TeamNum:"0",
origin:"0 0 0", //pos
editor: {
color:"236 135 192",
groupid:"1",
visgroupid:"1",
visgroupautoshown:"1",
logicalpos: "[0 1000]"
}
}*/
class VMF {
//Convert JS object into VMF format
constructor(object) {
this.object = object
}
ind = indent => `\t`.repeat(indent)
objectToVMF = (obj, i = 0) =>
`${[...Object.keys(obj)].map(x => typeof obj[x] == "object" ? Array.isArray(obj[x]) ?
///*Map Array*/ !!obj[x][0].key && !!obj[x][0].value ? `${this.ind(i)}${x}\n${this.ind(i)}{\n${obj[x].map(y => `${this.ind(i+1)}"${y.key}" "${y.value}"`).join(`\n`)}\n${this.ind(i)}}` :
/*Array*/ obj[x].map(y => `${this.ind(i)}${x}\n${this.ind(i)}{\n${this.objectToVMF(y,i+1)}\n${this.ind(i)}}`).join(`\n`) :
/*Object*/ `${this.ind(i)}${x}\n${this.ind(i)}{\n${this.objectToVMF(obj[x],i+1)}\n${this.ind(i)}}` :
/*Other*/ `${this.ind(i)}"${x}" "${obj[x]}"`).join(`\n`)}`
toString = () => {
//this.object.cordon.maxs = `(${data.max.x+64} ${data.max.y+64} ${data.max.z+64})` //Move this
return this.objectToVMF(this.object,0)
}
}
/****/
const commandRegex = /setpos (?<posx>-?\d+(?:\.\d+)?) (?<posy>-?\d+(?:\.\d+)?) (?<posz>-?\d+(?:\.\d+)?) *;? *setang (?<angx>-?\d+(?:\.\d+)?) (?<angy>-?\d+(?:\.\d+)?) (?<angz>-?\d+(?:\.\d+)?)/i
const round = (value, precision) => {
var multiplier = Math.pow(10, precision || 0);
return Math.round(value * multiplier) / multiplier;
}
class Point extends React.Component {
constructor(props) {
super(props)
this.state = {
replacementAllowed:false,
frame:1
}
this.contentEditables={
pos:{
x:React.createRef(),
y:React.createRef(),
z:React.createRef()
},
ang:{
x:React.createRef(),
y:React.createRef(),
z:React.createRef()
}
}
}
changeName = () => {
this.props.changeName(this.props.index, this.name.value)
}
handleReplacement = ({target}) => {
const groups = target.value.match(commandRegex)?.groups
if(groups) {
this.props.handleTemp(this.props.index, {
pos:{
x:parseFloat(round(groups.posx,1)).toString(),
y:parseFloat(round(groups.posy,1)).toString(),
z:parseFloat(round(groups.posz,1)).toString()
},
ang:{
x:parseFloat(round(groups.angx,1)).toString(),
y:parseFloat(round(groups.angy,1)).toString(),
z:parseFloat(round(groups.angz,1)).toString()
}
})
} else {
this.props.handleTemp(this.props.index, null)
}
}
handleEdit = (id, value) => {
if (/^-?\d+(?:\.\d*)?$/.test(value)) {
this.props.handleEdit(this.props.index, id, value) //Was going to convert to float here
this.props.errorWith(this.props.index, false)
} else if(value.length == 0) {
this.props.handleEdit(this.props.index, id, "")
this.props.errorWith(this.props.index, true)
} else { //Allow but will show as error
this.props.handleEdit(this.props.index, id, value)
this.props.errorWith(this.props.index, true)
}
}
changeFov = () => {
let value = this.fov.value || 0
if(/^\d{2}$/) {
value = parseInt(value)
}
if (isNaN(value)) {
value=0
}
this.props.changeAdvanced(this.props.index, {fov:value})
}
changeTargetname = () => {
this.props.changeAdvanced(this.props.index, {targetname:this.targetname.value.replace(/ |-/g, "_")})
}
render() {
return (
<div class={classNames("point", {new:this.props.new == this.props.index}, {deleted:this.props.deleting == this.props.index}, {belowDeleted:this.props.deleting < this.props.index && this.props.deleting >= 0}, {secondary:this.props.recent.from == this.props.index}, {moveup:(this.props.recent.to > this.props.recent.from ? this.props.recent.from == this.props.index : this.props.recent.to == this.props.index)}, {movedown:(this.props.recent.to > this.props.recent.from ? this.props.recent.to == this.props.index : this.props.recent.from == this.props.index)})} frame={this.state.frame}>
<button class="delete" title="Delete camera" onClick={this.props.delete}>
<i class="fas fa-trash"/>
</button>
<div class="frame">
<div class="container">
<div class="replacement">
<div class={classNames("input", {"parsed":!!this.props.point.temp})}>
{!this.props.point.temp &&
<input type="text" ref={node => {this.input = node}} onChange={this.handleReplacement} placeholder="Paste replacement `getpos x y z;getang x y z` command here."/>
}
{this.props.point.temp &&
<>
<div>
<span>getpos</span>
<span class="num">{this.props.point.temp.pos.x}</span>
<span class="num">{this.props.point.temp.pos.y}</span>
<span class="num">{this.props.point.temp.pos.z}</span>
</div>
<div>
<span>getang</span>
<span class="num">{this.props.point.temp.ang.x}</span>
<span class="num">{this.props.point.temp.ang.y}</span>
<span class="num">{this.props.point.temp.ang.z}</span>
</div>
</>
}
</div>
<div class="buttons left">
<button class={classNames("circle", {green:this.props.point.temp})} disabled={!this.props.point.temp} onClick={() => {this.props.resolveTemp(this.props.index, true);this.setState({frame:1})}}>
<i class="fas fa-check"/>
</button>
<button class={classNames("circle", {red:this.props.point.temp})} onClick={() => {this.props.resolveTemp(this.props.index, false);this.setState({frame:1})}}>
<i class="fas fa-times"/>
</button>
</div>
</div>
<div class="main">
<div class="buttons right">
<button class="welcomepoint" onClick={this.props.setAsWelcome} title="Set this as welcome point">
<i class={classNames({fas:this.props.point.welcomePoint},{fal:!this.props.point.welcomePoint}, "fa-badge")}/>
</button>
<button class="enabled" onClick={this.props.toggleEnabled} title="Observer point starts enabled">
<i class={classNames({fas:this.props.point.enabled}, {fal:!this.props.point.enabled}, {"fa-check-square":this.props.point.enabled}, {"fa-square":!this.props.point.enabled})} />
</button>
<button class="matchsummary" onClick={this.props.setAsSummary} title="Set as match summary point">
<i class={classNames({fas:this.props.point.summary},{fal:!this.props.point.summary}, "fa-trophy")}/>
</button>
</div>
<div class="info">
<div class="name">
<input type="text" ref={node => {this.name = node}} onChange={this.changeName} value={this.props.point.name} placeholder="Give this observer point a name"/>
</div>
<div class="details">
<span class={classNames("vertical", {"error":!this.props.point.set.pos.x || !/^-?\d+(?:\.\d*)?$/.test(this.props.point.set.pos.x)})}>
<span>x</span>
<ContentEditable content={`${this.props.point.set.pos.x ? this.props.point.set.pos.x : ``}`} tagName="span" sanitise={true} onChange={(_,v) => this.handleEdit("posx", v)}/>
</span>
<span class={classNames("vertical", {"error":!this.props.point.set.pos.y || !/^-?\d+(?:\.\d*)?$/.test(this.props.point.set.pos.y)})}>
<span>y</span>
<ContentEditable content={`${this.props.point.set.pos.y ? this.props.point.set.pos.y : ``}`} tagName="span" sanitise={true} onChange={(_,v) => this.handleEdit("posy", v)}/>
</span>
<span class={classNames("vertical", {"error":!this.props.point.set.pos.z || !/^-?\d+(?:\.\d*)?$/.test(this.props.point.set.pos.z)})}>
<span>z</span>
<ContentEditable content={`${this.props.point.set.pos.z ? this.props.point.set.pos.z : ``}`} tagName="span" sanitise={true} onChange={(_,v) => this.handleEdit("posz", v)}/>
</span>
<span class="seperator" />
<span class={classNames("vertical", {"error":!this.props.point.set.ang.x || !/^-?\d+(?:\.\d*)?$/.test(this.props.point.set.ang.x)})}>
<span>pitch</span>
<ContentEditable content={`${this.props.point.set.ang.x ? this.props.point.set.ang.x : ``}`} tagName="span" sanitise={true} onChange={(_,v) => this.handleEdit("angx", v)}/>
</span>
<span class={classNames("vertical", {"error":!this.props.point.set.ang.y || !/^-?\d+(?:\.\d*)?$/.test(this.props.point.set.ang.y)})}>
<span>yaw</span>
<ContentEditable content={`${this.props.point.set.ang.y ? this.props.point.set.ang.y : ``}`} tagName="span" sanitise={true} onChange={(_,v) => this.handleEdit("angy", v)}/>
</span>
<span class={classNames("vertical", {"error":!this.props.point.set.ang.z || !/^-?\d+(?:\.\d*)?$/.test(this.props.point.set.ang.z)})}>
<span>roll</span>
<ContentEditable content={`${this.props.point.set.ang.z ? this.props.point.set.ang.z : ``}`} tagName="span" sanitise={true} onChange={(_,v) => this.handleEdit("angz", v)}/>
</span>
<span class="seperator" />
{ this.props.point.associated_team_entity &&
<span class="entity">
<i class="fas fa-flag" title={this.props.point.associated_team_entity}/>
</span>
}
<span class="teamselection">
<div class={classNames("any", {selected:this.props.point.team==0})} onClick={() => this.props.changeTeam(this.props.index, 0)}>Any</div>
<div class={classNames("spectator", {selected:this.props.point.team==1})} onClick={() => this.props.changeTeam(this.props.index, 1)}>Spec</div>
<div class={classNames("blu", {selected:this.props.point.team==2})} onClick={() => this.props.changeTeam(this.props.index, 2)}>Blu</div>
<div class={classNames("red", {selected:this.props.point.team==3})} onClick={() => this.props.changeTeam(this.props.index, 3)}>Red</div>
</span>
</div>
</div>
<div class="buttons left">
<button title="Replace camera" onClick={() => this.setState({frame:0})}>
<i class="fas fa-exchange"/>
</button>
<button title="Advanced options" onClick={() => this.setState({frame:2})}>
<i class="fas fa-cog"/>
</button>
</div>
</div>
<div class="advanced">
<div class="options">
<div class="name">fov</div>
<div>
<input title="75 to 90, with 0 being default" class={classNames({error:(!(this.props.point.fov == 0 || this.props.point.fov >= 75 && this.props.point.fov <= 90))})} ref={node => {this.fov = node}} onChange={this.changeFov} value={this.props.point.fov}/>
</div>
<div class="name">targetname</div>
<div>
<input title="Name of this entity" ref={node => {this.targetname = node}} onChange={this.changeTargetname} value={this.props.point.targetname}/>
</div>
<div class="name">parent</div>
<div>
<input ref={node => {this.parent = node}} onChange={()=>this.props.changeAdvanced(this.props.index, {parent:this.parent.value})} value={this.props.point.parent}/>
</div>
<div class="name small">associated_team_entity</div>
<div>
<input title="Owner of team entity can view from this camera, team filter applies." ref={node => {this.associated_team_entity = node}} onChange={()=>this.props.changeAdvanced(this.props.index, {associated_team_entity:this.associated_team_entity.value})} value={this.props.point.associated_team_entity}/>
</div>
</div>
<div class="buttons left">
<button onClick={() => this.setState({frame:1})}>
<i class="fas fa-check"/>
</button>
</div>
</div>
</div>
</div>
<div class="reorder">
<button class="fas fa-angle-up" disabled={this.props.index == 0} onClick={this.props.moveUp}/>
<button class="fas fa-angle-down" disabled={this.props.last} onClick={this.props.moveDown}/>
</div>
</div>
)
}
}
class App extends React.Component {
constructor(props) {
super(props)
const points = JSON.parse(localStorage.getItem("points")) || []
this.state = {
deleting:-1,
new:-1,
points:points,
showModel:false,
recent:{
from:-1,
to:-1
//From higher index to lower index: `to` gets `moveup` and `from` gets `movedown secondary`
//From lower index to higher insex: `to` gets `movedown` and `from` gets `moveup secondary`
}
}
}
download = () => {
const _map = {...MapVMF}
_map.entity = this.state.points.map(point => {
let _point = {
id:`${++dataId}`,
classname:"info_observer_point",
angles:`${point.set.ang.x} ${point.set.ang.y} ${point.set.ang.z}`,
defaultwelcome:`${+point.welcomePoint}`,
fov:`${point.fov}`,
match_summary:`${+point.summary}`,
StartDisabled:`${+!point.enabled}`, //Inverse of enabled
TeamNum:`${point.team}`,
origin:`${point.set.pos.x} ${point.set.pos.y} ${point.set.pos.z}`, //pos
editor: {
color:"236 135 192",
groupid:"1",
visgroupid:"2",
visgroupautoshown:"1",
logicalpos: "[0 1000]"
}
}
if(point.name?.length > 0) {
_point = {_name:point.name}
}
//_point.angles = `${point.set.ang.x} ${point.set.ang.y} ${point.set.ang.z}`
if(point.associated_team_entity?.length > 0) {
_point = {associated_team_entity:point.associated_team_entity}
}
if(point.targetname?.length > 0) {
_point = {targetname:point.targetname}
}
if(point.parent?.length > 0) {
_point = {parent:point.parent}
}
//_point.defaultwelcome = `${+point.welcomePoint}`
//_point.fov = `${point.fov}`
//_point.match_summary = `${+point.summary}`
//_point.StartDisabled = `${+!point.enabled}`
//_point.TeamNum = `${point.team}`
//_point.origin = `${point.set.pos.x} ${point.set.pos.y} ${point.set.pos.z}`
return _point
})
dl.href = `data:text/plain,${encodeURIComponent(new VMF(_map).toString())}`
dl.click()
}
save = points => {
localStorage.setItem("points", JSON.stringify((points || this.state.points).map(point => ({...point, temp:null}))))
}
changeModel = showModel => {
this.setState({
showModel
})
}
errorWith = (index, error) => {
this.setState({points:this.state.points.map((point,i) => index == i ? {...point, error} : point)})
setTimeout(this.save)
}
addPoint = () => {
const groups = this.newpoint.value.match(commandRegex)?.groups
if(groups) {
let addition = {
id:Math.random()*1E18,
error:false,
set:{
pos:{
x:parseFloat(round(groups.posx,1)).toString(),
y:parseFloat(round(groups.posy,1)).toString(),
z:parseFloat(round(groups.posz,1)).toString()
},
ang:{
x:parseFloat(round(groups.angx,1)).toString(),
y:parseFloat(round(groups.angy,1)).toString(),
z:parseFloat(round(groups.angz,1)).toString()
}
},
temp:null,
team:0,
name:"",
welcomePoint:false,
enabled:true,
summary:false,
fov:0,
targetname:"",
parent:"",
associated_team_entity:""
}
this.newpoint.value = ""
this.setState({
new:this.state.points.length,
points:[...this.state.points, addition]
})
setTimeout(() => {
this.setState({
new:-1
})
},1000)
this.save([...this.state.points, addition])
}
}
setAsWelcome = index => {
this.setState({points:this.state.points.map((point,i) => index == i ? {...point, welcomePoint:!point.welcomePoint} : {...point, welcomePoint:false})})
setTimeout(this.save)
}
toggleEnabled = index => {
this.setState({points:this.state.points.map((point,i) => index == i ? {...point, enabled:!point.enabled} : point)})
setTimeout(this.save)
}
setAsSummary = index => {
this.setState({points:this.state.points.map((point,i) => index == i ? {...point, summary:!point.summary} : {...point, summary:false})})
setTimeout(this.save)
}
resolveTemp = (index, through) => {
this.setState({
points:this.state.points.map((point,i) => index == i ? {...point, set:through ? point.temp : point.set, frame:1} : point)
})
setTimeout(() => {
this.setState({
points:this.state.points.map((point,i) => index == i ? {...point, temp:null} : point)
})
}, 500)
this.save(this.state.points.map((point,i) => index == i ? {...point, temp:null} : point))
}
handleTemp = (index, temps) => {
this.setState({
points:this.state.points.map((point,i) => index == i ? {...point, temp:temps} : point)
})
setTimeout(this.save)
}
changeName = (index, name) => {
this.setState({
points:this.state.points.map((point,i) => index == i ? {...point, name} : point)
})
setTimeout(this.save)
}
changeAdvanced = (index, advanced) => {
this.setState({
points:this.state.points.map((point,i) => index == i ? {...point, ...advanced} : point)
})
setTimeout(this.save)
}
changeTeam = (index, team) => {
this.setState({
points:this.state.points.map((point,i) => index == i ? {...point, team} : point)
})
setTimeout(this.save)
}
manipulateSet = (set, id, value) => {
let _set = set
switch (id) {
case "posx":
_set.pos.x = value
break
case "posy":
_set.pos.y = value
break
case "posz":
_set.pos.z = value
break
case "angx":
_set.ang.x = value
break
case "angy":
_set.ang.y = value
break
case "angz":
_set.ang.z = value
break
}
return _set
}
handleEdit = (index, id, value) => {
this.setState({
points:this.state.points.map((point,i) =>
index == i ? {...point, set:this.manipulateSet(point.set, id, value)} : point
)
})
setTimeout(this.save)
}
moveUp = index => {
if(this.state.recent.from != -1) return;
this.setState({
points:this.state.points.map((point, i) => i == index ? this.state.points[i-1] : i+1 == index ? this.state.points[i+1] : point),
recent: {
from:index,
to:index-1
}
})
setTimeout(this.save)
setTimeout(() => {
this.setState({
recent: {
from:-1,
to:-1
}
})
},1000)
}
moveDown = index => {
if(this.state.recent.from != -1) return;
this.setState({
points:this.state.points.map((point, i) => i == index ? this.state.points[i+1] : i-1 == index ? this.state.points[i-1] : point),
recent: {
from:index,
to:index+1
}
})
setTimeout(this.save)
setTimeout(() => {
this.setState({
recent: {
from:-1,
to:-1
}
})
},1000)
}
delete = index => {
if(this.state.deleting != -1) return;
this.setState({
deleting:index
})
this.save(this.state.points.filter((x,i) => i != index))
setTimeout(() => {
this.setState({
deleting:-1,
points:this.state.points.filter((x,i) => i != index)
})
},1000)
}
render() {
return (
<>
<a id="dl" download="observer_points.vmf"></a>
<div id="model" class={classNames({shade:this.state.showModel})}>
<div class="click" onClick={()=>this.changeModel(false)}></div>
<div class="card">
<h2>About</h2>
<button class="close fas fa-times" onClick={()=>this.changeModel(false)}></button>
<p>This tool allows you to more easily place spectator cameras in your map through fetching positions in-game.</p>
<p>In a source game <pre>noclip</pre> to where you would like a spectator camera and use the command <pre>getpos</pre>, in the console there will be an output which looks like <pre>setpos x y z;setang x y z</pre> copy the console which contains that text and paste it into the input at the top (you don't have to be exact, that text just needs to be somewhere in the paste)</p>
<p>Once you have the cameras you want click the <i class="fas fa-arrow-alt-to-bottom"/> button and an <pre>observer_points.vmf</pre> will be downloaded, this contains the observer points. Open that file in Hammer and select the group of observer points (they are grouped already), copy, and in your map use Paste Special [Edit <i class="fas fa-angle-right"/> Paste Special... <i class="fas fa-angle-right"/> Ok] and the cameras are now perfectly in position.</p>
<p>You can set an observer to be the welcome point <i class="fas fa-certificate"/> which is the first view players joining the map will see, enable <i class="fas fa-check-square"/>/disable <i class="fal fa-square"/> at start, set as summary <i class="fas fa-trophy"/> which will be shown at the end of the map while showing final scores. Order of cameras in this list will be the order that they will be shown in-game, change the order with the arrows on the right.</p>
<p>Sessions are saved automatically, you can close this page and when you come back you can continue off from where you were. You can click <i class="fas fa-exchange"/> and paste in another <pre>setpos...setang</pre> to replace that camera.</p>
<p>This tool is based on Idolon's <a href="http://ianspadin.com/misc/spec.html" target="_blank">Observer Camera Tool</a></p>
</div>
</div>
<div id="container">
<header>
<h1>Observer Camera Tool</h1>
<button id="about" class="fas fa-question-circle" onClick={()=>this.changeModel(true)}></button>
<button id="download" class="fas fa-arrow-alt-to-bottom" onClick={this.download} disabled={this.state.points.some(point => point.error)}></button>
</header>
<div id="input">
<input ref={node => {this.newpoint = node}} onChange={this.addPoint} disabled={this.state.new != -1} type="text" placeholder="Paste `setpos x y z;setang x y z` command here."/>
</div>
<div id="points">
{this.state.points.map((point, i) =>
<Point key={point.id} index={i} toBeDeleted={i==this.state.deleting} last={this.state.points.length-1==i} point={point}
handleTemp={this.handleTemp}
resolveTemp={this.resolveTemp}
setAsWelcome={() => this.setAsWelcome(i)}
toggleEnabled={() => this.toggleEnabled(i)}
setAsSummary={() => this.setAsSummary(i)}
handleEdit={this.handleEdit}
changeName={this.changeName}
changeAdvanced={this.changeAdvanced}
changeTeam={this.changeTeam}
recent={this.state.recent}
moveUp={() => this.moveUp(i)}
moveDown={() => this.moveDown(i)}
delete={() => this.delete(i)}
deleting={this.state.deleting}
new={this.state.new}
errorWith={this.errorWith}
/>)
}
</div>
</div>
</>
)
}
}
ReactDOM.render(<App />, document.getElementById("app"))
:root {
--background:#23282E;
--background-transparent:rgba(35, 40, 46, 0.5);
--text:white;
--text-dim:#CCD1D9;
--card-background:#2C3339;
--card-text:var(--text);
--card-text-dim:var(--text-dim);
--card-background-grab:#262C32;
--card-text-grab:#3C4349;
--input-background:#15191C;
--input-text:var(--text);
--button-red-hover:#D8334A;
--button-red-light:#ED5565;
--button-red-dark:#BF263C;
--welcome-point:#FFCE54;
--checkbox-enabled:#5D9CEC;
--match-summary:#AC92EC;
--button-text-disabled:#656D78;
--camera-height:6rem;
--grey:#656D78;
--swap-duration:1s;
}
body {
background:var(--background);
color:var(--text);
font-family: 'Open Sans', sans-serif;
overflow-x: hidden;
pre {
font-family: 'Fira Code', monospace;
}
#app {
width:calc(100% - 4rem);
max-width: 60rem;
margin:0 auto;
input, button {
outline:none;
}
button {
display:grid;
place-items: center;
border:none;
padding:.25rem;
margin:0;
background:none;
color:inherit;
transition:.25s -.1s;
&.circle {
width:1.5rem;
height:1.5rem;
border-radius: 100%;
}
&:not([disabled]) {
cursor: pointer;
&.green {
background:rgba(46, 204, 112, 0.5);
&:hover {
background:rgba(42, 186, 102, 0.75);
}
}
&.red {
background:rgba(216, 51, 73, 0.5);
&:hover {
background:rgba(191, 38, 61, 0.75);
}
}
}
}
a {
color:inherit;
text-decoration: underline;
}
input {
border:none;
background:var(--input-background);
color:var(--input-text);
}
code {
background:var(--input-background);
border-radius: .2rem;
box-shadow: 0 0 0 .25rem var(--input-background);
}
#model {
z-index:10;
display: grid;
place-items: center;
position: fixed;
top:0;
left:0;
bottom:0;
right:0;
pointer-events: none;
transition:.25s;
.click {
position: absolute;
top:0;
left:0;
right:0;
bottom:0;
background:rgba(0,0,0,0.2);
opacity:0;
transition:.25s;
}
.card {
position: relative;
max-width:40rem;
width:calc(100% - 7rem);
opacity:0;
background:var(--card-background);
padding:1rem 2rem 1rem 1rem;
border-radius: .5rem;
box-shadow:0 0 2rem -1rem black;
transition:.25s;
.close {
position: absolute;
top:1rem;
right:1rem;
}
pre {
display: inline;
background:var(--input-background);
border-radius: .2rem;
padding:0rem .5rem;
}
.fa-times {
display: inline;
margin-left: auto;
}
h2,p {
&:first-child {
margin-top:0;
}
&:last-child {
margin-bottom:0;
}
}
p {
line-height:1.15;
}
}
&.shade {
background:var(--background-transparent);
pointer-events:initial;
&~#container {
filter:blur(.15rem);
}
.card, .click {
opacity: 1;
pointer-events:initial;
}
}
#notifications {
display: grid;
grid-gap: 1rem;
position: fixed;
top:0;
left:50%;
width:calc(100% - 4rem);
max-width: 60rem;
transform:translateX(-50%);
padding:.5rem;
margin:0;
&.new {
.notification {
transform:translateY(-100%) translateY();
}
}
.notification {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
grid-gap: .5rem;
padding:1rem;
border-radius: .5rem;
background:var(--card-background);
border:var(--notification-border,var(--button-red-dark)) solid .125rem;
box-shadow: 0 0 2rem -1rem rgb(0, 0, 0), inset 0 0 2rem -1.25rem var(--notification-border,var(--button-red-dark)), 0 0 2rem -1rem var(--notification-border,var(--button-red-dark));
pointer-events:initial;
&[items="2"] {
grid-template-columns: 1fr auto auto;
}
}
}
}
#container {
display:flex;
flex-direction: column;
margin:0;
transition:.25s;
&.blur {
filter:blur(.15rem);
}
&:not(.disabletooltips) {
*[tooltip] {
position: relative;
overflow:initial;
&:before, &:after {
content: '';
position: absolute;
opacity: 0;
pointer-events:none;
}
&:hover {
&[tooltip-hoist] {
z-index:5;
}
&:before, &:after {
opacity: 1;
}
}
&:before {
border:.5rem solid transparent;
}
&:after {
content:attr(tooltip);
padding:.25rem .5rem;
border-radius: .25rem;
background:var(--input-background);
color:var(--input-text);
white-space: pre;
}
&:not([tooltip-right]):not([tooltip-left]):not([tooltip-down]), &[tooltip-up] {
&:before {
top:-.25rem;
left:50%;
border-top-color: var(--input-background);
transform:translateX(-50%);
}
&:after {
bottom:calc(100% + .25rem);
left:50%;
transform:translateX(-50%);
}
}
&[tooltip-right] {
&:before {
right:-.25rem;
top:50%;
border-right-color: var(--input-background);
transform:translateY(-50%);
}
&:after {
left:calc(100% + .25rem);
top:50%;
transform:translateY(-50%);
}
}
&[tooltip-left] {
&:before {
left:-.25rem;
top:50%;
border-left-color: var(--input-background);
transform:translateY(-50%);
}
&:after {
right:calc(100% + .25rem);
top:50%;
transform:translateY(-50%);
}
}
}
}
}
header {
display: flex;
align-items: center;
#about {
margin-top: .5rem;
margin-left:.75rem;
}
#save, #download {
width:3rem;
height:3rem;
border-radius: .5rem;
margin-left: .5rem;
background:#5D9CEC;
font-size: 1.5rem;
transition:.25s ease;
&:hover {
background: #4A89DC;
}
&[disabled] {
background:var(--grey);
opacity:0.25;
}
&.error {
background:var(--button-red-light);
&:hover {
background: var(--button-red-hover);
}
}
}
#download {
margin-left: auto;
}
}
#input {
display:grid;
input {
border-radius: .5rem;
padding:1rem;
font-size:1.25rem;
}
}
#points {
display: grid;
grid-gap: 1rem;
margin:1rem 0;
.point {
--frame:0;
display: flex;
position: relative;
height:var(--camera-height);
border-radius: .5rem;
&[frame="1"] {
--frame:1;
}
&[frame="2"] {
--frame:2;
}
&.moving {
transition:transform .25s ease;
}
&.redglow {
box-shadow:0 0 2rem -.25rem var(--button-red-hover);
}
&.new {
@keyframes new {
0% {
top:0;
left:100vw;
}
35%, 100% {
top:0;
left:0;
}
}
animation:new var(--swap-duration) ease;
}
&.deleted {
@keyframes deleted {
0% {
top:0;
left:0;
}
35%, 100% {
top:0;
left:-100vw;
}
}
animation:deleted var(--swap-duration) ease;
}
&.belowDeleted {
@keyframes belowDeleted {
0%,25% {
top:0rem;
}
75%,100% {
top:calc(-1*(var(--camera-height) + 1rem));
}
}
animation:belowDeleted var(--swap-duration) ease forwards;
}
&.moveup {
@keyframes moveup {
0%,25% {
top:calc(var(--camera-height) + 1rem);
}
75%,100% {
top:0rem;
}
}
@keyframes moveupSecondary {
0% {
top:calc(var(--camera-height) + 1rem);
left:0;
}
35% {
top:calc(var(--camera-height) + 1rem);
left:-100vw;
}
35.0000000001%, 65% {
top:0;
left:100vw;
}
100% {
left:0;
}
}
&.secondary {
animation:moveupSecondary var(--swap-duration) ease;
}
&:not(.secondary) {
animation:moveup var(--swap-duration) ease;
}
}
&.movedown {
@keyframes movedown {
0%,25% {
top:calc(-1*(var(--camera-height) + 1rem));
}
75%,100% {
top:0rem;
}
}
@keyframes movedownSecondary {
0% {
top:calc(-1*(var(--camera-height) + 1rem));
left:0;
}
35% {
top:calc(-1*(var(--camera-height) + 1rem));
left:-100vw;
}
35.0000000001%, 65% {
top:0;
left:100vw;
}
100% {
left:0;
}
}
&.secondary {
z-index:100;
animation:movedownSecondary var(--swap-duration) ease;
}
&:not(.secondary) {
animation:movedown var(--swap-duration) ease;
}
}
>.delete, .reorder {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
margin:.25rem 0;
background:var(--input-background);
color:var(--input-text);
}
>.delete {
position: relative;
width:3rem;
padding-right:.5rem;
border-top-left-radius: .5rem;
border-bottom-left-radius: .5rem;
transition:.5s ease-out;
box-shadow:inset .25rem .25rem 1rem -.75rem rgba(255,255,255,.3), 0 0 0rem -.5rem var(--button-red-hover), 0 0 0rem -.25rem var(--button-red-hover),inset 0 0 0rem 0rem rgba(0,0,0,0.4);
//box-shadow:inset .25rem .25rem 1rem -.75rem rgba(255,255,255,.3);
i {
z-index: 5;
position: relative;
}
&:before {
opacity:0;
content:'';
position: absolute;
top:.25rem;
left:.25rem;
right:0;
bottom:.25rem;
border-top-left-radius: .25rem;
border-bottom-left-radius: .25rem;
background-image: url("https://i.skylarkx.uk/hashed.svg?2");
background-size:48px 88px;
filter:blur(.1rem);
transition:.4s ease-in;
}
&:hover {
transition:.4s ease-in;
background:var(--button-red-hover);
box-shadow:inset .25rem .25rem 1rem -.75rem rgba(255,255,255,.5), 0 0 2rem -.5rem var(--button-red-hover), 0 0 1rem -.25rem var(--button-red-hover),inset 0 0 2rem 0rem rgba(0,0,0,0.4);
&:before {
opacity:1;
}
}
&:active, &.active {
transition:.1s ease-in;
background:var(--button-red-light);
box-shadow:inset .25rem .25rem 1rem -.75rem rgba(255,255,255,.6), 0 0 2rem -.5rem var(--button-red-hover), 0 0 1rem -.25rem var(--button-red-hover),inset 0 0 2rem 0rem rgba(0,0,0,0.6);
}
}
.frame {
z-index:2;
flex-grow:1;
margin:0 -.5rem;
border-radius: .5rem;
position: relative;
background:var(--card-background);
overflow:hidden;
.container {
position: absolute;
display: flex;
flex-direction: column;
top:calc(var(--frame) * -100%);
width:100%;
transition:top .5s cubic-bezier(0.5, 0, .25, 1);
>div {
height:var(--camera-height);
width:100%;
.buttons {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
width:2rem;
button[disabled] {
color:var(--grey);
opacity:0.25;
}
&.left {
position: relative;
&:before {
content: '';
position: absolute;
top:.5rem;
bottom:.5rem;
right:100%;
width:0.0625rem;
background:var(--input-background);
}
}
&.right {
position: relative;
&:after {
content: '';
position: absolute;
top:.5rem;
bottom:.5rem;
left:100%;
width:0.0625rem;
background:var(--input-background);
}
}
}
&.replacement {
display:flex;
flex-direction: row;
justify-content: center;
.input{
flex-grow: 1;
input {
width:calc(100% - 2rem);
height:100%;
padding:0 1rem;
background: none;
}
&.parsed {
display:grid;
grid-template-columns: auto auto 1fr;
grid-gap: 2rem;
//flex-direction: row;
align-items: center;
padding:0 1rem;
>div {
display:grid;
grid-template-columns: repeat(4, auto);
grid-gap: .25rem;
align-items: center;
span {
&.num {
padding:.25rem .5rem;
border-radius: .25rem;
background:var(--input-background);
}
}
}
}
}
}
&.main {
display: flex;
.buttons {
.welcomepoint, .enabled, .matchsummary {
color:var(--grey);
}
.fal {
transition:.5s color;
}
.fas.fa-badge {
color:var(--welcome-point);
}
.fas.fa-check-square {
color:var(--checkbox-enabled)
}
.fas.fa-trophy {
color:var(--match-summary);
}
}
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
.name {
input {
background:none;
padding:.5rem;
width:calc(100% - 1rem);
line-height: 100%;
}
position: relative;
&:before {
content: '';
position: absolute;
left:.5rem;
right:.5rem;
top:100%;
height:0.0625rem;
background:var(--input-background);
}
}
.details {
display: flex;
align-items: center;
margin:0 .5rem;
//place-items: center;
//grid-template-columns: repeat(3, auto) auto repeat(3, auto);
flex-grow: 1;
>.vertical {
display: flex;
flex-direction: column;
align-items: center;
border-radius: .25rem;
margin:0 .1rem;
transition:.25s;
span:first-child {
font-size: .75rem;
color:var(--grey);
user-select:none;
}
span:last-child {
font-weight:bold;
}
&.error {
font-weight:bold;
background:var(--button-red-dark);
color:white;
//padding:.1rem .25rem;
span:first-child {
color:white;
}
}
}
span[contenteditable] {
padding:.25rem .5rem;
outline:none;
/*&.degree:after {
content:'°';
pointer-events:none;
}*/
}
span.seperator {
display: block;
width:0.0625rem;
height:calc(100% - 1rem);
margin:.5rem .5rem;
background:var(--input-background);
}
.entity {
padding-right:.5rem;
font-size:1.5rem;
}
.teamselection {
display: flex;
//height:1.5rem;
height:calc(100% - 1rem);
flex-grow: 1;
border-radius: .25rem;
//overflow:hidden;
cursor: pointer;
user-select:none;
>div {
flex-grow: 1;
flex-shrink: 1;
display:grid;
place-items: center;
color:black;
font-weight:bold;
font-size:1.25rem;
transition:.25s ease;
&.any {
background: #AC92EC;
border-top-left-radius: .25rem;
border-bottom-left-radius: .25rem;
}
&.spectator {
background: #E6E9ED;
}
&.blu {
background:#4FC1E9;
}
&.red {
background: #D8334A;
border-top-right-radius: .25rem;
border-bottom-right-radius: .25rem;
}
&:not(.selected) {
opacity:.25;
}
&:not(.selected):hover {
flex-grow:1.5;
opacity:.75;
}
&.selected {
flex-grow:3;
}
}
}
}
}
}
&.advanced {
display: flex;
flex-direction: row;
.options {
flex-grow: 1;
display: grid;
place-items: center;
grid-template-columns: repeat(4, auto);
grid-template-rows: repeat(2, 1fr);
grid-gap: .5rem;
margin:.5rem;
//height:calc(100%;
//width:calc(100% - 1rem);
font-size: 1.25rem;
div {
padding:0 .25rem;
&.name {
width:100%;
text-align:right;
&.small {
font-size:.9em;
}
}
input {
width:100%;
border-radius: .25rem;
padding:.25rem;
line-height:100%;
transition:.25s;
&.error {
background-color: var(--button-red-hover);
}
}
}
}
.buttons {
margin-left: .5rem;
}
}
}
}
}
.reorder {
width:2.5rem;
padding-left:.5rem;
border-top-right-radius: .5rem;
border-bottom-right-radius: .5rem;
font-size: 1.5rem;
button {
&[disabled] {
color:var(--grey);
opacity:0.25;
}
}
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment