mirror of
https://github.com/MapMakersAndProgrammers/io_scene_a3d.git
synced 2025-10-26 09:59:11 -07:00
Merge map.bin support into main
Support for loading map.bin files
This commit is contained in:
BIN
images/demo7.png
Normal file
BIN
images/demo7.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
BIN
images/demo8.png
Normal file
BIN
images/demo8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 975 KiB |
@@ -32,21 +32,7 @@ from .A3DObjects import (
|
|||||||
A3D_VERTEXTYPE_COLOR,
|
A3D_VERTEXTYPE_COLOR,
|
||||||
A3D_VERTEXTYPE_NORMAL2
|
A3D_VERTEXTYPE_NORMAL2
|
||||||
)
|
)
|
||||||
|
from .BlenderMaterialUtils import addImageTextureToMaterial
|
||||||
def addImageTextureToMaterial(image, node_tree):
|
|
||||||
nodes = node_tree.nodes
|
|
||||||
links = node_tree.links
|
|
||||||
|
|
||||||
# Check if this material already has a texture on it
|
|
||||||
if len(nodes) > 2:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create nodes
|
|
||||||
principledBSDFNode = nodes[0]
|
|
||||||
textureNode = nodes.new(type="ShaderNodeTexImage")
|
|
||||||
links.new(textureNode.outputs["Color"], principledBSDFNode.inputs["Base Color"])
|
|
||||||
# Apply image
|
|
||||||
if image != None: textureNode.image = image
|
|
||||||
|
|
||||||
def mirrorUVY(uv):
|
def mirrorUVY(uv):
|
||||||
x, y = uv
|
x, y = uv
|
||||||
@@ -171,8 +157,16 @@ class A3DBlenderImporter:
|
|||||||
me.polygons[faceI+faceIndexBase].material_index = submeshI
|
me.polygons[faceI+faceIndexBase].material_index = submeshI
|
||||||
faceIndexBase += submesh.indexCount//3
|
faceIndexBase += submesh.indexCount//3
|
||||||
|
|
||||||
# Finalise
|
#XXX: call this before we assign split normals, if you do not it causes a segmentation fault
|
||||||
me.validate()
|
me.validate()
|
||||||
|
|
||||||
|
# Split normals
|
||||||
|
if len(normal1) != 0:
|
||||||
|
me.normals_split_custom_set_from_vertices(normal1)
|
||||||
|
elif len(normal2) != 0:
|
||||||
|
me.normals_split_custom_set_from_vertices(normal2)
|
||||||
|
|
||||||
|
# Finalise
|
||||||
me.update()
|
me.update()
|
||||||
return me
|
return me
|
||||||
|
|
||||||
|
|||||||
163
io_scene_a3d/AlternativaProtocol.py
Normal file
163
io_scene_a3d/AlternativaProtocol.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'''
|
||||||
|
Copyright (c) 2025 Pyogenics
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
'''
|
||||||
|
|
||||||
|
from zlib import decompress
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from .IOTools import unpackStream
|
||||||
|
|
||||||
|
def unwrapPacket(stream):
|
||||||
|
print("Unwrapping packet")
|
||||||
|
|
||||||
|
# Determine size and compression
|
||||||
|
packetFlags = int.from_bytes(stream.read(1))
|
||||||
|
compressedPacket = (packetFlags & 0b01000000) > 0
|
||||||
|
|
||||||
|
packetLength = 0
|
||||||
|
packetLengthType = packetFlags & 0b10000000
|
||||||
|
if packetLengthType == 0:
|
||||||
|
# This is a short packet
|
||||||
|
packetLength = int.from_bytes(stream.read(1))
|
||||||
|
packetLength += (packetFlags & 0b00111111) << 8 # Part of the length is embedded in the flags field
|
||||||
|
else:
|
||||||
|
# This is a long packet
|
||||||
|
packetLength = int.from_bytes(stream.read(3), "big")
|
||||||
|
packetLength += (packetFlags & 0b00111111) << 24
|
||||||
|
|
||||||
|
# Decompress the packet if needed
|
||||||
|
packetData = stream.read(packetLength)
|
||||||
|
if compressedPacket:
|
||||||
|
print("Decompressing packet")
|
||||||
|
packetData = decompress(packetData)
|
||||||
|
|
||||||
|
return BytesIO(packetData)
|
||||||
|
|
||||||
|
def readOptionalMask(stream):
|
||||||
|
print("Reading optional mask")
|
||||||
|
|
||||||
|
optionalMask = []
|
||||||
|
|
||||||
|
# Determine mask type (there are multiple length types)
|
||||||
|
maskFlags = int.from_bytes(stream.read(1))
|
||||||
|
maskLengthType = maskFlags & 0b10000000
|
||||||
|
if maskLengthType == 0:
|
||||||
|
# Short mask: 5 optional bits + upto 3 extra bytes
|
||||||
|
# First read the integrated optional bits
|
||||||
|
integratedOptionalBits = maskFlags << 3 # Trim flag bits so we're left with the optionals and some padding bits
|
||||||
|
for bitI in range(7, 2, -1): #0b11111000 left to right
|
||||||
|
optional = (integratedOptionalBits & 2**bitI) == 0
|
||||||
|
optionalMask.append(optional)
|
||||||
|
|
||||||
|
# Now read the external bytes
|
||||||
|
externalByteCount = (maskFlags & 0b01100000) >> 5
|
||||||
|
externalBytes = stream.read(externalByteCount)
|
||||||
|
for externalByte in externalBytes:
|
||||||
|
for bitI in range(7, -1, -1): #0b11111111 left to right
|
||||||
|
optional = (externalByte & 2**bitI) == 0
|
||||||
|
optionalMask.append(optional)
|
||||||
|
else:
|
||||||
|
# This type of mask encodes an extra length/count field to increase the number of possible optionals significantly
|
||||||
|
maskLengthType = maskFlags & 0b01000000
|
||||||
|
externalByteCount = 0
|
||||||
|
if maskLengthType == 0:
|
||||||
|
# Medium mask: stores number of bytes used for the optional mask in the last 6 bits of the flags
|
||||||
|
externalByteCount = maskFlags & 0b00111111
|
||||||
|
else:
|
||||||
|
# Long mask: # Medium mask: stores number of bytes used for the optional mask in the last 6 bits of the flags + 2 extra bytes
|
||||||
|
externalByteCount = (maskFlags & 0b00111111) << 16
|
||||||
|
externalByteCount += int.from_bytes(stream.read(2), "big")
|
||||||
|
|
||||||
|
# Read the external bytes
|
||||||
|
externalBytes = stream.read(externalByteCount)
|
||||||
|
for externalByte in externalBytes:
|
||||||
|
for bitI in range(7, -1, -1): #0b11111111 left to right
|
||||||
|
optional = (externalByte & 2**bitI) == 0
|
||||||
|
optionalMask.append(optional)
|
||||||
|
|
||||||
|
optionalMask.reverse()
|
||||||
|
return optionalMask
|
||||||
|
|
||||||
|
'''
|
||||||
|
Array type readers
|
||||||
|
'''
|
||||||
|
def readArrayLength(packet):
|
||||||
|
arrayLength = 0
|
||||||
|
|
||||||
|
arrayFlags = int.from_bytes(packet.read(1))
|
||||||
|
arrayLengthType = arrayFlags & 0b10000000
|
||||||
|
if arrayLengthType == 0:
|
||||||
|
# Short array
|
||||||
|
arrayLength = arrayFlags & 0b01111111
|
||||||
|
else:
|
||||||
|
# Long array
|
||||||
|
arrayLengthType = arrayFlags & 0b01000000
|
||||||
|
if arrayLengthType == 0:
|
||||||
|
# Length in last 6 bits of flags + next byte
|
||||||
|
arrayLength = (arrayFlags & 0b00111111) << 8
|
||||||
|
arrayLength += int.from_bytes(packet.read(1))
|
||||||
|
else:
|
||||||
|
# Length in last 6 bits of flags + next 2 byte
|
||||||
|
arrayLength = (arrayFlags & 0b00111111) << 16
|
||||||
|
arrayLength += int.from_bytes(packet.read(2), "big")
|
||||||
|
|
||||||
|
return arrayLength
|
||||||
|
|
||||||
|
def readObjectArray(packet, objReader, optionalMask):
|
||||||
|
arrayLength = readArrayLength(packet)
|
||||||
|
objects = []
|
||||||
|
for _ in range(arrayLength):
|
||||||
|
obj = objReader()
|
||||||
|
obj.read(packet, optionalMask)
|
||||||
|
objects.append(obj)
|
||||||
|
|
||||||
|
return objects
|
||||||
|
|
||||||
|
def readString(packet):
|
||||||
|
stringLength = readArrayLength(packet)
|
||||||
|
string = packet.read(stringLength)
|
||||||
|
string = string.decode("utf-8")
|
||||||
|
|
||||||
|
return string
|
||||||
|
|
||||||
|
def readInt16Array(packet):
|
||||||
|
arrayLength = readArrayLength(packet)
|
||||||
|
integers = unpackStream(f"{arrayLength}h", packet)
|
||||||
|
|
||||||
|
return list(integers)
|
||||||
|
|
||||||
|
def readIntArray(packet):
|
||||||
|
arrayLength = readArrayLength(packet)
|
||||||
|
integers = unpackStream(f"{arrayLength}i", packet)
|
||||||
|
|
||||||
|
return list(integers)
|
||||||
|
|
||||||
|
def readInt64Array(packet):
|
||||||
|
arrayLength = readArrayLength(packet)
|
||||||
|
integers = unpackStream(f"{arrayLength}q", packet)
|
||||||
|
|
||||||
|
return list(integers)
|
||||||
|
|
||||||
|
def readFloatArray(packet):
|
||||||
|
arrayLength = readArrayLength(packet)
|
||||||
|
floats = unpackStream(f">{arrayLength}f", packet)
|
||||||
|
|
||||||
|
return list(floats)
|
||||||
276
io_scene_a3d/BattleMap.py
Normal file
276
io_scene_a3d/BattleMap.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
'''
|
||||||
|
Copyright (c) 2024 Pyogenics
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
'''
|
||||||
|
|
||||||
|
from .IOTools import unpackStream
|
||||||
|
from . import AlternativaProtocol
|
||||||
|
|
||||||
|
'''
|
||||||
|
Objects
|
||||||
|
'''
|
||||||
|
class AtlasRect:
|
||||||
|
def __init__(self):
|
||||||
|
self.height = 0
|
||||||
|
self.libraryName = ""
|
||||||
|
self.name = ""
|
||||||
|
self.width = 0
|
||||||
|
self.x = 0
|
||||||
|
self.y = 0
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.height, = unpackStream(">I", stream)
|
||||||
|
self.libraryName = AlternativaProtocol.readString(stream)
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
self.width, self.x, self.y = unpackStream(">3I", stream)
|
||||||
|
|
||||||
|
class CollisionBox:
|
||||||
|
def __init__(self):
|
||||||
|
self.position = (0.0, 0.0, 0.0)
|
||||||
|
self.rotation = (0.0, 0.0, 0.0)
|
||||||
|
self.size = (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.position = unpackStream(">3f", stream)
|
||||||
|
self.rotation = unpackStream(">3f", stream)
|
||||||
|
self.size = unpackStream(">3f", stream)
|
||||||
|
|
||||||
|
class CollisionPlane:
|
||||||
|
def __init__(self):
|
||||||
|
self.length = 0.0
|
||||||
|
self.position = (0.0, 0.0, 0.0)
|
||||||
|
self.rotation = (0.0, 0.0, 0.0)
|
||||||
|
self.width = 0.0
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.length, = unpackStream(">d", stream)
|
||||||
|
self.position = unpackStream(">3f", stream)
|
||||||
|
self.rotation = unpackStream(">3f", stream)
|
||||||
|
self.width, = unpackStream(">d", stream)
|
||||||
|
|
||||||
|
class CollisionTriangle:
|
||||||
|
def __init__(self):
|
||||||
|
self.length = 0.0
|
||||||
|
self.position = (0.0, 0.0, 0.0)
|
||||||
|
self.rotation = (0.0, 0.0, 0.0)
|
||||||
|
self.v0 = (0.0, 0.0, 0.0)
|
||||||
|
self.v1 = (0.0, 0.0, 0.0)
|
||||||
|
self.v2 = (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.length, = unpackStream(">d", stream)
|
||||||
|
self.position = unpackStream(">3f", stream)
|
||||||
|
self.rotation = unpackStream(">3f", stream)
|
||||||
|
self.v0 = unpackStream(">3f", stream)
|
||||||
|
self.v1 = unpackStream(">3f", stream)
|
||||||
|
self.v2 = unpackStream(">3f", stream)
|
||||||
|
|
||||||
|
class ScalarParameter:
|
||||||
|
def __init__(self):
|
||||||
|
self.name = ""
|
||||||
|
self.value = 0.0
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
self.value, = unpackStream(">f", stream)
|
||||||
|
|
||||||
|
class TextureParameter:
|
||||||
|
def __init__(self):
|
||||||
|
self.name = ""
|
||||||
|
self.textureName = ""
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
self.libraryName = None
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.libraryName = AlternativaProtocol.readString(stream)
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
self.textureName = AlternativaProtocol.readString(stream)
|
||||||
|
|
||||||
|
class Vector2Parameter:
|
||||||
|
def __init__(self):
|
||||||
|
self.name = ""
|
||||||
|
self.value = (0.0, 0.0)
|
||||||
|
|
||||||
|
def __init__(self, stream, optionalMask):
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
self.value = unpackStream(">2f", stream)
|
||||||
|
|
||||||
|
class Vector3Parameter:
|
||||||
|
def __init__(self):
|
||||||
|
self.name = ""
|
||||||
|
self.value = (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
def __init__(self, stream, optionalMask):
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
self.value = unpackStream(">3f", stream)
|
||||||
|
|
||||||
|
class Vector4Parameter:
|
||||||
|
def __init__(self):
|
||||||
|
self.name = ""
|
||||||
|
self.value = (0.0, 0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
self.value = unpackStream(">4f", stream)
|
||||||
|
|
||||||
|
'''
|
||||||
|
Main objects
|
||||||
|
'''
|
||||||
|
class Atlas:
|
||||||
|
def __init__(self):
|
||||||
|
self.height = 0
|
||||||
|
self.name = ""
|
||||||
|
self.padding = 0
|
||||||
|
self.rects = []
|
||||||
|
self.width = 0
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.height, unpackStream(">i", stream)
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
self.padding = unpackStream(">I", stream)
|
||||||
|
self.rects = AlternativaProtocol.readObjectArray(stream, AtlasRect, optionalMask)
|
||||||
|
self.width, = unpackStream(">I", stream)
|
||||||
|
|
||||||
|
class Batch:
|
||||||
|
def __init__(self):
|
||||||
|
self.materialID = 0
|
||||||
|
self.name = ""
|
||||||
|
self.position = (0.0, 0.0, 0.0)
|
||||||
|
self.propIDs = ""
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.materialID, = unpackStream(">I", stream)
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
self.position = unpackStream(">3f", stream)
|
||||||
|
self.propIDs = AlternativaProtocol.readString(stream)
|
||||||
|
|
||||||
|
class CollisionGeometry:
|
||||||
|
def __init__(self):
|
||||||
|
self.boxes = []
|
||||||
|
self.planes = []
|
||||||
|
self.triangles = []
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.boxes = AlternativaProtocol.readObjectArray(stream, CollisionBox, optionalMask)
|
||||||
|
self.planes = AlternativaProtocol.readObjectArray(stream, CollisionPlane, optionalMask)
|
||||||
|
self.triangles = AlternativaProtocol.readObjectArray(stream, CollisionTriangle, optionalMask)
|
||||||
|
|
||||||
|
class Material:
|
||||||
|
def __init__(self):
|
||||||
|
self.ID = 0
|
||||||
|
self.name = ""
|
||||||
|
self.shader = ""
|
||||||
|
self.textureParameters = None
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
self.scalarParameters = None
|
||||||
|
self.vector2Parameters = None
|
||||||
|
self.vector3Parameters = None
|
||||||
|
self.vector4Parameters = None
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.ID, = unpackStream(">I", stream)
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.scalarParameters = AlternativaProtocol.readObjectArray(stream, ScalarParameter, optionalMask)
|
||||||
|
self.shader = AlternativaProtocol.readString(stream)
|
||||||
|
self.textureParameters = AlternativaProtocol.readObjectArray(stream, TextureParameter, optionalMask)
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.vector2Parameters = AlternativaProtocol.readObjectArray(stream, Vector2Parameter, optionalMask)
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.vector3Parameters = AlternativaProtocol.readObjectArray(stream, Vector3Parameter, optionalMask)
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.vector4Parameters = AlternativaProtocol.readObjectArray(stream, Vector4Parameter, optionalMask)
|
||||||
|
|
||||||
|
class SpawnPoint:
|
||||||
|
def __init__(self):
|
||||||
|
self.position = (0.0, 0.0, 0.0)
|
||||||
|
self.rotation = (0.0, 0.0, 0.0)
|
||||||
|
self.type = 0
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
self.position = unpackStream(">3f", stream)
|
||||||
|
self.rotation = unpackStream(">3f", stream)
|
||||||
|
self.type, = unpackStream(">I", stream)
|
||||||
|
|
||||||
|
class Prop:
|
||||||
|
def __init__(self):
|
||||||
|
self.ID = 0
|
||||||
|
self.libraryName = ""
|
||||||
|
self.materialID = 0
|
||||||
|
self.name = ""
|
||||||
|
self.position = (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
self.groupName = None
|
||||||
|
self.rotation = None
|
||||||
|
self.scale = None
|
||||||
|
|
||||||
|
def read(self, stream, optionalMask):
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.groupName = AlternativaProtocol.readString(stream)
|
||||||
|
self.ID, = unpackStream(">I", stream)
|
||||||
|
self.libraryName = AlternativaProtocol.readString(stream)
|
||||||
|
self.materialID, = unpackStream(">I", stream)
|
||||||
|
self.name = AlternativaProtocol.readString(stream)
|
||||||
|
self.position = unpackStream(">3f", stream)
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.rotation = unpackStream(">3f", stream)
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.scale = unpackStream(">3f", stream)
|
||||||
|
|
||||||
|
'''
|
||||||
|
Main
|
||||||
|
'''
|
||||||
|
class BattleMap:
|
||||||
|
def __init__(self):
|
||||||
|
self.atlases = []
|
||||||
|
self.batches = []
|
||||||
|
self.collisionGeometry = None
|
||||||
|
self.collisionGeometryOutsideGamingZone = None
|
||||||
|
self.materials = []
|
||||||
|
self.spawnPoints = []
|
||||||
|
self.staticGeometry = []
|
||||||
|
|
||||||
|
'''
|
||||||
|
IO
|
||||||
|
'''
|
||||||
|
def read(self, stream):
|
||||||
|
print("Reading BattleMap")
|
||||||
|
|
||||||
|
# Read packet
|
||||||
|
packet = AlternativaProtocol.unwrapPacket(stream)
|
||||||
|
optionalMask = AlternativaProtocol.readOptionalMask(packet)
|
||||||
|
|
||||||
|
# Read data
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.atlases = AlternativaProtocol.readObjectArray(packet, Atlas, optionalMask)
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.batches = AlternativaProtocol.readObjectArray(packet, Batch, optionalMask)
|
||||||
|
self.collisionGeometry = CollisionGeometry()
|
||||||
|
self.collisionGeometry.read(packet, optionalMask)
|
||||||
|
self.collisionGeometryOutsideGamingZone = CollisionGeometry()
|
||||||
|
self.collisionGeometryOutsideGamingZone.read(packet, optionalMask)
|
||||||
|
self.materials = AlternativaProtocol.readObjectArray(packet, Material, optionalMask)
|
||||||
|
if optionalMask.pop():
|
||||||
|
self.spawnPoints = AlternativaProtocol.readObjectArray(packet, SpawnPoint, optionalMask)
|
||||||
|
self.staticGeometry = AlternativaProtocol.readObjectArray(packet, Prop, optionalMask)
|
||||||
445
io_scene_a3d/BattleMapBlenderImporter.py
Normal file
445
io_scene_a3d/BattleMapBlenderImporter.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
'''
|
||||||
|
Copyright (c) 2025 Pyogenics <https://github.com/Pyogenics>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
'''
|
||||||
|
|
||||||
|
from json import load
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from bpy_extras.image_utils import load_image
|
||||||
|
from bpy_extras.node_shader_utils import PrincipledBSDFWrapper
|
||||||
|
import bmesh
|
||||||
|
from mathutils import Matrix
|
||||||
|
|
||||||
|
from .A3D import A3D
|
||||||
|
from .A3DBlenderImporter import A3DBlenderImporter
|
||||||
|
from .BlenderMaterialUtils import addImageTextureToMaterial, decodeIntColorToTuple
|
||||||
|
|
||||||
|
class BattleMapBlenderImporter:
|
||||||
|
# Allows subsequent map loads to be faster
|
||||||
|
libraryCache = {}
|
||||||
|
|
||||||
|
def __init__(self, mapData, lightmapData, propLibrarySourcePath, map_scale_factor=0.01, import_static_geom=True, import_collision_geom=False, import_spawn_points=False, import_lightmapdata=False):
|
||||||
|
self.mapData = mapData
|
||||||
|
self.lightmapData = lightmapData
|
||||||
|
self.propLibrarySourcePath = propLibrarySourcePath
|
||||||
|
self.map_scale_factor = map_scale_factor
|
||||||
|
self.import_static_geom = import_static_geom
|
||||||
|
self.import_collision_geom = import_collision_geom
|
||||||
|
self.import_spawn_points = import_spawn_points
|
||||||
|
self.import_lightmapdata = import_lightmapdata
|
||||||
|
|
||||||
|
# Cache for collision meshes, don't cache triangles because they are set using unique vertices
|
||||||
|
self.collisionPlaneMesh = None
|
||||||
|
self.collisionBoxMesh = None
|
||||||
|
|
||||||
|
self.materials = {}
|
||||||
|
|
||||||
|
def importData(self):
|
||||||
|
print("Importing BattleMap data into blender")
|
||||||
|
|
||||||
|
# Process materials
|
||||||
|
for materialData in self.mapData.materials:
|
||||||
|
ma = self.createBlenderMaterial(materialData)
|
||||||
|
self.materials[materialData.ID] = ma
|
||||||
|
|
||||||
|
# Static geometry
|
||||||
|
propObjects = []
|
||||||
|
if self.import_static_geom:
|
||||||
|
# Load props
|
||||||
|
for propData in self.mapData.staticGeometry:
|
||||||
|
ob = self.getBlenderProp(propData)
|
||||||
|
propObjects.append(ob)
|
||||||
|
print(f"Loaded {len(propObjects)} prop objects")
|
||||||
|
|
||||||
|
# Collision geometry
|
||||||
|
collisionObjects = []
|
||||||
|
if self.import_collision_geom:
|
||||||
|
# Create collision meshes
|
||||||
|
self.collisionPlaneMesh = bpy.data.meshes.new("collisionPlane")
|
||||||
|
bm = bmesh.new()
|
||||||
|
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1.0)
|
||||||
|
bm.to_mesh(self.collisionPlaneMesh)
|
||||||
|
bm.free()
|
||||||
|
|
||||||
|
self.collisionBoxMesh = bpy.data.meshes.new("collisionBox")
|
||||||
|
bm = bmesh.new()
|
||||||
|
bmesh.ops.create_cube(bm)
|
||||||
|
bm.to_mesh(self.collisionBoxMesh)
|
||||||
|
bm.free()
|
||||||
|
|
||||||
|
# Load collision meshes
|
||||||
|
collisionTriangles = self.mapData.collisionGeometry.triangles + self.mapData.collisionGeometryOutsideGamingZone.triangles
|
||||||
|
collisionTriangleObjects = self.createBlenderCollisionTriangles(collisionTriangles)
|
||||||
|
collisionPlanes = self.mapData.collisionGeometry.planes + self.mapData.collisionGeometryOutsideGamingZone.planes
|
||||||
|
collisionPlaneObjects = self.createBlenderCollisionPlanes(collisionPlanes)
|
||||||
|
collisionBoxes = self.mapData.collisionGeometry.boxes + self.mapData.collisionGeometryOutsideGamingZone.boxes
|
||||||
|
collisionBoxObjects = self.createBlenderCollisionBoxes(collisionBoxes)
|
||||||
|
|
||||||
|
collisionObjects += collisionTriangleObjects
|
||||||
|
collisionObjects += collisionPlaneObjects
|
||||||
|
collisionObjects += collisionBoxObjects
|
||||||
|
print(f"Loaded {len(collisionObjects)} collision objects")
|
||||||
|
|
||||||
|
# Spawn points
|
||||||
|
spawnPointObjects = []
|
||||||
|
if self.import_spawn_points:
|
||||||
|
# Create spawn points
|
||||||
|
for spawnPointData in self.mapData.spawnPoints:
|
||||||
|
ob = self.createBlenderSpawnPoint(spawnPointData)
|
||||||
|
spawnPointObjects.append(ob)
|
||||||
|
print(f"Loaded {len(spawnPointObjects)} spawn points")
|
||||||
|
|
||||||
|
# Create container object to store all our objects
|
||||||
|
objects = propObjects + collisionObjects + spawnPointObjects
|
||||||
|
mapOB = bpy.data.objects.new("BattleMap", None)
|
||||||
|
mapOB.empty_display_size = 100 # Alternativa use a x100 scale
|
||||||
|
mapOB.scale = (self.map_scale_factor, self.map_scale_factor, self.map_scale_factor)
|
||||||
|
objects.append(mapOB)
|
||||||
|
|
||||||
|
# Create empty objects to group each type of object
|
||||||
|
if self.import_static_geom:
|
||||||
|
groupOB = bpy.data.objects.new("StaticGeometry", None)
|
||||||
|
groupOB.parent = mapOB
|
||||||
|
objects.append(groupOB)
|
||||||
|
for ob in propObjects:
|
||||||
|
ob.parent = groupOB
|
||||||
|
if self.import_collision_geom:
|
||||||
|
groupOB = bpy.data.objects.new("CollisionGeometry", None)
|
||||||
|
groupOB.parent = mapOB
|
||||||
|
objects.append(groupOB)
|
||||||
|
for ob in collisionObjects:
|
||||||
|
ob.parent = groupOB
|
||||||
|
if self.import_spawn_points:
|
||||||
|
groupOB = bpy.data.objects.new("SpawnPoints", None)
|
||||||
|
groupOB.parent = mapOB
|
||||||
|
objects.append(groupOB)
|
||||||
|
for ob in spawnPointObjects:
|
||||||
|
ob.parent = groupOB
|
||||||
|
|
||||||
|
# Lighting data
|
||||||
|
if self.import_lightmapdata:
|
||||||
|
# Create a sun light object
|
||||||
|
li = bpy.data.lights.new("DirectionalLight", "SUN")
|
||||||
|
li.color = decodeIntColorToTuple(self.lightmapData.lightColour)
|
||||||
|
|
||||||
|
ob = bpy.data.objects.new(li.name, li)
|
||||||
|
ob.location = (0.0, 0.0, 1000.0) # Just place it like 10 meters off the ground (in alternativa units)
|
||||||
|
lightAngleX, lightAngleZ = self.lightmapData.lightAngle
|
||||||
|
ob.rotation_mode = "XYZ"
|
||||||
|
ob.rotation_euler = (lightAngleX, 0.0, lightAngleZ)
|
||||||
|
|
||||||
|
ob.parent = mapOB
|
||||||
|
objects.append(ob)
|
||||||
|
|
||||||
|
# Set ambient world light
|
||||||
|
scene = bpy.context.scene
|
||||||
|
if scene.world == None:
|
||||||
|
wd = bpy.data.worlds.new("map")
|
||||||
|
scene.world = wd
|
||||||
|
world = scene.world
|
||||||
|
world.use_nodes = False
|
||||||
|
world.color = decodeIntColorToTuple(self.lightmapData.ambientLightColour)
|
||||||
|
|
||||||
|
return objects
|
||||||
|
|
||||||
|
def getPropLibrary(self, libraryName):
|
||||||
|
# First check if we've already loaded the required prop library
|
||||||
|
if not libraryName in self.libraryCache:
|
||||||
|
# Load the proplib
|
||||||
|
libraryPath = f"{self.propLibrarySourcePath}/{libraryName}"
|
||||||
|
library = PropLibrary(libraryPath)
|
||||||
|
self.libraryCache[libraryName] = library
|
||||||
|
|
||||||
|
return self.libraryCache[libraryName]
|
||||||
|
|
||||||
|
def tryLoadTexture(self, textureName, libraryName):
|
||||||
|
if libraryName == None:
|
||||||
|
# For some reason Remaster proplib is alwaus marked as None? This is not true for the ny2024 remaster prop lib though
|
||||||
|
libraryName = "Remaster"
|
||||||
|
|
||||||
|
propLibrary = self.getPropLibrary(libraryName)
|
||||||
|
texture = propLibrary.getTexture(f"{textureName}.webp")
|
||||||
|
return texture
|
||||||
|
|
||||||
|
'''
|
||||||
|
Blender data builders
|
||||||
|
'''
|
||||||
|
def getBlenderProp(self, propData):
|
||||||
|
# Load prop
|
||||||
|
propLibrary = self.getPropLibrary(propData.libraryName)
|
||||||
|
prop = propLibrary.getProp(propData.name, propData.groupName)
|
||||||
|
propOB = prop.mainObject.copy() # We want to use a copy of the prop object
|
||||||
|
|
||||||
|
# Assign data
|
||||||
|
propOB.name = f"{propData.name}_{propData.ID}"
|
||||||
|
propOB.location = propData.position
|
||||||
|
propOB.rotation_mode = "XYZ"
|
||||||
|
propRotation = propData.rotation
|
||||||
|
if propRotation == None:
|
||||||
|
propRotation = (0.0, 0.0, 0.0)
|
||||||
|
propOB.rotation_euler = propRotation
|
||||||
|
propScale = propData.scale
|
||||||
|
if propScale == None:
|
||||||
|
propScale = (1.0, 1.0, 1.0)
|
||||||
|
propOB.scale = propScale
|
||||||
|
|
||||||
|
# Lighting info
|
||||||
|
if self.import_lightmapdata:
|
||||||
|
lightingMapObject = None
|
||||||
|
for mapObject in self.lightmapData.mapObjects:
|
||||||
|
if mapObject.index == propData.ID:
|
||||||
|
lightingMapObject = mapObject
|
||||||
|
break
|
||||||
|
if lightingMapObject != None:
|
||||||
|
#XXX: do something with lightingMapObject.recieveShadows??
|
||||||
|
propOB.visible_shadow = lightingMapObject.castShadows
|
||||||
|
|
||||||
|
# Material
|
||||||
|
ma = self.materials[propData.materialID]
|
||||||
|
if len(propOB.data.materials) != 0:
|
||||||
|
# Create a duplicate mesh object if it needs a different material, XXX: could probably cache these to reuse datablocks
|
||||||
|
if propOB.data.materials[0] != ma:
|
||||||
|
propOB.data = propOB.data.copy()
|
||||||
|
propOB.data.materials[0] = ma
|
||||||
|
|
||||||
|
return propOB
|
||||||
|
|
||||||
|
def createBlenderCollisionTriangles(self, collisionTriangles):
|
||||||
|
objects = []
|
||||||
|
for collisionTriangle in collisionTriangles:
|
||||||
|
# Create the mesh
|
||||||
|
me = bpy.data.meshes.new("collisionTriangle")
|
||||||
|
|
||||||
|
# Create array for coordinate data, blender doesn't like tuples
|
||||||
|
vertices = []
|
||||||
|
vertices += collisionTriangle.v0
|
||||||
|
vertices += collisionTriangle.v1
|
||||||
|
vertices += collisionTriangle.v2
|
||||||
|
|
||||||
|
# Assign coordinates
|
||||||
|
me.vertices.add(3)
|
||||||
|
me.vertices.foreach_set("co", vertices)
|
||||||
|
me.loops.add(3)
|
||||||
|
me.loops.foreach_set("vertex_index", [0, 1, 2])
|
||||||
|
me.polygons.add(1)
|
||||||
|
me.polygons.foreach_set("loop_start", [0])
|
||||||
|
|
||||||
|
me.validate()
|
||||||
|
me.update()
|
||||||
|
|
||||||
|
# Create object
|
||||||
|
ob = bpy.data.objects.new("collisionTriangle", me)
|
||||||
|
ob.location = collisionTriangle.position
|
||||||
|
ob.rotation_mode = "XYZ"
|
||||||
|
ob.rotation_euler = collisionTriangle.rotation
|
||||||
|
#print(collisionTriangle.length) # XXX: how to handle collisionTriangle.length?
|
||||||
|
|
||||||
|
objects.append(ob)
|
||||||
|
|
||||||
|
return objects
|
||||||
|
|
||||||
|
def createBlenderCollisionPlanes(self, collisionPlanes):
|
||||||
|
objects = []
|
||||||
|
for collisionPlane in collisionPlanes:
|
||||||
|
# Create object
|
||||||
|
ob = bpy.data.objects.new("collisionPlane", self.collisionPlaneMesh)
|
||||||
|
ob.location = collisionPlane.position
|
||||||
|
ob.rotation_mode = "XYZ"
|
||||||
|
ob.rotation_euler = collisionPlane.rotation
|
||||||
|
ob.scale = (collisionPlane.width*0.5, collisionPlane.length*0.5, 1.0) # Unsure why they double the width and length, could be because of central origin?
|
||||||
|
|
||||||
|
objects.append(ob)
|
||||||
|
|
||||||
|
return objects
|
||||||
|
|
||||||
|
def createBlenderCollisionBoxes(self, collisionBoxes):
|
||||||
|
objects = []
|
||||||
|
for collisionBox in collisionBoxes:
|
||||||
|
# Create object
|
||||||
|
ob = bpy.data.objects.new("collisionBox", self.collisionBoxMesh)
|
||||||
|
ob.location = collisionBox.position
|
||||||
|
ob.rotation_mode = "XYZ"
|
||||||
|
ob.rotation_euler = collisionBox.rotation
|
||||||
|
ob.scale = collisionBox.size
|
||||||
|
|
||||||
|
objects.append(ob)
|
||||||
|
|
||||||
|
return objects
|
||||||
|
|
||||||
|
def createBlenderSpawnPoint(self, spawnPointData):
|
||||||
|
#TODO: implement spawn type name lookup
|
||||||
|
ob = bpy.data.objects.new(f"SpawnPoint_{spawnPointData.type}", None)
|
||||||
|
ob.empty_display_type = "ARROWS"
|
||||||
|
ob.empty_display_size = 100 # The map will be at 100x scale so it's a good idea to match that here
|
||||||
|
ob.location = spawnPointData.position
|
||||||
|
ob.rotation_mode = "XYZ"
|
||||||
|
ob.rotation_euler = spawnPointData.rotation
|
||||||
|
|
||||||
|
return ob
|
||||||
|
|
||||||
|
def createBlenderMaterial(self, materialData):
|
||||||
|
ma = bpy.data.materials.new(f"{materialData.ID}_{materialData.name}")
|
||||||
|
|
||||||
|
# Shader specific logic
|
||||||
|
if materialData.shader == "TankiOnline/SingleTextureShader" or materialData.shader == "TankiOnline/SingleTextureShaderWinter":
|
||||||
|
bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
|
||||||
|
bsdf.roughness_set(1.0)
|
||||||
|
bsdf.ior_set(1.0)
|
||||||
|
|
||||||
|
# Try load texture
|
||||||
|
textureParameter = materialData.textureParameters[0]
|
||||||
|
texture = self.tryLoadTexture(textureParameter.textureName, textureParameter.libraryName)
|
||||||
|
|
||||||
|
addImageTextureToMaterial(texture, ma.node_tree)
|
||||||
|
elif materialData.shader == "TankiOnline/SpriteShader":
|
||||||
|
bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
|
||||||
|
bsdf.roughness_set(1.0)
|
||||||
|
bsdf.ior_set(1.0)
|
||||||
|
|
||||||
|
# Try load texture
|
||||||
|
textureParameter = materialData.textureParameters[0]
|
||||||
|
texture = self.tryLoadTexture(textureParameter.textureName, textureParameter.libraryName)
|
||||||
|
|
||||||
|
addImageTextureToMaterial(texture, ma.node_tree, linkAlpha=True)
|
||||||
|
elif materialData.shader == "TankiOnline/Terrain":
|
||||||
|
# XXX: still need to figure out how to do the terrain properly, all manual attempts have yielded mixed results
|
||||||
|
bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
|
||||||
|
bsdf.roughness_set(1.0)
|
||||||
|
bsdf.ior_set(1.0)
|
||||||
|
bsdf.base_color_set((0.0, 0.0, 0.0))
|
||||||
|
else:
|
||||||
|
pass # Unknown shader
|
||||||
|
|
||||||
|
return ma
|
||||||
|
|
||||||
|
class PropLibrary:
|
||||||
|
propGroups = {}
|
||||||
|
def __init__(self, directory):
|
||||||
|
self.directory = directory
|
||||||
|
self.libraryInfo = {}
|
||||||
|
|
||||||
|
# Load library info
|
||||||
|
with open(f"{self.directory}/library.json", "r") as file: self.libraryInfo = load(file)
|
||||||
|
print(f"Loaded prop library: " + self.libraryInfo["name"])
|
||||||
|
|
||||||
|
def getProp(self, propName, groupName):
|
||||||
|
# Create the prop group if it's not already loaded
|
||||||
|
if not groupName in self.propGroups:
|
||||||
|
self.propGroups[groupName] = {}
|
||||||
|
|
||||||
|
# Load the prop if it's not already loaded
|
||||||
|
if not propName in self.propGroups[groupName]:
|
||||||
|
# Find the prop group
|
||||||
|
groupInfo = None
|
||||||
|
for group in self.libraryInfo["groups"]:
|
||||||
|
if group["name"] == groupName:
|
||||||
|
groupInfo = group
|
||||||
|
break
|
||||||
|
if groupInfo == None:
|
||||||
|
raise RuntimeError(f"Unable to find prop group with name {groupName} in " + self.libraryInfo["name"])
|
||||||
|
|
||||||
|
# Find the prop
|
||||||
|
propInfo = None
|
||||||
|
for prop in groupInfo["props"]:
|
||||||
|
if prop["name"] == propName:
|
||||||
|
propInfo = prop
|
||||||
|
break
|
||||||
|
if propInfo == None:
|
||||||
|
raise RuntimeError(f"Unable to find prop with name {propName} in {groupName} from " + self.libraryInfo["name"])
|
||||||
|
|
||||||
|
# Create the prop
|
||||||
|
prop = Prop()
|
||||||
|
meshInfo = propInfo["mesh"]
|
||||||
|
spriteInfo = propInfo["sprite"]
|
||||||
|
if meshInfo != None:
|
||||||
|
modelPath = f"{self.directory}/" + meshInfo["file"]
|
||||||
|
prop.loadModel(modelPath)
|
||||||
|
elif spriteInfo != None:
|
||||||
|
prop.loadSprite(propInfo)
|
||||||
|
else:
|
||||||
|
#XXX: Uhhhhhh, empty prop?
|
||||||
|
pass
|
||||||
|
self.propGroups[groupName][propName] = prop
|
||||||
|
|
||||||
|
return self.propGroups[groupName][propName]
|
||||||
|
|
||||||
|
def getTexture(self, textureName):
|
||||||
|
im = load_image(textureName, self.directory)
|
||||||
|
return im
|
||||||
|
|
||||||
|
class Prop:
|
||||||
|
def __init__(self):
|
||||||
|
self.objects = []
|
||||||
|
self.mainObject = None
|
||||||
|
|
||||||
|
def loadModel(self, modelPath):
|
||||||
|
fileExtension = modelPath.split(".")[-1]
|
||||||
|
if fileExtension == "a3d":
|
||||||
|
modelData = A3D()
|
||||||
|
with open(modelPath, "rb") as file: modelData.read(file)
|
||||||
|
|
||||||
|
# Import the model
|
||||||
|
modelImporter = A3DBlenderImporter(modelData, None, reset_empty_transform=False, try_import_textures=False)
|
||||||
|
self.objects = modelImporter.importData()
|
||||||
|
elif fileExtension == "3ds":
|
||||||
|
bpy.ops.import_scene.max3ds(filepath=modelPath, use_apply_transform=False)
|
||||||
|
for ob in bpy.context.selectable_objects:
|
||||||
|
# The imported objects are added to the active collection, remove them
|
||||||
|
bpy.context.collection.objects.unlink(ob)
|
||||||
|
|
||||||
|
# Correct the origin XXX: this does not work for all cases, investigate more
|
||||||
|
ob.animation_data_clear()
|
||||||
|
x, y, z = -ob.location.x, -ob.location.y, -ob.location.z
|
||||||
|
objectOrigin = Matrix.Translation((x, y, z))
|
||||||
|
ob.data.transform(objectOrigin)
|
||||||
|
ob.location = (0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
self.objects.append(ob)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unknown model file extension: {fileExtension}")
|
||||||
|
|
||||||
|
# Identify the main parent object
|
||||||
|
for ob in self.objects:
|
||||||
|
if ob.parent == None: self.mainObject = ob
|
||||||
|
if self.mainObject == None:
|
||||||
|
raise RuntimeError(f"Unable to find the parent object for: {modelPath}")
|
||||||
|
|
||||||
|
def loadSprite(self, propInfo):
|
||||||
|
spriteInfo = propInfo["sprite"]
|
||||||
|
|
||||||
|
# Create a plane we can use for the sprite
|
||||||
|
me = bpy.data.meshes.new(propInfo["name"])
|
||||||
|
|
||||||
|
# bm = bmesh.new()
|
||||||
|
# bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=spriteInfo["scale"]*100)
|
||||||
|
# bm.to_mesh(me)
|
||||||
|
# bm.free()
|
||||||
|
|
||||||
|
ob = bpy.data.objects.new(me.name, me)
|
||||||
|
|
||||||
|
# Assign data
|
||||||
|
ob.scale = (spriteInfo["width"], 1.0, spriteInfo["height"]) #XXX: this should involve spriteInfo["scale"] probably?
|
||||||
|
spriteOrigin = Matrix.Translation((0.0, spriteInfo["originY"], 0.0))
|
||||||
|
me.transform(spriteOrigin)
|
||||||
|
|
||||||
|
# Finalise
|
||||||
|
self.objects.append(ob)
|
||||||
|
self.mainObject = ob
|
||||||
51
io_scene_a3d/BlenderMaterialUtils.py
Normal file
51
io_scene_a3d/BlenderMaterialUtils.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
'''
|
||||||
|
Copyright (c) 2025 Pyogenics <https://github.com/Pyogenics>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
'''
|
||||||
|
|
||||||
|
'''
|
||||||
|
Functions
|
||||||
|
'''
|
||||||
|
def addImageTextureToMaterial(image, node_tree, linkAlpha=False):
|
||||||
|
nodes = node_tree.nodes
|
||||||
|
links = node_tree.links
|
||||||
|
|
||||||
|
# Check if this material has already been edited
|
||||||
|
if len(nodes) > 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create nodes
|
||||||
|
bsdfNode = nodes.get("Principled BSDF")
|
||||||
|
textureNode = nodes.new(type="ShaderNodeTexImage")
|
||||||
|
links.new(textureNode.outputs["Color"], bsdfNode.inputs["Base Color"])
|
||||||
|
if linkAlpha:
|
||||||
|
links.new(textureNode.outputs["Alpha"], bsdfNode.inputs["Alpha"])
|
||||||
|
|
||||||
|
# Apply image
|
||||||
|
if image != None: textureNode.image = image
|
||||||
|
|
||||||
|
def decodeIntColorToTuple(intColor):
|
||||||
|
# Fromat is argb
|
||||||
|
a = (intColor >> 24) & 255
|
||||||
|
r = (intColor >> 16) & 255
|
||||||
|
g = (intColor >> 8) & 255
|
||||||
|
b = intColor & 255
|
||||||
|
|
||||||
|
return (r/255, g/255, b/255)
|
||||||
113
io_scene_a3d/LightmapData.py
Normal file
113
io_scene_a3d/LightmapData.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'''
|
||||||
|
Copyright (c) 2025 Pyogenics <https://github.com/Pyogenics>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
'''
|
||||||
|
|
||||||
|
from .IOTools import unpackStream
|
||||||
|
from . import AlternativaProtocol
|
||||||
|
|
||||||
|
class LightmapData:
|
||||||
|
def __init__(self):
|
||||||
|
self.lightColour = (0.0, 0.0, 0.0)
|
||||||
|
self.ambientLightColour = (0.0, 0.0, 0.0)
|
||||||
|
self.lightAngle = (0.0, 0.0) # (x, z)
|
||||||
|
self.lightmaps = []
|
||||||
|
self.mapObjects = []
|
||||||
|
|
||||||
|
def read(self, stream):
|
||||||
|
print("Reading LightmapData")
|
||||||
|
|
||||||
|
# There is no signature so just start reading data and hope this is actually a lightmap data file
|
||||||
|
version, = unpackStream("<I", stream)
|
||||||
|
print(f"Reading LightmapData version {version}")
|
||||||
|
|
||||||
|
if version == 1:
|
||||||
|
self.read1(stream)
|
||||||
|
elif version == 2:
|
||||||
|
self.read2(stream)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"Unknown LightmapData version: {version}")
|
||||||
|
|
||||||
|
'''
|
||||||
|
Version specific readers
|
||||||
|
'''
|
||||||
|
def read1(self, stream):
|
||||||
|
raise RuntimeError("Version 1 LightmapData is not implemented yet")
|
||||||
|
|
||||||
|
def read2(self, stream):
|
||||||
|
# Light info
|
||||||
|
self.lightColour, self.ambientLightColour = unpackStream("<2I", stream)
|
||||||
|
self.lightAngle = unpackStream("<2f", stream)
|
||||||
|
|
||||||
|
# Lightmaps
|
||||||
|
lightmapCount, = unpackStream("<I", stream)
|
||||||
|
print(f"Reading {lightmapCount} lightmaps")
|
||||||
|
for _ in range(lightmapCount):
|
||||||
|
lightmap = AlternativaProtocol.readString(stream)
|
||||||
|
self.lightmaps.append(lightmap)
|
||||||
|
|
||||||
|
# Map objects
|
||||||
|
mapObjectCount, = unpackStream("<I", stream)
|
||||||
|
print(f"Reading {mapObjectCount} map objects")
|
||||||
|
for _ in range(mapObjectCount):
|
||||||
|
mapObject = MapObject()
|
||||||
|
mapObject.read(stream)
|
||||||
|
self.mapObjects.append(mapObject)
|
||||||
|
|
||||||
|
#XXX: there is more data but do we actually care about it?
|
||||||
|
|
||||||
|
print(f"[LightmapData2 lightColour: {hex(self.lightColour)} ambientLightColour: {hex(self.ambientLightColour)} lightAngle: {self.lightAngle}]")
|
||||||
|
|
||||||
|
'''
|
||||||
|
Objects
|
||||||
|
'''
|
||||||
|
class MapObject:
|
||||||
|
def __init__(self):
|
||||||
|
self.index = 0
|
||||||
|
self.lightmapIndex = 0
|
||||||
|
self.lightmapScaleOffset = (0.0, 0.0, 0.0, 0.0)
|
||||||
|
self.UV1 = []
|
||||||
|
self.UV2 = []
|
||||||
|
self.castShadows = False
|
||||||
|
self.recieveShadows = False
|
||||||
|
|
||||||
|
def read(self, stream):
|
||||||
|
self.index, self.lightmapIndex = unpackStream("<2i", stream)
|
||||||
|
|
||||||
|
# Read lightmap data
|
||||||
|
if self.lightmapIndex >= 0:
|
||||||
|
self.lightmapScaleOffset = unpackStream("<4f", stream)
|
||||||
|
|
||||||
|
# Check if we have UVs and read them
|
||||||
|
hasUVs, = unpackStream("b", stream)
|
||||||
|
if hasUVs > 0:
|
||||||
|
vertexCount, = unpackStream("<I", stream)
|
||||||
|
for _ in range(vertexCount//2):
|
||||||
|
UV1 = unpackStream("<2f", stream)
|
||||||
|
self.UV1.append(UV1)
|
||||||
|
UV2 = unpackStream("<2f", stream)
|
||||||
|
self.UV2.append(UV2)
|
||||||
|
|
||||||
|
# Light settings
|
||||||
|
castShadows, recieveShadows = unpackStream("2b", stream)
|
||||||
|
self.castShadows = castShadows > 0
|
||||||
|
self.recieveShadows = recieveShadows > 0
|
||||||
|
|
||||||
|
print(f"[MapObject index: {self.index} lightmapIndex: {self.lightmapIndex} lightmapScaleOffset: {self.lightmapScaleOffset} UV1: {len(self.UV1)} UV2: {len(self.UV2)} castShadows: {self.castShadows} recieveShadows: {self.recieveShadows}]")
|
||||||
@@ -21,14 +21,30 @@ SOFTWARE.
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from bpy.types import Operator, OperatorFileListElement
|
from bpy.types import Operator, OperatorFileListElement, AddonPreferences
|
||||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty
|
from bpy.props import StringProperty, BoolProperty, CollectionProperty, FloatProperty
|
||||||
from bpy_extras.io_utils import ImportHelper
|
from bpy_extras.io_utils import ImportHelper
|
||||||
|
|
||||||
from .A3D import A3D
|
from .A3D import A3D
|
||||||
from .A3DBlenderImporter import A3DBlenderImporter
|
from .A3DBlenderImporter import A3DBlenderImporter
|
||||||
|
from .BattleMap import BattleMap
|
||||||
|
from .BattleMapBlenderImporter import BattleMapBlenderImporter
|
||||||
|
from .LightmapData import LightmapData
|
||||||
|
|
||||||
from glob import glob
|
from os.path import isdir
|
||||||
|
from time import time
|
||||||
|
|
||||||
|
'''
|
||||||
|
Addon preferences
|
||||||
|
'''
|
||||||
|
class Preferences(AddonPreferences):
|
||||||
|
bl_idname = __package__
|
||||||
|
|
||||||
|
propLibrarySourcePath: StringProperty(name="Prop library source path", subtype='DIR_PATH')
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
layout = self.layout
|
||||||
|
layout.prop(self, "propLibrarySourcePath")
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Operators
|
Operators
|
||||||
@@ -40,8 +56,8 @@ class ImportA3D(Operator, ImportHelper):
|
|||||||
bl_options = {'PRESET', 'UNDO'}
|
bl_options = {'PRESET', 'UNDO'}
|
||||||
|
|
||||||
filter_glob: StringProperty(default="*.a3d", options={'HIDDEN'})
|
filter_glob: StringProperty(default="*.a3d", options={'HIDDEN'})
|
||||||
directory: StringProperty(subtype='DIR_PATH', options={'HIDDEN'})
|
directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'})
|
||||||
files: CollectionProperty(type=OperatorFileListElement, options={"HIDDEN", "SKIP_SAVE"})
|
files: CollectionProperty(type=OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
|
||||||
|
|
||||||
# User options
|
# User options
|
||||||
create_collection: BoolProperty(name="Create collection", description="Create a collection to hold all the model objects", default=False)
|
create_collection: BoolProperty(name="Create collection", description="Create a collection to hold all the model objects", default=False)
|
||||||
@@ -49,15 +65,17 @@ class ImportA3D(Operator, ImportHelper):
|
|||||||
reset_empty_transform: BoolProperty(name="Reset empty transforms", description="Reset rotation and scale if it is set to 0, more useful for version 2 models like props", default=True)
|
reset_empty_transform: BoolProperty(name="Reset empty transforms", description="Reset rotation and scale if it is set to 0, more useful for version 2 models like props", default=True)
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
import_panel_options(self.layout, self)
|
import_panel_options_a3d(self.layout, self)
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
return ImportHelper.invoke(self, context, event)
|
return ImportHelper.invoke(self, context, event)
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
|
importStartTime = time()
|
||||||
|
|
||||||
objects = []
|
objects = []
|
||||||
for file in self.files:
|
for file in self.files:
|
||||||
filepath = self.directory + file.name
|
filepath = f"{self.directory}/{file.name}"
|
||||||
# Read the file
|
# Read the file
|
||||||
print(f"Reading A3D data from {filepath}")
|
print(f"Reading A3D data from {filepath}")
|
||||||
modelData = A3D()
|
modelData = A3D()
|
||||||
@@ -76,12 +94,72 @@ class ImportA3D(Operator, ImportHelper):
|
|||||||
for obI, ob in enumerate(objects):
|
for obI, ob in enumerate(objects):
|
||||||
collection.objects.link(ob)
|
collection.objects.link(ob)
|
||||||
|
|
||||||
|
importEndTime = time()
|
||||||
|
self.report({'INFO'}, f"Imported {len(objects)} objects in {importEndTime-importStartTime}s")
|
||||||
|
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
class ImportBattleMap(Operator, ImportHelper):
|
||||||
|
bl_idname = "import_scene.tanki_battlemap"
|
||||||
|
bl_label = "Import map"
|
||||||
|
bl_description = "Import a BIN format Tanki Online map file"
|
||||||
|
bl_options = {'PRESET', 'UNDO'}
|
||||||
|
|
||||||
|
filter_glob: StringProperty(default="*.bin", options={'HIDDEN'})
|
||||||
|
directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'})
|
||||||
|
|
||||||
|
# User options
|
||||||
|
import_static_geom: BoolProperty(name="Import static geometry", description="Static geometry includes all the visual aspects of the map", default=True)
|
||||||
|
import_collision_geom: BoolProperty(name="Import collision geometry", description="Collision geometry defines the geometry used for collision checks and cannot normally be seen by players", default=False)
|
||||||
|
import_spawn_points: BoolProperty(name="Import spawn points", description="Places a marker at locations where tanks can spawn", default=False)
|
||||||
|
import_lightmapdata: BoolProperty(name="Import lighting information", description="Loads the lightmapdata file which stores information about the sun, ambient lighting and shadow settings. Only works on remaster maps.", default=True)
|
||||||
|
map_scale_factor: FloatProperty(name="Map scale", description="Sets the map's default scale, maps and models are at a 100x scale so this allows you to directly import the map in the right size.", default=0.01, min=0.0, soft_max=1.0)
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
import_panel_options_battlemap(self.layout, self)
|
||||||
|
|
||||||
|
def invoke(self, context, event):
|
||||||
|
return ImportHelper.invoke(self, context, event)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
print(f"Reading BattleMap data from {self.filepath}")
|
||||||
|
importStartTime = time()
|
||||||
|
|
||||||
|
# lightmapdata files only exist for remaster maps
|
||||||
|
lightmapData = LightmapData()
|
||||||
|
if self.import_lightmapdata:
|
||||||
|
try:
|
||||||
|
with open(f"{self.directory}/lightmapdata", "rb") as file: lightmapData.read(file)
|
||||||
|
except:
|
||||||
|
print("Couldn't open lightmapdata file, ignoring")
|
||||||
|
self.import_lightmapdata = False
|
||||||
|
|
||||||
|
# read map data
|
||||||
|
mapData = BattleMap()
|
||||||
|
with open(self.filepath, "rb") as file:
|
||||||
|
mapData.read(file)
|
||||||
|
|
||||||
|
# Import data into blender
|
||||||
|
preferences = context.preferences.addons[__package__].preferences
|
||||||
|
if not isdir(preferences.propLibrarySourcePath):
|
||||||
|
raise RuntimeError("Please set a valid prop library folder in addon properties!")
|
||||||
|
mapImporter = BattleMapBlenderImporter(mapData, lightmapData, preferences.propLibrarySourcePath, self.map_scale_factor, self.import_static_geom, self.import_collision_geom, self.import_spawn_points, self.import_lightmapdata)
|
||||||
|
objects = mapImporter.importData()
|
||||||
|
|
||||||
|
# Link objects
|
||||||
|
collection = bpy.context.collection
|
||||||
|
for ob in objects:
|
||||||
|
collection.objects.link(ob)
|
||||||
|
|
||||||
|
importEndTime = time()
|
||||||
|
self.report({'INFO'}, f"Imported {len(objects)} objects in {importEndTime-importStartTime}s")
|
||||||
|
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Menu
|
Menu
|
||||||
'''
|
'''
|
||||||
def import_panel_options(layout, operator):
|
def import_panel_options_a3d(layout, operator):
|
||||||
header, body = layout.panel("alternativa_import_options", default_closed=False)
|
header, body = layout.panel("alternativa_import_options", default_closed=False)
|
||||||
header.label(text="Options")
|
header.label(text="Options")
|
||||||
if body:
|
if body:
|
||||||
@@ -89,25 +167,42 @@ def import_panel_options(layout, operator):
|
|||||||
body.prop(operator, "try_import_textures")
|
body.prop(operator, "try_import_textures")
|
||||||
body.prop(operator, "reset_empty_transform")
|
body.prop(operator, "reset_empty_transform")
|
||||||
|
|
||||||
|
def import_panel_options_battlemap(layout, operator):
|
||||||
|
header, body = layout.panel("tanki_battlemap_import_options", default_closed=False)
|
||||||
|
header.label(text="Options")
|
||||||
|
if body:
|
||||||
|
body.prop(operator, "import_static_geom")
|
||||||
|
body.prop(operator, "import_collision_geom")
|
||||||
|
body.prop(operator, "import_spawn_points")
|
||||||
|
body.prop(operator, "import_lightmapdata")
|
||||||
|
body.prop(operator, "map_scale_factor")
|
||||||
|
|
||||||
def menu_func_import_a3d(self, context):
|
def menu_func_import_a3d(self, context):
|
||||||
self.layout.operator(ImportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)")
|
self.layout.operator(ImportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)")
|
||||||
|
|
||||||
|
def menu_func_import_battlemap(self, context):
|
||||||
|
self.layout.operator(ImportBattleMap.bl_idname, text="Tanki Online BattleMap (.bin)")
|
||||||
|
|
||||||
'''
|
'''
|
||||||
Registration
|
Registration
|
||||||
'''
|
'''
|
||||||
classes = [
|
classes = [
|
||||||
ImportA3D
|
Preferences,
|
||||||
|
ImportA3D,
|
||||||
|
ImportBattleMap
|
||||||
]
|
]
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
for c in classes:
|
for c in classes:
|
||||||
bpy.utils.register_class(c)
|
bpy.utils.register_class(c)
|
||||||
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_a3d)
|
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_a3d)
|
||||||
|
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_battlemap)
|
||||||
|
|
||||||
def unregister():
|
def unregister():
|
||||||
for c in classes:
|
for c in classes:
|
||||||
bpy.utils.unregister_class(c)
|
bpy.utils.unregister_class(c)
|
||||||
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_a3d)
|
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_a3d)
|
||||||
|
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_battlemap)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
register()
|
register()
|
||||||
Reference in New Issue
Block a user