Merge map.bin support into main

Support for loading map.bin files
This commit is contained in:
Pyogenics
2025-04-11 12:35:34 +00:00
committed by GitHub
9 changed files with 1162 additions and 25 deletions

BIN
images/demo7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
images/demo8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

View File

@@ -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

View 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
View 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)

View 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

View 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)

View 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}]")

View File

@@ -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()