Körperbewegung

This commit is contained in:
N-Nachtigal 2025-05-13 23:14:13 +02:00
parent b16b24e4f7
commit 95945c0306
78 changed files with 12503 additions and 0 deletions

View file

@ -0,0 +1,7 @@
globals = {"character_anim"}
read_globals = {
"modlib",
-- Minetest
math = {fields = {"sign"}},
"vector", "minetest"
}

View file

@ -0,0 +1,184 @@
# Character Animations (`character_anim`)
Animates the character. Resembles [`playeranim`](https://github.com/minetest-mods/playeranim) and [`headanim`](https://github.com/LoneWolfHT/headanim).
## About
Depends on [`modlib`](https://github.com/appgurueu/modlib). Code written by Lars Mueller aka LMD or appguru(eu) and licensed under the MIT license.
## Screenshot
![Image](screenshot.png)
## Links
* [GitHub](https://github.com/appgurueu/character_anim) - sources, issue tracking, contributing
* [Discord](https://discordapp.com/invite/ysP74by) - discussion, chatting
* [Minetest Forum](https://forum.minetest.net/viewtopic.php?f=9&t=25385) - (more organized) discussion
* [ContentDB](https://content.minetest.net/packages/LMD/character_anim) - releases (cloning from GitHub is recommended)
## Features
* Animates head, right arm & body
* Support for arbitrary player models, as long as `Head`, `Arm_Right` & `Body` bones exist
* If any of these bones do not exist, it will still try to animate the remaining bones
* Advantages over `playeranim`:
* Extracts exact animations and bone positions from b3d models at runtime (no complex installation)
* Also animates attached players (with restrictions on angles)
* Advantages over `headanim`:
* Provides compatibility back until Minetest 0.4.x (as opposed to `headanim` supporting only 5.3+)
* Head angles are clamped, head can tilt sideways
* Animates right arm & body as well
## Configuration
<!--modlib:conf:2-->
### `default`
#### `arm_right`
##### `radius`
Right arm spin radius
* Type: number
* Default: `10`
* &gt;= `-180`
* &lt;= `180`
##### `speed`
Right arm spin speed
* Type: number
* Default: `1000`
* &gt; `0`
* &lt;= `10000`
##### `yaw`
###### `max`
Right arm yaw (max)
* Type: number
* Default: `160`
* &gt;= `-180`
* &lt;= `180`
###### `min`
Right arm yaw (min)
* Type: number
* Default: `-30`
* &gt;= `-180`
* &lt;= `180`
#### `body`
##### `turn_speed`
Body turn speed
* Type: number
* Default: `0.2`
* &gt; `0`
* &lt;= `1000`
#### `head`
##### `pitch`
###### `max`
Head pitch (max)
* Type: number
* Default: `80`
* &gt;= `-180`
* &lt;= `180`
###### `min`
Head pitch (min)
* Type: number
* Default: `-60`
* &gt;= `-180`
* &lt;= `180`
##### `yaw`
###### `max`
Head yaw (max)
* Type: number
* Default: `90`
* &gt;= `-180`
* &lt;= `180`
###### `min`
Head yaw (min)
* Type: number
* Default: `-90`
* &gt;= `-180`
* &lt;= `180`
##### `yaw_restricted`
###### `max`
Head yaw restricted (max)
* Type: number
* Default: `45`
* &gt;= `-180`
* &lt;= `180`
###### `min`
Head yaw restricted (min)
* Type: number
* Default: `0`
* &gt;= `-180`
* &lt;= `180`
##### `yaw_restriction`
Head yaw restriction
* Type: number
* Default: `60`
* &gt;= `-180`
* &lt;= `180`
### `models`
Other models, same format as `default` model
<!--modlib:conf-->
## API
Minetest's `player:set_bone_position` is overridden so that it still works as expected.
### `character_anim.set_bone_override(player, bonename, position, rotation)`
The signature resembles that of `set_bone_position`. `bonename` must be a string. The following additional features are provided:
* Using it like `set_bone_position` by setting `rotation` and `position` to non-`nil` values and using `""` to set the root bone
* *Setting only the bone position* by setting `rotation` to `nil` - bone rotation will then be model-animation-determined
* *Setting only the bone rotation* by setting `position` to `nil` - bone position will then be model-animation-determined
* *Clearing the override* by setting both `rotation` and `position` to `nil` ("unset_bone_position")

View file

@ -0,0 +1 @@
modlib

View file

@ -0,0 +1,379 @@
assert(modlib.version >= 103, "character_anim requires at least version rolling-103 of modlib")
local workaround_model = modlib.mod.require"workaround"
character_anim = {}
character_anim.conf = modlib.mod.configuration()
local quaternion = modlib.quaternion
-- TODO deduplicate code: move to modlib (see ghosts mod)
local media_paths = modlib.minetest.media.paths
local static_model_names = {}
local animated_model_names = {}
for name in pairs(media_paths) do
if (name:find"character" or name:find"player") and name:match"%.b3d$" then
local fixed, data = pcall(workaround_model, name)
if fixed then
local static_name = "_character_anim_" .. name
minetest.dynamic_add_media({
filename = static_name,
filedata = data,
})
static_model_names[name] = static_name
animated_model_names[static_name] = name
else
minetest.log("warning", "character_anim: failed to workaround model " .. name)
end
end
end
local function find_node(root, name)
if root.name == name then return root end
for _, child in ipairs(root.children) do
local node = find_node(child, name)
if node then return node end
end
end
local models = setmetatable({}, {__index = function(self, filename)
if animated_model_names[filename] then
return self[animated_model_names[filename]]
end
local _, ext = modlib.file.get_extension(filename)
if not ext or ext:lower() ~= "b3d" then
-- Only B3D support currently
return
end
local path = assert(media_paths[filename], filename)
local stream = io.open(path, "rb")
local model = assert(modlib.b3d.read(stream))
assert(stream:read(1) == nil, "EOF expected")
stream:close()
self[filename] = model
return model
end})
function character_anim.is_interacting(player)
local control = player:get_player_control()
return minetest.check_player_privs(player, "interact") and (control.RMB or control.LMB)
end
local function get_look_horizontal(player)
return -math.deg(player:get_look_horizontal())
end
local players = {}
character_anim.players = players
local function get_playerdata(player)
local name = player:get_player_name()
local data = players[name]
if data then return data end
-- Initialize playerdata if it doesn't already exist
data = {
interaction_time = 0,
animation_time = 0,
look_horizontal = get_look_horizontal(player),
bone_positions = {}
}
players[name] = data
return data
end
function character_anim.set_bone_override(player, bonename, position, rotation)
local value = {
position = position,
euler_rotation = rotation
}
get_playerdata(player).bone_positions[bonename] = next(value) and value
end
local function nil_default(value, default)
if value == nil then return default end
return value
end
-- Forward declaration
local handle_player_animations
-- Raw PlayerRef methods
local set_bone_position, set_animation, set_local_animation
minetest.register_on_joinplayer(function(player)
get_playerdata(player) -- Initalizes playerdata if it isn't already initialized
if not set_bone_position then
local PlayerRef = getmetatable(player)
-- Keep our model hack completely opaque to the outside world
local set_properties = PlayerRef.set_properties
function PlayerRef:set_properties(props)
if not self:is_player() then
return set_properties(self, props)
end
local old_mesh = props.mesh
props.mesh = static_model_names[old_mesh] or old_mesh
set_properties(self, props)
props.mesh = old_mesh
end
local get_properties = PlayerRef.get_properties
function PlayerRef:get_properties()
if not self:is_player() then
return get_properties(self)
end
local props = get_properties(self)
if not props then return nil end
props.mesh = animated_model_names[props.mesh] or props.mesh
return props
end
set_bone_position = PlayerRef.set_bone_position
function PlayerRef:set_bone_position(bonename, position, rotation)
if self:is_player() then
character_anim.set_bone_override(self, bonename or "",
position or {x = 0, y = 0, z = 0},
rotation or {x = 0, y = 0, z = 0})
end
return set_bone_position(self, bonename, position, rotation)
end
set_animation = PlayerRef.set_animation
function PlayerRef:set_animation(frame_range, frame_speed, frame_blend, frame_loop)
if not self:is_player() then
return set_animation(self, frame_range, frame_speed, frame_blend, frame_loop)
end
local player_animation = get_playerdata(self)
if not player_animation then
return
end
local prev_anim = player_animation.animation
local new_anim = {
nil_default(frame_range, {x = 1, y = 1}),
nil_default(frame_speed, 15),
nil_default(frame_blend, 0),
nil_default(frame_loop, true)
}
player_animation.animation = new_anim
if not prev_anim or (prev_anim[1].x ~= new_anim[1].x or prev_anim[1].y ~= new_anim[1].y) then
-- Reset animation only if the animation changed
player_animation.animation_time = 0
handle_player_animations(0, player)
elseif prev_anim[2] ~= new_anim[2] then
-- Adapt time to new speed
player_animation.animation_time = player_animation.animation_time * prev_anim[2] / new_anim[2]
end
end
local set_animation_frame_speed = PlayerRef.set_animation_frame_speed
function PlayerRef:set_animation_frame_speed(frame_speed)
if not self:is_player() then
return set_animation_frame_speed(self, frame_speed)
end
frame_speed = nil_default(frame_speed, 15)
local player_animation = get_playerdata(self)
if not player_animation then
return
end
local prev_speed = player_animation.animation[2]
player_animation.animation[2] = frame_speed
-- Adapt time to new speed
player_animation.animation_time = player_animation.animation_time * prev_speed / frame_speed
end
local get_animation = PlayerRef.get_animation
function PlayerRef:get_animation()
if not self:is_player() then
return get_animation(self)
end
local anim = get_playerdata(self).animation
if anim then
return unpack(anim, 1, 4)
end
return get_animation(self)
end
set_local_animation = PlayerRef.set_local_animation
function PlayerRef:set_local_animation(idle, walk, dig, walk_while_dig, frame_speed)
if not self:is_player() then return set_local_animation(self) end
frame_speed = frame_speed or 30
get_playerdata(self).local_animation = {idle, walk, dig, walk_while_dig, frame_speed}
end
local get_local_animation = PlayerRef.get_local_animation
function PlayerRef:get_local_animation()
if not self:is_player() then return get_local_animation(self) end
local local_anim = get_playerdata(self).local_animation
if local_anim then
return unpack(local_anim, 1, 5)
end
return get_local_animation(self)
end
end
-- First update `character_anim` with the current animation
-- which mods like `player_api` might have already set
-- (note: these two methods are already hooked)
player:set_animation(player:get_animation())
-- Then disable animation & local animation
local no_anim = {x = 0, y = 0}
set_animation(player, no_anim, 0, 0, false)
set_local_animation(player, no_anim, no_anim, no_anim, no_anim, 1)
end)
minetest.register_on_leaveplayer(function(player) players[player:get_player_name()] = nil end)
local function clamp(value, range)
if value > range.max then
return range.max
end
if value < range.min then
return range.min
end
return value
end
local function normalize_angle(angle)
return ((angle + 180) % 360) - 180
end
local function normalize_rotation(euler_rotation)
return vector.apply(euler_rotation, normalize_angle)
end
function handle_player_animations(dtime, player)
local mesh
do
local props = player:get_properties()
if not props then
-- HACK inside on_joinplayer, the player object may be invalid
-- causing get_properties() to return nothing - just ignore this
return
end
mesh = props.mesh
end
if static_model_names[mesh] then
player:set_properties{mesh = mesh}
elseif animated_model_names[mesh] then
mesh = animated_model_names[mesh]
end
local model = models[mesh]
if not model then
return
end
local conf = character_anim.conf.models[mesh] or character_anim.conf.default
local player_animation = get_playerdata(player)
local anim = player_animation.animation
if not anim then
return
end
local range, frame_speed, _, frame_loop = unpack(anim, 1, 4)
local animation_time = player_animation.animation_time
animation_time = animation_time + dtime
player_animation.animation_time = animation_time
local range_min, range_max = range.x + 1, range.y + 1
local keyframe
if range_min == range_max then
keyframe = range_min
elseif frame_loop then
keyframe = range_min + ((animation_time * frame_speed) % (range_max - range_min))
else
keyframe = math.min(range_max, range_min + animation_time * frame_speed)
end
local bones = {}
local animated_bone_props = model:get_animated_bone_properties(keyframe, true)
local body_quaternion
for _, props in ipairs(animated_bone_props) do
local bone = props.bone_name
if bone == "Body" then
body_quaternion = props.rotation
end
local position, rotation = modlib.vector.to_minetest(props.position), props.rotation
-- Invert quaternion to match Minetest's coordinate system
rotation = {-rotation[1], -rotation[2], -rotation[3], rotation[4]}
local euler_rotation = quaternion.to_euler_rotation(rotation)
bones[bone] = {position = position, rotation = rotation, euler_rotation = euler_rotation}
end
local Body = (bones.Body or {}).euler_rotation
local Head = (bones.Head or {}).euler_rotation
local Arm_Right = (bones.Arm_Right or {}).euler_rotation
local look_vertical = math.deg(player:get_look_vertical())
if Head then Head.x = -look_vertical end
local interacting = character_anim.is_interacting(player)
if interacting and Arm_Right then
local interaction_time = player_animation.interaction_time
-- Note: -90 instead of -Arm_Right.x because it looks better
Arm_Right.x = -90 - look_vertical - math.sin(-interaction_time) * conf.arm_right.radius
Arm_Right.y = Arm_Right.y + math.cos(-interaction_time) * conf.arm_right.radius
player_animation.interaction_time = interaction_time + dtime * math.rad(conf.arm_right.speed)
else
player_animation.interaction_time = 0
end
local look_horizontal = get_look_horizontal(player)
local diff = look_horizontal - player_animation.look_horizontal
if math.abs(diff) > 180 then
diff = math.sign(-diff) * 360 + diff
end
local moving_diff = math.sign(diff) * math.abs(diff) * math.min(1, dtime / conf.body.turn_speed)
player_animation.look_horizontal = player_animation.look_horizontal + moving_diff
if math.abs(moving_diff) < 1e-6 then
player_animation.look_horizontal = look_horizontal
end
local lag_behind = diff - moving_diff
local attach_parent, _, _, attach_rotation = player:get_attach()
if attach_parent then
local parent_rotation
if attach_parent.get_rotation then
parent_rotation = attach_parent:get_rotation()
else -- 0.4.x doesn't have get_rotation(), only yaw
parent_rotation = {x = 0, y = attach_parent:get_yaw(), z = 0}
end
if attach_rotation and parent_rotation then
parent_rotation = vector.apply(parent_rotation, math.deg)
local total_rotation = normalize_rotation(vector.subtract(attach_rotation, parent_rotation))
local function rotate_relative(euler_rotation)
if not euler_rotation then return end
euler_rotation.y = euler_rotation.y + look_horizontal
local new_rotation = normalize_rotation(vector.subtract(euler_rotation, total_rotation))
modlib.table.add_all(euler_rotation, new_rotation)
end
rotate_relative(Head)
if interacting then rotate_relative(Arm_Right) end
end
elseif Body and not modlib.table.nilget(rawget(_G, "player_api"), "player_attached", player:get_player_name()) then
Body.y = Body.y + lag_behind
if Head then Head.y = Head.y + lag_behind end
if interacting and Arm_Right then Arm_Right.y = Arm_Right.y + lag_behind end
end
-- HACK this essentially only works for very character.b3d-like models;
-- it tries to find the (sole) X-rotation of the body relative to a subsequent (180°) Y-rotation.
if body_quaternion then
local body_rotation = assert(assert(find_node(model.node, "Body")).rotation)
local body_x = quaternion.to_euler_rotation(modlib.quaternion.compose(body_rotation, body_quaternion)).x
if Head then Head.x = normalize_angle(Head.x - body_x) end
if interacting and Arm_Right then Arm_Right.x = normalize_angle(Arm_Right.x - body_x) end
end
if Head then
Head.x = clamp(Head.x, conf.head.pitch)
Head.y = clamp(Head.y, conf.head.yaw)
if math.abs(Head.y) > conf.head.yaw_restriction then
Head.x = clamp(Head.x, conf.head.yaw_restricted)
end
end
if Arm_Right then Arm_Right.y = clamp(Arm_Right.y, conf.arm_right.yaw) end
-- Replace animation with serverside bone animation
for bone, values in pairs(bones) do
local overridden_values = player_animation.bone_positions[bone]
overridden_values = overridden_values or {}
set_bone_position(player, bone,
overridden_values.position or values.position,
overridden_values.euler_rotation or values.euler_rotation)
end
end
minetest.register_globalstep(function(dtime)
for _, player in pairs(minetest.get_connected_players()) do
handle_player_animations(dtime, player)
end
end)

View file

@ -0,0 +1,8 @@
name = character_anim
title = Character Animations
description = Animates the character
author = LMD
depends = modlib
# these mods are supported, but don't necessarily need to load before character_anim
optional_depends = player_api, 3d_armor, skinsdb
release = 29484

View file

@ -0,0 +1,65 @@
local function angle(description, default)
return { type = "number", range = { min = -180, max = 180 }, description = description, default = default }
end
local range = function(description, default_min, default_max)
return {
type = "table",
entries = {
min = angle(description .. " (min)", default_min),
max = angle(description .. " (max)", default_max)
},
func = function(range)
if range.max < range.min then return "Minimum range value is not <= maximum range value" end
end
}
end
local model = {
type = "table",
entries = {
body = {
type = "table",
entries = {
turn_speed = {
type = "number",
range = { min_exclusive = 0, max = 1e3 },
description = "Body turn speed",
default = 0.2
}
}
},
head = {
type = "table",
entries = {
pitch = range("Head pitch", -60, 80),
yaw = range("Head yaw", -90, 90),
yaw_restricted = range("Head yaw restricted", 0, 45),
yaw_restriction = angle("Head yaw restriction", 60)
}
},
arm_right = {
type = "table",
entries = {
radius = angle("Right arm spin radius", 10),
speed = {
type = "number",
range = { min_exclusive = 0, max = 1e4 },
description = "Right arm spin speed",
default = 1e3
},
yaw = range("Right arm yaw", -30, 160)
}
}
}
}
return {
type = "table",
entries = {
default = model,
models = {
type = "table",
keys = { type = "string" },
description = "Other models, same format as `default` model"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -0,0 +1,53 @@
[character_anim.default]
[*character_anim.default.arm_right]
# Right arm spin radius
character_anim.default.arm_right.radius (Character anim Default Arm right Radius) float 10 -180.000000 180.000000
# Right arm spin speed
character_anim.default.arm_right.speed (Character anim Default Arm right Speed) float 1000 0.000000 10000.000000
[**character_anim.default.arm_right.yaw]
# Right arm yaw (max)
character_anim.default.arm_right.yaw.max (Character anim Default Arm right Yaw Max) float 160 -180.000000 180.000000
# Right arm yaw (min)
character_anim.default.arm_right.yaw.min (Character anim Default Arm right Yaw Min) float -30 -180.000000 180.000000
[*character_anim.default.body]
# Body turn speed
character_anim.default.body.turn_speed (Character anim Default Body Turn speed) float 0.2 0.000000 1000.000000
[*character_anim.default.head]
# Head yaw restriction
character_anim.default.head.yaw_restriction (Character anim Default Head Yaw restriction) float 60 -180.000000 180.000000
[**character_anim.default.head.pitch]
# Head pitch (max)
character_anim.default.head.pitch.max (Character anim Default Head Pitch Max) float 80 -180.000000 180.000000
# Head pitch (min)
character_anim.default.head.pitch.min (Character anim Default Head Pitch Min) float -60 -180.000000 180.000000
[**character_anim.default.head.yaw]
# Head yaw (max)
character_anim.default.head.yaw.max (Character anim Default Head Yaw Max) float 90 -180.000000 180.000000
# Head yaw (min)
character_anim.default.head.yaw.min (Character anim Default Head Yaw Min) float -90 -180.000000 180.000000
[**character_anim.default.head.yaw_restricted]
# Head yaw restricted (max)
character_anim.default.head.yaw_restricted.max (Character anim Default Head Yaw restricted Max) float 45 -180.000000 180.000000
# Head yaw restricted (min)
character_anim.default.head.yaw_restricted.min (Character anim Default Head Yaw restricted Min) float 0 -180.000000 180.000000
[character_anim.models]

View file

@ -0,0 +1,54 @@
-- See https://github.com/luanti-org/luanti/issues/15692
local mod = modlib.mod
local b3d = modlib.b3d
local media_paths = modlib.minetest.media.paths
local function is_perfect(quat)
local mat = modlib.matrix4.rotation(quat)
local diag_abs_sum = 0
for i = 1, 3 do
diag_abs_sum = diag_abs_sum + math.abs(mat[i][i])
end
return math.abs(diag_abs_sum - 3) < 1e-5
end
return function(name)
local stream = assert(io.open(media_paths[name], "rb"))
local character = assert(b3d.read(stream))
stream:close()
local function wiggle_rotation(quat)
if math.abs(quat[1]) + math.abs(quat[2]) + math.abs(quat[3]) < 1e-5 then return quat end -- identity
if not is_perfect(quat) then return quat end
local wiggled = {}
for i = 1, 4 do
wiggled[i] = quat[i] + 1e-3
end
wiggled = modlib.quaternion.normalize(wiggled)
if not is_perfect(wiggled) then return wiggled end
for i = 1, 4 do
wiggled[i] = quat[i] - 1e-3
end
wiggled = modlib.quaternion.normalize(wiggled)
if not is_perfect(wiggled) then return wiggled end
return quat -- this shouldn't happen
end
local function wiggle_rotations(node)
node.rotation = wiggle_rotation(node.rotation)
node.keys = {}
for _, child in ipairs(node.children) do
wiggle_rotations(child)
end
end
wiggle_rotations(character.node)
local rope = {}
character:write({write = function(_, str)
table.insert(rope, str)
end})
return table.concat(rope)
end