Implement avatar limb animations

Refactor helpers to take "impl" objects rather than functions
This commit is contained in:
Jeremy Penner 2023-12-28 16:10:00 -05:00
parent a89c3fcec5
commit d1fb2daa41
5 changed files with 130 additions and 87 deletions

View file

@ -42,6 +42,8 @@ cels_affected_by_height:
; an index into a table of bitmasks, states are defined as an index into ; an index into a table of bitmasks, states are defined as an index into
; the table of cels directly. only one cel is visible per-limb at a time. ; the table of cels directly. only one cel is visible per-limb at a time.
; 1 - padding to match prop header? always zero.
; 2 - offset of start_end table
; 1-2 - unknown. first byte seems to always be zero. second byte seems ; 1-2 - unknown. first byte seems to always be zero. second byte seems
; to be correlated with the number of frames or cels, but isn't a direct ; to be correlated with the number of frames or cels, but isn't a direct
; count of either. ; count of either.
@ -88,6 +90,14 @@ which suggests these as valid values.
define AV_ACT_sit_front = 0x80 + 29 define AV_ACT_sit_front = 0x80 + 29
; choreography tables: ; choreography tables:
; an array of arrays of bytes, indicating "states". if the high bit is ; an array of arrays of bytes, indicating "states". Each byte has three
; set, this signals the end of the inner array. ; values packed into it: ElllAAAA
; unclear at this time how exactly these values are interpreted. ; E (0x80): "end" bit - if this is set, indicates that this is the last
; byte of the array.
; l (0x70): "limb" - value from 0-6, indexing the 6 limbs. If limb is 6,
; 0x10 is added to S and limb is set to 5. Stored in the X
; register and passed to `init_avatar_chores` (chore.m:252)
; A (0x0f): "animation" - index of animation in the limb's start_end table
; I believe all limbs default to animation 0 if no alternative animation is given
; for a given chore in the choreography table.

View file

@ -15,10 +15,13 @@
line-height:1.2 line-height:1.2
} }
</style> </style>
<script src="index.js"></script> <script src="index.js?v=1"></script>
</head> </head>
<body> <body>
<h1 id="filename"></h1> <h1 id="filename"></h1>
<div id="limbs">
<h2>Limbs</h2>
</div>
<div id="cels"> <div id="cels">
<h2>Cels</h1> <h2>Cels</h1>
</div> </div>
@ -37,19 +40,28 @@
container.appendChild(textNode(JSON.stringify(prop, propFilter, 2), "pre")) container.appendChild(textNode(JSON.stringify(prop, propFilter, 2), "pre"))
} }
const limbNames = ["legs", "legs2", "left arm", "torso", "face", "right arm"]
const labelLimb = (container, ilimb) => {
container.appendChild(textNode(ilimb < limbNames.length ? limbNames[ilimb] : `Limb #${ilimb}??`, "div"))
}
const onload = async () => { const onload = async () => {
const q = new URLSearchParams(window.location.search) const q = new URLSearchParams(window.location.search)
const filename = q.get("f") const filename = q.get("f")
document.getElementById("filename").innerText = filename document.getElementById("filename").innerText = filename
try { try {
const body = await decodeBinary(filename, decodeBody) const body = await decodeBinary(filename, BodyImpl)
dumpProp(body, document.getElementById("data")) dumpProp(body, document.getElementById("data"))
if (body.error) { if (body.error) {
showError(body.error, filename) showError(body.error, filename)
} else { } else {
const celContainer = document.getElementById("cels") const celContainer = document.getElementById("cels")
for (const limb of body.limbs) { const limbContainer = document.getElementById("limbs")
for (const [ilimb, limb] of body.limbs.entries()) {
labelLimb(celContainer, ilimb)
showCels(limb, celContainer) showCels(limb, celContainer)
labelLimb(limbContainer, ilimb)
showAnimations(limb, limbContainer, LimbImpl)
} }
} }
} catch (e) { } catch (e) {

View file

@ -15,7 +15,7 @@
line-height:1.2 line-height:1.2
} }
</style> </style>
<script src="index.js"></script> <script src="index.js?v=1"></script>
</head> </head>
<body> <body>
<h1 id="filename"></h1> <h1 id="filename"></h1>
@ -48,12 +48,12 @@
const filename = q.get("f") const filename = q.get("f")
document.getElementById("filename").innerText = filename document.getElementById("filename").innerText = filename
try { try {
const prop = await decodeBinary(filename, decodeProp) const prop = await decodeBinary(filename, PropImpl)
dumpProp(prop, document.getElementById("data")) dumpProp(prop, document.getElementById("data"))
if (prop.error) { if (prop.error) {
showError(prop.error, filename) showError(prop.error, filename)
} else { } else {
showAnimations(prop, document.getElementById("animations")) showAnimations(prop, document.getElementById("animations"), PropImpl)
showStates(prop, document.getElementById("states")) showStates(prop, document.getElementById("states"))
showCels(prop, document.getElementById("cels")) showCels(prop, document.getElementById("cels"))
} }

View file

@ -15,18 +15,18 @@
line-height:1.2 line-height:1.2
} }
</style> </style>
<script src="index.js"></script> <script src="index.js?v=1"></script>
<script> <script>
function showErrors() { function showErrors() {
document.getElementById('errors').style.display = 'block' document.getElementById('errors').style.display = 'block'
} }
const displayEverything = async () => { const displayEverything = async () => {
await displayBodyList("bodies.json", "bodies") await displayList("bodies.json", "bodies", BodyImpl)
await displayPropList("heads.json", "heads") await displayList("heads.json", "heads", PropImpl)
await displayPropList("props.json", "props") await displayList("props.json", "props", PropImpl)
await displayPropList("misc.json", "misc") await displayList("misc.json", "misc", PropImpl)
await displayPropList("beta.json", "beta") await displayList("beta.json", "beta", PropImpl)
} }
displayEverything() displayEverything()
@ -35,7 +35,7 @@
<body> <body>
<h1>Inhabitor - The Habitat Inspector</h1> <h1>Inhabitor - The Habitat Inspector</h1>
<p> <p>
You are looking at a haphazardly-gathered collection of object graphics from You are looking at a collection of object graphics from
<a href="https://frandallfarmer.github.io/neohabitat-doc/docs/">Lucasfilm Games' Habitat</a>. These images are <a href="https://frandallfarmer.github.io/neohabitat-doc/docs/">Lucasfilm Games' Habitat</a>. These images are
generated by parsing Habitat's internal binary image / animation format in generated by parsing Habitat's internal binary image / animation format in
JavaScript. The full <a href="https://git.information-superhighway.net/SpindleyQ/inhabitor"> JavaScript. The full <a href="https://git.information-superhighway.net/SpindleyQ/inhabitor">

163
index.js
View file

@ -412,6 +412,32 @@ const encodeWalkto = ({ fromSide, offset }) => {
return encodeSide(fromSide) | (offset & 0xfc) return encodeSide(fromSide) | (offset & 0xfc)
} }
const decodeAnimations = (data, startEndTableOff, firstCelOff, stateCount) => {
const animations = []
// The prop structure also does not encode a count for how many frames there are, so we simply
// stop parsing once we find one that doesn't make sense.
// We also use the heuristic that this structure always precedes the first cel, as that seems to be
// consistently be the case with all the props in the Habitat source tree. We'll stop reading
// animation data if we cross that boundary. If we encounter a prop that has the animation data
// _after_ the cel data, which would be legal but doesn't happen in practice, then we ignore this
// heuristic rather than failing to parse any animation data.
// It's possible for there to be no frames, which is represented by an offset of 0 (no_animation)
if (startEndTableOff != 0) {
for (let frameOff = startEndTableOff; (startEndTableOff > firstCelOff) || (frameOff < firstCelOff); frameOff += 2) {
// each animation is two bytes: the starting state, and the ending state
// the first byte can have its high bit set to indicate that the animation should cycle
const cycle = (data.getUint8(frameOff) & 0x80) != 0
const startState = data.getUint8(frameOff) & 0x7f
const endState = data.getUint8(frameOff + 1)
if (startState >= stateCount || endState >= stateCount) {
break
}
animations.push({ cycle: cycle, startState: startState, endState: endState })
}
}
return animations
}
const decodeProp = (data) => { const decodeProp = (data) => {
const prop = { const prop = {
data: data, data: data,
@ -419,7 +445,6 @@ const decodeProp = (data) => {
colorBitmask: data.getUint8(1), colorBitmask: data.getUint8(1),
containerXYOff: data.getUint8(3), // TODO: parse this when nonzero containerXYOff: data.getUint8(3), // TODO: parse this when nonzero
walkto: { left: decodeWalkto(data.getUint8(4)), right: decodeWalkto(data.getUint8(5)), yoff: data.getInt8(6) }, walkto: { left: decodeWalkto(data.getUint8(4)), right: decodeWalkto(data.getUint8(5)), yoff: data.getInt8(6) },
animations: [],
celmasks: [], celmasks: [],
cels: [] cels: []
} }
@ -451,33 +476,11 @@ const decodeProp = (data) => {
prop.cels.push(decodeCel(new DataView(data.buffer, celOff), (prop.colorBitmask & celbit) != 0)) prop.cels.push(decodeCel(new DataView(data.buffer, celOff), (prop.colorBitmask & celbit) != 0))
allCelsMask = (allCelsMask << 1) & 0xff allCelsMask = (allCelsMask << 1) & 0xff
} }
prop.animations = decodeAnimations(data, graphicStateOff, firstCelOff, stateCount)
// The prop structure also does not encode a count for how many frames there are, so we simply
// stop parsing once we find one that doesn't make sense.
// We also use the heuristic that this structure always precedes the first cel, as that seems to be
// consistently be the case with all the props in the Habitat source tree. We'll stop reading
// animation data if we cross that boundary. If we encounter a prop that has the animation data
// _after_ the cel data, which would be legal but doesn't happen in practice, then we ignore this
// heuristic rather than failing to parse any animation data.
// It's possible for there to be no frames, which is represented by an offset of 0 (no_animation)
if (graphicStateOff != 0) {
for (let frameOff = graphicStateOff; (graphicStateOff > firstCelOff) || (frameOff < firstCelOff); frameOff += 2) {
// each animation is two bytes: the starting state, and the ending state
// the first byte can have its high bit set to indicate that the animation should cycle
const cycle = (data.getUint8(frameOff) & 0x80) != 0
const startState = data.getUint8(frameOff) & 0x7f
const endState = data.getUint8(frameOff + 1)
if (startState >= stateCount || endState >= stateCount) {
break
}
prop.animations.push({ cycle: cycle, startState: startState, endState: endState })
}
}
return prop return prop
} }
const decodeLimb = (data, limb) => { const decodeLimb = (data, limb) => {
limb.unknown = [data.getUint8(1), data.getUint8(2)]
let frameCount = data.getUint8(0) + 1 let frameCount = data.getUint8(0) + 1
limb.frames = [] limb.frames = []
for (let iframe = 0; iframe < frameCount; iframe ++) { for (let iframe = 0; iframe < frameCount; iframe ++) {
@ -486,11 +489,17 @@ const decodeLimb = (data, limb) => {
const celOffsetsOff = 3 + frameCount const celOffsetsOff = 3 + frameCount
const maxCelIndex = Math.max(...limb.frames) const maxCelIndex = Math.max(...limb.frames)
limb.cels = [] limb.cels = []
let firstCelOff
for (let icel = 0; icel <= maxCelIndex; icel ++) { for (let icel = 0; icel <= maxCelIndex; icel ++) {
const celOff = data.getUint16(celOffsetsOff + (icel * 2), LE) const celOff = data.getUint16(celOffsetsOff + (icel * 2), LE)
if (icel == 0) {
firstCelOff = celOff
}
limb.cels.push(decodeCel(new DataView(data.buffer, data.byteOffset + celOff))) limb.cels.push(decodeCel(new DataView(data.buffer, data.byteOffset + celOff)))
} }
limb.animations = decodeAnimations(data, data.getUint8(2), firstCelOff, limb.frames.length)
} }
const choreographyActions = [ const choreographyActions = [
"init", "stand", "walk", "hand_back", "sit_floor", "bend_over", "init", "stand", "walk", "hand_back", "sit_floor", "bend_over",
"bend_back", "point", "throw", "get_shot", "jump", "punch", "wave", "bend_back", "point", "throw", "get_shot", "jump", "punch", "wave",
@ -614,23 +623,43 @@ const wrapLink = (element, href) => {
return link return link
} }
const linkDetail = (element, filename) => { const PropImpl = {
return wrapLink(element, `detail.html?f=${filename}`) decode: decodeProp,
detailHref: (filename) => `detail.html?f=${filename}`,
celsForAnimationState: (prop, istate) => celsFromMask(prop, prop.celmasks[istate]),
} }
const linkBody = (element, filename) => { const BodyImpl = {
return wrapLink(element, `body.html?f=${filename}`) decode: decodeBody,
detailHref: (filename) => `body.html?f=${filename}`
} }
const createAnimation = (prop, animation) => { const LimbImpl = {
celsForAnimationState: (limb, istate) => {
const iframe = limb.frames[istate]
if (iframe >= 0) {
return [limb.cels[iframe]]
} else {
return []
}
}
}
const linkDetail = (element, filename, impl) => {
return impl && impl.detailHref ? wrapLink(element, impl.detailHref(filename)) : element
}
const createAnimation = (animation, value, impl) => {
const frames = [] const frames = []
for (let istate = animation.startState; istate <= animation.endState; istate ++) { for (let istate = animation.startState; istate <= animation.endState; istate ++) {
const frame = compositeCels(celsFromMask(prop, prop.celmasks[istate])) const frame = compositeCels(impl.celsForAnimationState(value, istate))
if (frame != null) { if (frame != null) {
frames.push(frame) frames.push(frame)
} }
} }
if (frames.length == 1) { if (frames.length == 0) {
return textNode("")
} else if (frames.length == 1) {
return imageFromCanvas(frames[0].canvas) return imageFromCanvas(frames[0].canvas)
} }
let minX = Number.POSITIVE_INFINITY let minX = Number.POSITIVE_INFINITY
@ -663,9 +692,9 @@ const createAnimation = (prop, animation) => {
return canvas return canvas
} }
const showAnimations = (prop, container) => { const showAnimations = (value, container, impl) => {
for (const animation of prop.animations) { for (const animation of value.animations) {
container.appendChild(linkDetail(createAnimation(prop, animation), prop.filename)) container.appendChild(linkDetail(createAnimation(animation, value, impl), value.filename, impl))
} }
} }
@ -688,9 +717,9 @@ const showCels = (prop, container) => {
} }
} }
const decodeBinary = async (filename, decoder) => { const decodeBinary = async (filename, impl) => {
try { try {
const prop = decoder(await readBinary(filename)) const prop = impl.decode(await readBinary(filename))
prop.filename = filename prop.filename = filename
return prop return prop
} catch (e) { } catch (e) {
@ -698,11 +727,11 @@ const decodeBinary = async (filename, decoder) => {
} }
} }
const showError = (e, filename, link = (x,_) => x) => { const showError = (e, filename, impl) => {
const container = document.getElementById("errors") const container = document.getElementById("errors")
const errNode = document.createElement("p") const errNode = document.createElement("p")
console.error(e) console.error(e)
errNode.appendChild(link(textNode(filename, "b"), filename)) errNode.appendChild(linkDetail(textNode(filename, "b"), filename, impl))
errNode.appendChild(textNode(e.toString(), "p")) errNode.appendChild(textNode(e.toString(), "p"))
if (e.stack) { if (e.stack) {
errNode.appendChild(textNode(e.stack.toString(), "pre")) errNode.appendChild(textNode(e.stack.toString(), "pre"))
@ -710,22 +739,38 @@ const showError = (e, filename, link = (x,_) => x) => {
container.appendChild(errNode) container.appendChild(errNode)
} }
const displayFile = async (filename, container, decode, display, link = (x,_) => x) => { const displayFile = async (filename, container, impl) => {
const prop = await decodeBinary(filename, decode) const value = await decodeBinary(filename, impl)
if (prop.error) { if (value.error) {
container.parentNode.removeChild(container) container.parentNode.removeChild(container)
showError(prop.error, prop.filename, link) showError(value.error, value.filename, impl)
} else { } else {
try { try {
display(prop, container) impl.display(value, container)
} catch (e) { } catch (e) {
container.parentNode.removeChild(container) container.parentNode.removeChild(container)
showError(e, prop.filename, link) showError(e, value.filename, impl)
} }
} }
} }
const displayList = async (indexFile, containerId, decode, display, link = (x,_) => x) => { PropImpl.display = (prop, container) => {
if (prop.filename == 'heads/fhead.bin') {
container.appendChild(textNode("CW: Pixel genitals"))
} else if (prop.animations.length > 0) {
showAnimations(prop, container, PropImpl)
} else {
showStates(prop, container)
}
}
BodyImpl.display = (body, container) => {
for (const limb of body.limbs) {
showCels(limb, container)
}
}
const displayList = async (indexFile, containerId, impl) => {
const response = await fetch(indexFile, { cache: "no-cache" }) const response = await fetch(indexFile, { cache: "no-cache" })
const filenames = await response.json() const filenames = await response.json()
const container = document.getElementById(containerId) const container = document.getElementById(containerId)
@ -735,32 +780,8 @@ const displayList = async (indexFile, containerId, decode, display, link = (x,_)
fileContainer.style.margin = "2px" fileContainer.style.margin = "2px"
fileContainer.style.padding = "2px" fileContainer.style.padding = "2px"
fileContainer.style.display = "inline-block" fileContainer.style.display = "inline-block"
fileContainer.appendChild(link(textNode(filename, "div"), filename)) fileContainer.appendChild(linkDetail(textNode(filename, "div"), filename, impl))
container.appendChild(fileContainer) container.appendChild(fileContainer)
displayFile(filename, fileContainer, decode, display, link) displayFile(filename, fileContainer, impl)
} }
}
const displayProp = (prop, container) => {
if (prop.filename == 'heads/fhead.bin') {
container.appendChild(textNode("CW: Pixel genitals"))
} else if (prop.animations.length > 0) {
showAnimations(prop, container)
} else {
showStates(prop, container)
}
}
const displayBody = (body, container) => {
for (const limb of body.limbs) {
showCels(limb, container)
}
}
const displayPropList = async (indexFile, containerId) => {
await displayList(indexFile, containerId, decodeProp, displayProp, linkDetail)
}
const displayBodyList = async (indexFile, containerId) => {
await displayList(indexFile, containerId, decodeBody, displayBody, linkBody)
} }