scene designer

This commit is contained in:
2025-03-13 18:02:48 +02:00
parent a199e59608
commit 96869a62e4
10 changed files with 483 additions and 76 deletions
+2 -2
View File
@@ -8,7 +8,7 @@
</template>
<v-list>
<v-list-item to="/game-objects/add">Нов игрови обект</v-list-item>
<v-list-item>Нов сценарий</v-list-item>
<v-list-item to="/scenarios/add">Нов сценарий</v-list-item>
<v-list-item to="/games/add">Нова игра</v-list-item>
</v-list>
</v-menu>
@@ -21,7 +21,7 @@
<v-divider></v-divider>
<v-list nav>
<v-list-item prepend-icon="mdi-database" to="/game-objects/list" :title="$l.gameObjects"></v-list-item>
<v-list-item prepend-icon="mdi-receipt-text-edit-outline" :title="$l.gameScenarios"></v-list-item>
<v-list-item prepend-icon="mdi-receipt-text-edit-outline" to="/scenarios/list" :title="$l.gameScenarios"></v-list-item>
<v-list-item prepend-icon="mdi-cogs" :title="$l.gameRules"></v-list-item>
<v-divider></v-divider>
<v-list-item prepend-icon="mdi-controller" :title="$l.games" to="/games/list"></v-list-item>
@@ -0,0 +1,269 @@
<template>
<div class="container my-3">
<v-btn-toggle variant="tonal" density="compact" class="mx-auto" v-model="listMode" color="blue">
<v-btn class="text-none" value="scene"><v-icon>mdi-panorama-outline</v-icon><span>{{ $l.addScene }}</span></v-btn>
<v-btn class="text-none" value="object"><v-icon>mdi-bird</v-icon><span>{{ $l.addScene }}</span></v-btn>
<v-btn class="text-none" value="task"><v-icon>mdi-checkbox-marked-circle-plus-outline</v-icon><span>{{ $l.addScene }}</span></v-btn>
</v-btn-toggle>
<div @wheel="onWheel" @mousedown="onMouseDown" @mouseup="onMouseUp" @mousemove="onDrag"
:class="`svg-container ${mode}`" ref="svgContainer">
<svg>
</svg>
</div>
<v-navigation-drawer location="right">
<svg-scene></svg-scene>
</v-navigation-drawer>
</div>
</template>
<script>
import SvgScene from './SvgScene.vue';
const components = {
SvgScene
}
export default {
components: { SvgScene },
props:{
modelValue: Object
},
data(){
return {
listMode: 'select'
}
},
computed:{
object:()=>this.modelValue,
zoom:{
get(){
return 1 / this.scale;
},
set(v){
this.rescale(1 / v);
}
},
mode(){
return this.listMode[0];
},
components(){
return components;
},
},
methods:{
rescale(scale, e){
let oldScale = this.scale;
if (!e){
this.scale = scale;
this.offset.x += (oldScale - this.scale) * (this.$refs.svgContainer.offsetWidth/2);
this.offset.y += (oldScale - this.scale) * (this.$refs.svgContainer.offsetHeight/2);
}else{
this.scale *= (1 + Math.sign(e.deltaY) / 10);
let oo = {
x: (oldScale - this.scale) * e.offsetX,
y: (oldScale - this.scale) * e.offsetY
};
if (this.target){
this.retarget(e)
}
this.offset.x += oo.x;
this.offset.y += oo.y
}
document.documentElement.style.setProperty('--svg-scale', this.scale);
},
retarget(e){
if (this.target.delta){
this.retargetDelta(e, this.target)
}else{
this.retargetAbsolute(e, this.target)
}
},
retargetDelta(e, target){
let p = {
x: Utils.round(e.movementX*this.scale, 0),
y: Utils.round(e.movementY*this.scale, 0)
}
target.attrs.forEach(a=>{
if (Array.isArray(a)){
a[0] = p.x;
a[1] = p.y;
}else if (a.startsWith('x')){
this.target.target[a] += p.x;
}else if (a.startsWith('y')){
this.target.target[a] += p.y;
}
})
},
retargetAbsolute(e, target){
let p = {
x: Utils.round(this.vb.x + e.offsetX * this.scale),
y: Utils.round(this.vb.y + e.offsetY * this.scale)
}
target.attrs.forEach(a=>{
if (Array.isArray(a)){
a[0] = p.x;
a[1] = p.y;
}else if (a.startsWith('x')){
this.target.target[a] = p.x;
}else if (a.startsWith('y')){
this.target.target[a] = p.y;
}
})
},
onWheel(e){
this.rescale(null, e);
},
onDrag(e){
if (this.mousedown?.button == 0 || this.modeStep > 0) {
if (this.mode == 'move'){
let p = {
x: Utils.round(e.movementX*this.scale, 0),
y: Utils.round(e.movementY*this.scale, 0)
}
this.selectedItem.forEach(i=>{
let mf = components[i.name].modifiers;
mf.filter(m=>m.match(/^x[0-9]+$/)).forEach(x=>i.data[x]+= p.x);
mf.filter(m=>m.match(/^y[0-9]+$/)).forEach(y=>i.data[y]+= p.y);
})
}else if (this.target) {
this.retarget(e)
}
}
if (e.shiftKey ||
this.mousedown?.button == 1 ||
(this.mode == 'pan' && this.mousedown?.button == 0) ||
(this.mode == 'default' && this.mousedown?.button == 0 && !this.target)
){
this.offset.x -= e.movementX*this.scale;
this.offset.y -= e.movementY*this.scale;
}
//console.log(e);
},
onMouseDown(e){
this.mousedown = { button: e.button };
//console.log(e, this.mode, this.modeStep)
if (e.button == 0 && !['default', 'move', 'pan'].includes( this.mode )){
let cs;
if (this.mode == 'select'){
//console.log('selecting')
cs = [['x1', 'y1'], ['x2', 'y2']];
if (this.modeStep == 0){
this.target = {
target: this.selector,
attrs: []
}
}
}else{
cs = this.components[this.mode].steps;
//console.log(cs);
if (this.modeStep == 0){
this.target = {
target: {},
attrs: []
}
let id, nid = 1;
do {
id = `${this.components[this.mode].name}-${nid++}`
}while (this.items.find(i=>i.id == id))
this.items.push({
name: this.mode,
data: this.target.target,
visible: true,
id, title: id
})
}
}
let p = {
x: Utils.round(this.vb.x + e.offsetX * this.scale),
y: Utils.round(this.vb.y + e.offsetY * this.scale)
}
for (let i = this.modeStep + 1; i <= cs.length; i++){
this.target.target[cs[i-1][0]] = p.x;
this.target.target[cs[i-1][1]] = p.y;
}
this.modeStep++;
if (this.modeStep >= cs.length){
this.modeStep = 0;
if (this.mode == 'select'){
this.select();
this.selector.x1 = this.selector.y1 = this.selector.x2 = this.selector.y2 = 0;
}
}
if (this.modeStep ){
this.target.attrs[0] = cs[this.modeStep][0];
this.target.attrs[1] = cs[this.modeStep][1];
}
}
},
onMouseUp(){
this.mousedown = false;
if (this.mode == 'default' && !this.target){
this.selectedItem = [];
}
if (this.modeStep == 0){
this.target = null;
}
},
setTarget(t, item){
this.target = t;
this.selectedItem = [item]
},
async save(){
let imageData = await Utils.blobToBase64(await (await fetch(this.imageTarget)).blob());
//console.log(imageData)
let jsonData = {
items: this.items,
viewBox: this.viewBox,
offset: this.offset,
zoom: this.zoom,
rotation: this.imgRotation,
page: this.page,
data: imageData
};
const link = document.createElement('a')
const blob = new Blob([JSON.stringify(jsonData)], {type: 'application/json'});
link.href = URL.createObjectURL(blob)
link.download = "document.json"
link.click();
},
select(){
let r = Utils.adjustMinMax(this.selector);
this.selectedItem = this.items.filter(i=>this.$refs['svg-'+i.id][0].intersect(r));
},
async processImage(){
this.processingImage = true;
await this.$nextTick();
let processor = new ImageProcessor(this.$refs.imageCanvas, this.img)
let lines = processor.identifyLines(), iw = this.img.naturalWidth;
lines.forEach((l, i)=>{
this.items.push({
name: 'SvgHorizontalLine', id: `aline-${i}`, visible: true, title: `ALine-${i}`,
data: { x1:iw*.1, y1: l, x2: iw*.9}
})
})
this.processingImage = false;
}
}
}
</script>
<style lang="scss">
.svg-container{
svg{
width: 100%;
min-height: 100vh;
}
image{
clip-path: circle(50% at 50% 50%);
}
circle {
stroke: rgb(var(--v-theme-primary));
fill:rgba(255,255,255,.5);
stroke-width: 2px;
}
}
</style>
@@ -0,0 +1,28 @@
<template>
<circle :cx="65 + x" :cy="65 + y" :r="70"></circle>
<image :href="src" :x="x" :y="y" height="130" width="130" preserveAspectRatio="xMidYMid slice"></image>
</template>
<script>
export default {
props:['src', 'x', 'y'],
data(){
return {
target: null,
img: null,
size:{}
}
},
created(){
// this.img = new Image();
// this.img.onload = ()=>{
// this.size = {
// w: this.img.naturalWidth,
// h: this.img.naturalHeight,
// a: this.img.naturalWidth / this.img.naturalHeight
// }
// }
// this.img.src= this.target;
}
}
</script>
+25
View File
@@ -0,0 +1,25 @@
<template>
<teleport to="svg" defer>
<g>
<svg-avatar src="/asset/thumb/6.webp" :x="50" :y="50"></svg-avatar>
</g>
</teleport>
<v-list density="compact" nav>
<v-list-item prepend-icon="mdi-panorama-outline" :title="$l.addScene" value="scene"></v-list-item>
</v-list>
</template>
<script>
import SvgAvatar from './SvgAvatar.vue';
export default {
components: { SvgAvatar },
data(){
return {
active: false
}
},
mounted(){
this.active = true;
}
}
</script>
+7
View File
@@ -35,6 +35,7 @@ class GameEngine {
// renderer.shadowMap.type = THREE.PCFSoftShadowMap; // default THREE.PCFShadowMap
renderer.outputEncoding = THREE.sRGBEncoding;
const controls = new OrbitControls( camera, renderer.domElement );
//controls.enableZoom = true;
//const controls = new MapControls( camera, renderer.domElement );
this.transformControls = new TransformControls( camera, renderer.domElement );
this.transformControls.addEventListener( 'dragging-changed', function ( event ) {
@@ -64,6 +65,12 @@ class GameEngine {
scene.background = bck; //new THREE.Color(0.7,0.7,0.7);
scene.environment = texture;
console.log('GameEngine started')
renderer.domElement.addEventListener('wheel', (event)=>{
camera.zoom -= event.deltaY / 1000;
camera.zoom = Math.max(camera.zoom, .4);
controls.rotateSpeed = 1 / camera.zoom;
camera.updateProjectionMatrix();
})
}
$ = THREE;
+43
View File
@@ -0,0 +1,43 @@
<template>
<v-card :title="id == 'add' ? $l.createScenario : $l.editScenario" class="container my-3">
<v-form class="pa-4" v-model="valid">
<v-text-field :label="$l.name" v-model="object.name" :rules="[rules.required]"></v-text-field>
</v-form>
<!-- <v-card-actions>
<v-btn @click="saveAndPreview" :loading="loading" prepend-icon="mdi-content-save" color="primary"
:disabled="!valid">
{{ $l.saveAndPreview }}
</v-btn>
<v-btn @click="publish" prepend-icon="mdi-publish" color="success" v-if="false && object.id">{{ $l.publish
}}</v-btn>
</v-card-actions> -->
</v-card>
<SceneDesigner v-model="object"></SceneDesigner>
<div class="sceneDrawer" >
</div>
</template>
<script>
import SceneDesigner from '@/components/SceneDesigner/SceneDesigner.vue';
export default {
data() {
return {
object: {},
valid: false,
rules: {
required: v => v ? true : this.$l.fieldRequired,
requiredFile: v => (v?.length || this.id != 'add') ? true : this.$l.fieldRequired
},
loading: false,
}
},
computed: {
id() {
return this.$route.params?.id;
}
},
}
</script>
+9
View File
@@ -0,0 +1,9 @@
<template>
</template>
<script>
export default {
}
</script>
+28 -2
View File
@@ -23,10 +23,36 @@ const lang = {
darkMode: 'Тъмен режим',
confirmDeletionOf: 'Потвърдете изтриването на',
yes: 'Да',
no: 'Не'
no: 'Не',
createScenario: 'Създаване на сценарий',
editScenario: 'Редкатиране на сценарий',
addScene: 'Добавяне на сцена'
},
en: {
createGameObject: 'Add game object',
editGameObject: 'Edit game object',
name: 'Name',
fieldRequired: 'Field is required',
objectType: 'Object type',
objectFile: 'File',
panorama2d: 'Panorama picture',
environment3d: 'Environment',
object3d: '3D object',
object2d: '2D object (picture)',
audio: 'Audio',
player3d: 'Player',
saveAndPreview: 'Save and preview',
preview: 'Preview',
captureThumbnail: 'Save thumbnail',
publish: 'Publish',
gameObjects: 'Objects',
gameScenarios: 'Scenarios',
gameRules: 'Rules',
games: 'Games',
darkMode: 'Dark mode',
confirmDeletionOf: 'Confirm deletion of',
yes: 'Yes',
no: 'No'
}
}