Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: jigsaw editor #680

Draft
wants to merge 14 commits into
base: dev
Choose a base branch
from
22 changes: 22 additions & 0 deletions src/components/Editors/Jigsaw/ComponentLib/ComponentLib.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<template>
<div class="outlined rounded-lg pa-2" style="width: 200px; height: 100%">
<v-combobox outlined dense />
<JigsawNode :boundToGrid="false" icon="mdi-heart" />
<JigsawNode :boundToGrid="false" icon="mdi-shoe-print" color="info" />
<JigsawNode :boundToGrid="false" icon="mdi-sword" color="toolbar" />
<JigsawNode :boundToGrid="false" icon="mdi-camera-timer" color="info" />
<EventLine :boundToGrid="false" />
</div>
</template>

<script>
import EventLine from '../EventLine.vue'
import JigsawNode from '../JigsawNode.vue'

export default {
components: {
JigsawNode,
EventLine,
},
}
</script>
48 changes: 48 additions & 0 deletions src/components/Editors/Jigsaw/EventLine.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<template>
<GridElement
:isEnabled="boundToGrid"
:class="`event-line rounded-lg`"
:background="`var(--v-${color}-base)`"
:x="x"
:y="y"
:yOffset="16"
:containerX="containerX"
:containerY="containerY"
/>
</template>

<script>
import GridElement from './FlowArea/GridElement.vue'
import { TranslationMixin } from '/@/components/Mixins/TranslationMixin'

export default {
components: { GridElement },
mixins: [TranslationMixin],
props: {
boundToGrid: {
type: Boolean,
default: true,
},
x: Number,
y: Number,
containerX: Number,
containerY: Number,
color: {
default: 'info',
type: String,
},
},
data: () => ({}),
}
</script>

<style scoped>
.event-line {
width: 37px;
height: 5px;
overflow: hidden;
user-select: none;
cursor: pointer;
display: inline-grid;
}
</style>
41 changes: 41 additions & 0 deletions src/components/Editors/Jigsaw/FlowArea/Grid.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<template>
<div
:style="{
top: `${(this.y % 42) - 42}px`,
left: `${(this.x % 42) - 42}px`,
}"
class="grid"
@mousedown.self="$emit('mousedown', $event)"
/>
</template>

<script>
export default {
props: {
x: Number,
y: Number,
},
}
</script>

<style scoped>
.grid {
position: absolute;
height: calc(100% + 84px);
width: calc(100% + 84px);
background-image: repeating-linear-gradient(
#555555 0 1px,
transparent 1px 100%
),
repeating-linear-gradient(90deg, #555555 0 1px, transparent 1px 100%);
background-size: 42px 42px;
background-repeat: repeat;
}
.theme--light .grid {
background-image: repeating-linear-gradient(
#a4a4a4 0 1px,
transparent 1px 100%
),
repeating-linear-gradient(90deg, #a4a4a4 0 1px, transparent 1px 100%);
}
</style>
34 changes: 34 additions & 0 deletions src/components/Editors/Jigsaw/FlowArea/GridElement.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<div
:style="{
position: isEnabled ? 'absolute' : undefined,
top: `${containerY + this.y * 42 + 3 + yOffset}px`,
left: `${containerX + this.x * 42 + 3}px`,
background,
cursor,
}"
>
<slot />
</div>
</template>

<script>
import { TranslationMixin } from '/@/components/Mixins/TranslationMixin'

export default {
mixins: [TranslationMixin],
props: {
isEnabled: {
type: Boolean,
default: true,
},
x: Number,
y: Number,
yOffset: { type: Number, default: 0 },
containerX: Number,
containerY: Number,
background: String,
cursor: String,
},
}
</script>
198 changes: 198 additions & 0 deletions src/components/Editors/Jigsaw/JigsawNode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<template>
<GridElement
:isEnabled="boundToGrid"
:class="{
[`component rounded-${roundedType}`]: true,
'shake elevation-24': isDragging,
}"
:background="`var(--v-${color}-base)`"
:cursor="cursorStyle"
:x="position.x"
:y="position.y"
:containerX="containerX"
:containerY="containerY"
@mousedown.native="onMouseDown"
>
<div
v-for="dir in connectTo"
:key="dir"
class="component rounded-lg connect"
:style="{
background: `var(--v-${color}-base)`,
left: `${dir === 'left' ? 12 : dir === 'right' ? -12 : 0}px`,
top: `${dir === 'up' ? -12 : dir === 'down' ? 12 : 0}px`,
}"
/>
<v-icon>{{ icon }}</v-icon>
</GridElement>
</template>

<script>
import GridElement from './FlowArea/GridElement.vue'
import { TranslationMixin } from '/@/components/Mixins/TranslationMixin'
import { addDisposableEventListener } from '/@/utils/disposableListener'
import { disposableTimeout } from '/@/utils/disposableTimeout'

export default {
components: { GridElement },
mixins: [TranslationMixin],
props: {
boundToGrid: { type: Boolean, default: true },
x: Number,
y: Number,
containerX: Number,
containerY: Number,
color: {
default: 'error',
type: String,
},
icon: String,
type: {
type: String,
default: 'component',
validate: (val) => ['component', 'event'].includes(val),
},
connect: {
type: [Array, String],
validate: (val) =>
val.every((v) => ['left', 'right', 'up', 'down'].includes(v)),
},
},
data: () => ({
position: {
x: 0,
y: 0,
},
mouseUpFired: false,
isDragging: false,
disposables: null,
cursorStyle: 'pointer',
mouseUpTimeout: null,
startPosData: null,
}),
mounted() {
this.position.x = this.x
this.position.y = this.y
},
computed: {
roundedType() {
switch (this.type) {
case 'component':
return 'lg'
case 'event':
return 'circle'
default:
return '0'
}
},
connectTo() {
return typeof this.connect === 'string'
? [this.connect]
: this.connect
},
},
methods: {
onMouseDown(event) {
// Wait for last mouseUpTimeout to finish
if (this.mouseUpTimeout) return
this.mouseUpFired = false
this.cursorStyle = 'grab'

this.startPosData = {
elementX: this.position.x,
elementY: this.position.y,
x: event.clientX,
y: event.clientY,
}

this.mouseUpTimeout = disposableTimeout(() => {
if (this.mouseUpFired) {
this.onClick(event)
} else {
this.onDrag(event)
}

this.mouseUpTimeout = null
}, 200)

this.disposables = [
addDisposableEventListener('mousemove', this.onMouseMove),
addDisposableEventListener('mouseup', this.onDragEnd),
]
},
onDrag(event) {
this.isDragging = true
this.cursorStyle = 'grabbing'
},
onClick() {
console.log('click')
this.cursorStyle = 'pointer'
},
onMouseMove(event) {
if (!this.isDragging) {
this.onDrag(event)
}

const { x, y, elementX, elementY } = this.startPosData

const dx = event.clientX - x
const dy = event.clientY - y
this.position.x = Math.round((elementX * 42 + dx) / 42)
this.position.y = Math.round((elementY * 42 + dy) / 42)
},
onDragEnd() {
this.isDragging = false
this.cursorStyle = 'pointer'

this.mouseUpFired = true
if (this.disposables)
this.disposables.forEach((disposable) => disposable.dispose())
this.disposables = null
this.startPosData = null
},
},
}
</script>

<style scoped>
.component {
user-select: none;
display: inline-grid;
height: 37px;
width: 37px;
}
.connect {
position: absolute;
transform: rotate(45deg) scale(0.5);
}

.shake {
z-index: 1;
animation: shake 1s;
animation-iteration-count: infinite;
transform: scale(1.2);
}

@keyframes shake {
10%,
90% {
transform: translate(-1px, 0) rotate(3deg) scale(1.2);
}

20%,
80% {
transform: translate(2px, 0) rotate(-5deg) scale(1.2);
}

30%,
50%,
70% {
transform: translate(-4px, 0) rotate(7deg) scale(1.2);
}

40%,
60% {
transform: translate(4px, 0) rotate(-7deg) scale(1.2);
}
}
</style>
37 changes: 37 additions & 0 deletions src/components/Editors/Jigsaw/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Flow Editor

Rethinking Minecraft Add-On creation.

## What is Flow Editor?

Flow is an experimental visual entity editor for Minecraft Add-Ons. Components are placed on a grid and component groups and events are inferred naturally by the way the components are connected. This allows for a more intuitive and visual way to create Minecraft Add-Ons.

## Technical Details

### Internal Representation

Flow grids will have a fixed size of horizontal cells and a theoretically infinite number of vertical cells. We are storing components within an one-dimensional array of cells. Each cell can contain a single component or an "event line" to connect components together.

### Adding components

Components can be dragged from an "inventory" onto the grid. A component group is simply defined by having multiple components right next to each other. Events are connecting component groups together. A red line means "remove this group" and a green line means "add this group".

### Editing components

We need to write a small library which can turn a JSON schema into a simple, visual user interface. It will open upon clicking on a component in the grid.

### Turning flow into code

In order to turn the visual representation of an entity into code, we first need to create a data structure that stores all component groups. This is done in the following way:

- Iterate over each cell line by line
- If the current cell is empty, jump to the next cell
- Otherwise, check if the cell above contains a component and the cell to the left contains a component
- If it does, merge the two components into one group and add the new component to the group as well
- If it doesn't, check if the cell above contains a component
- If it does, add the current component to the group above
- If it doesn't, check if the cell to the left contains a component
- If it does, add the current component to the group to the left
- Otherwise, add the current component to a new group

TODO: Event parsing
Loading