Skip to content

Commit 6231ab2

Browse files
committed
feat: initial commit
0 parents  commit 6231ab2

File tree

10 files changed

+376
-0
lines changed

10 files changed

+376
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# it has my C:\Users in it :(
2+
.luarc.json

.vscode/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"files.associations": {
3+
".anm2": "xml"
4+
}
5+
}

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# TwIsaac
2+
3+
Twitch Chat, but inside of Isaac, isn't it awesome?
4+
5+
> [!WARNING]
6+
> TwIsaac requires `--luadebug` flag in launch options, since it uses `luasocket` to connect to Twitch Chat.
7+
8+
# Feature
9+
10+
- You can read messages that people send in your/someone's chat!
11+
- And even reply to them, using console commands, wow!
12+
13+
# Installation
14+
15+
1. [Download](https://github.com/shockpast/twisaac/releases/latest) and extract the contents into `<game_installation>/mods/`
16+
2. Open `twisaac/data.lua` and replace `token`, `username` and `channel` variables with
17+
3. Start a New Game!
18+
19+
# Commands
20+
21+
### twisaac_say
22+
Sends a message to Twitch.
23+
24+
- Arguments: `string message`
25+
- Usage: `twisaac_say Hello, World!`
26+
27+
### twisaac_reply
28+
Replies to specific message from current chat hud.
29+
30+
- Arguments: `number message_index`, `string message`
31+
- Usage: `twisaac_reply 1 Hello, shockpast!`

data.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
local data = {}
2+
3+
data.channel = ""
4+
data.username = ""
5+
data.token = "" -- without "oauth:"
6+
7+
return data

main.lua

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
local twitch = include("modules/twitch")
2+
local helpers = include("modules/helpers")
3+
local data = include("data")
4+
5+
---@type ModReference
6+
local mod = RegisterMod("TwIsaac", 1)
7+
8+
local font = Font()
9+
font:Load("font/pftempestasevencondensed.fnt")
10+
11+
-- corountine that will receive incoming messages
12+
local recv_co = nil
13+
-- twitch's modules client
14+
local client = nil
15+
16+
local callbacks = {}
17+
18+
function callbacks:post_update()
19+
if client == nil then return end
20+
21+
recv_co = coroutine.create(function() twitch:receive() end)
22+
23+
if recv_co == nil then return end
24+
if coroutine.status(recv_co) == "dead" then return end
25+
26+
coroutine.resume(recv_co)
27+
end
28+
29+
function callbacks:post_render()
30+
if client == nil then return end
31+
32+
local screen_height = Isaac.GetScreenHeight()
33+
34+
font:DrawStringScaledUTF8(string.format("#%s", client.channel), 50, screen_height - 15, 0.5, 0.5, KColor(1, 1, 1, 1))
35+
36+
for index, message in ipairs(client.messages) do
37+
local username = string.format("%s", message.tags["display-name"])
38+
local text = string.format(": %s", message.text)
39+
local timestamp = string.format("@ %s", message.timestamp)
40+
41+
local username_width = font:GetStringWidthUTF8(username)
42+
local total_width = font:GetStringWidthUTF8(username .. text)
43+
44+
local r, g, b = table.unpack(message.color or { 1, 1, 1 })
45+
46+
local base_x = 53
47+
local base_y = screen_height - 17 - (index * 10)
48+
49+
for name, _ in pairs(message.badges) do
50+
---@type Sprite
51+
local badge = client.badges[name]
52+
if badge == nil then goto icontinue end
53+
54+
base_x = base_x + 16 - 5 -- move username
55+
56+
badge.Scale = Vector(0.5, 0.5)
57+
badge:Render(Vector(base_x - (16 - 5), base_y), Vector.Zero, Vector.Zero) -- render badge at old username position
58+
59+
::icontinue::
60+
end
61+
62+
font:DrawStringScaledUTF8(username, base_x, base_y, 0.5, 0.5, KColor(r, g, b, 1))
63+
font:DrawStringScaledUTF8(text, base_x + username_width / 2, base_y, 0.5, 0.5, KColor.White)
64+
font:DrawStringScaledUTF8(timestamp, base_x + (total_width / 2) + 2, base_y, 0.5, 0.5, KColor(1, 1, 1, 0.4))
65+
end
66+
end
67+
68+
---@param cmd string
69+
---@param params string
70+
function callbacks:execute_cmd(cmd, params)
71+
if client == nil then return end
72+
73+
local arguments = helpers.extract_arguments(params)
74+
75+
if cmd == "twisaac_say" then
76+
client:say(params)
77+
end
78+
79+
if cmd == "twisaac_reply" then -- id, ...message
80+
local id = table.remove(arguments, 1)
81+
local message = ""
82+
83+
for _, v in ipairs(arguments) do message = message .. " " .. v end
84+
85+
client:reply(id, message)
86+
end
87+
end
88+
89+
--
90+
mod:AddCallback(ModCallbacks.MC_POST_UPDATE, callbacks.post_update)
91+
mod:AddCallback(ModCallbacks.MC_POST_RENDER, callbacks.post_render)
92+
mod:AddCallback(ModCallbacks.MC_EXECUTE_CMD, callbacks.execute_cmd)
93+
94+
--
95+
client = twitch:connect(data.channel, data.username, data.token)

metadata.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!-- metadata.xml is mostly useless, since this mod requires "luadebug" flag -->
2+
3+
<metadata>
4+
<name>twisaac</name>
5+
<directory>twisaac</directory>
6+
<description/>
7+
<version>1.0</version>
8+
<visibility/>
9+
</metadata>

modules/helpers.lua

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
local helpers = {}
2+
3+
---@param hex string
4+
---@return number[]
5+
function helpers.hex_to_rgb(hex)
6+
local r, g, b = hex:match("#?(%x%x)(%x%x)(%x%x)")
7+
if not r or not g or not b then return { 1, 1, 1 } end
8+
9+
return { tonumber(r, 16) / 255, tonumber(g, 16) / 255, tonumber(b, 16) / 255 }
10+
end
11+
12+
---@param params string
13+
---@return string[]
14+
function helpers.extract_arguments(params)
15+
local arguments = {}
16+
for word in string.gmatch(params, "[^%s]+") do arguments[#arguments + 1] = word end
17+
18+
return arguments
19+
end
20+
21+
return helpers

modules/twitch.lua

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
--[[
2+
https://github.com/shockpast/twisaac
3+
Twitch, but inside of Isaac.
4+
5+
If you will use this module inside of other mod, please credit me :)
6+
]]
7+
8+
--
9+
local socket = require("socket")
10+
11+
local helpers = include("modules/helpers")
12+
13+
---@class Twitch
14+
local twitch = {}
15+
twitch.messages = {}
16+
twitch.badges = {}
17+
18+
local sfx = SFXManager()
19+
20+
---@param message string
21+
local function parse_message(message)
22+
local message_entity = {}
23+
24+
-- parse tags
25+
do
26+
local tags = {}
27+
28+
local tag_part, rest = message:match("^@(.-) (.+)")
29+
if tag_part then
30+
for k, v in tag_part:gmatch("([^=;]+)=([^;]*)") do
31+
tags[k] = v
32+
end
33+
end
34+
35+
message_entity.tags = tags
36+
message = rest or message
37+
end
38+
39+
-- parse badges
40+
do
41+
local badges = {}
42+
43+
for badge, version in message_entity.tags.badges:gmatch("([^/,]+)/([^/,]+)") do
44+
badges[badge] = tonumber(version) or version
45+
end
46+
47+
message_entity.badges = badges
48+
end
49+
50+
-- parse id
51+
do
52+
message_entity.id = message_entity.tags.id or "unknown"
53+
end
54+
55+
-- parse timestamp
56+
do
57+
local timestamp = tonumber(message_entity.tags["tmi-sent-ts"]) or 0
58+
message_entity.timestamp = os.date("%H:%M:%S", math.floor(timestamp / 1000))
59+
end
60+
61+
-- parse color
62+
do
63+
local color = message_entity.tags.color
64+
message_entity.color = color and helpers.hex_to_rgb(color) or nil
65+
end
66+
67+
-- parse default data
68+
do
69+
local prefix, command, channel, text = message:match(":(%S+) (%S+) (%S+) :(.*)")
70+
message_entity.prefix = prefix
71+
message_entity.command = command
72+
message_entity.channel = channel
73+
message_entity.username = prefix:match("^(.-)!")
74+
message_entity.text = text
75+
end
76+
77+
return message_entity
78+
end
79+
80+
---@param channel string
81+
---@param username string
82+
---@param token string
83+
function twitch:connect(channel, username, token)
84+
self.socket = socket.tcp()
85+
self.socket:settimeout(5)
86+
87+
local ok, err = self.socket:connect("irc.chat.twitch.tv", 6667)
88+
if not ok then
89+
print(string.format("[twisaac] connection failed: %s", tostring(err)))
90+
return nil
91+
end
92+
93+
self.channel = channel
94+
self.username = username
95+
96+
self.socket:send(string.format("PASS oauth:%s\r\n", token))
97+
self.socket:send(string.format("NICK %s\r\n", username))
98+
self.socket:send(string.format("JOIN #%s\r\n", channel))
99+
self.socket:send("CAP REQ :twitch.tv/membership twitch.tv/tags\r\n") -- chat metadata
100+
101+
self.messages[channel] = {}
102+
103+
print(string.format("[twisaac] connected to #%s", channel))
104+
105+
return twitch
106+
end
107+
108+
function twitch:receive()
109+
while self.socket do
110+
self.socket:settimeout(0)
111+
local message, err = self.socket:receive("*l")
112+
113+
if message ~= nil then
114+
if string.sub(message, 0, #"PING") == "PING" then
115+
print(message)
116+
print("[twisaac] ping-pong!")
117+
118+
self.socket:send("PONG :tmi.twitch.tv\r\n")
119+
end
120+
121+
if string.sub(message, 0, #"@badge-info") ~= "@badge-info" then return end
122+
if #self.messages > 5 then table.remove(self.messages, 1) end
123+
124+
sfx:Play(SoundEffect.SOUND_BOSS2_BUBBLES)
125+
126+
self.messages[#self.messages + 1] = parse_message(message)
127+
end
128+
129+
if err and err ~= "timeout" then
130+
print("[twisaac] error: " .. tostring(err))
131+
132+
self.socket:close()
133+
self.socket = nil
134+
end
135+
136+
coroutine.yield()
137+
end
138+
end
139+
140+
function twitch:reply(id, message)
141+
if id == nil or tonumber(id) == nil then return end
142+
if #message <= 0 then return end
143+
144+
local message_entity = self.messages[tonumber(id)]
145+
if message_entity == nil then return end
146+
147+
self.socket:send(string.format("@reply-parent-msg-id=%s PRIVMSG #%s :%s\r\n", message_entity.id, self.channel, message))
148+
149+
print("[twisaac] > " .. message)
150+
end
151+
152+
function twitch:say(message)
153+
if #message <= 0 then return end
154+
155+
self.socket:send(string.format("PRIVMSG #%s :%s\r\n", self.channel, message))
156+
157+
print("[twisaac] > " .. message)
158+
end
159+
160+
--
161+
do
162+
local badge_names = { "broadcaster", "moderator", "turbo", "verified", "vip" }
163+
164+
for i = 16, 5 * 16, 16 do
165+
local sprite = Sprite()
166+
sprite:Load("gfx/ui/twisaac/badges.anm2", true)
167+
sprite:Play("Root", true)
168+
sprite:SetFrame((i / 16) - 1)
169+
170+
twitch.badges[badge_names[i / 16]] = sprite
171+
end
172+
end
173+
--
174+
175+
return twitch
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<AnimatedActor>
2+
<Info CreatedBy="shockpast" CreatedOn="2/18/2025 3:11:42 PM" Version="1" Fps="30"/>
3+
<Content>
4+
<Spritesheets>
5+
<Spritesheet Path="badges.png" Id="0"/>
6+
</Spritesheets>
7+
<Layers>
8+
<Layer Name="Badges" Id="0" SpritesheetId="0"/>
9+
</Layers>
10+
<Nulls/>
11+
<Events/>
12+
</Content>
13+
<Animations DefaultAnimation="Root">
14+
<Animation Name="Root" FrameNum="5" Loop="false">
15+
<RootAnimation>
16+
<Frame XPosition="0" YPosition="0" XScale="100" YScale="100" Delay="1" Visible="true" RedTint="255" GreenTint="255" BlueTint="255" AlphaTint="255" RedOffset="0" GreenOffset="0" BlueOffset="0" Rotation="0" Interpolated="false"/>
17+
</RootAnimation>
18+
<LayerAnimations>
19+
<LayerAnimation LayerId="0" Visible="true">
20+
<Frame XPosition="0" YPosition="0" XPivot="0" YPivot="0" XCrop="0" YCrop="0" Width="16" Height="16" XScale="100" YScale="100" Delay="1" Visible="true" RedTint="255" GreenTint="255" BlueTint="255" AlphaTint="255" RedOffset="0" GreenOffset="0" BlueOffset="0" Rotation="0" Interpolated="false"/>
21+
<Frame XPosition="0" YPosition="0" XPivot="0" YPivot="0" XCrop="16" YCrop="0" Width="16" Height="16" XScale="100" YScale="100" Delay="1" Visible="true" RedTint="255" GreenTint="255" BlueTint="255" AlphaTint="255" RedOffset="0" GreenOffset="0" BlueOffset="0" Rotation="0" Interpolated="false"/>
22+
<Frame XPosition="0" YPosition="0" XPivot="0" YPivot="0" XCrop="32" YCrop="0" Width="16" Height="16" XScale="100" YScale="100" Delay="1" Visible="true" RedTint="255" GreenTint="255" BlueTint="255" AlphaTint="255" RedOffset="0" GreenOffset="0" BlueOffset="0" Rotation="0" Interpolated="false"/>
23+
<Frame XPosition="0" YPosition="0" XPivot="0" YPivot="0" XCrop="48" YCrop="0" Width="16" Height="16" XScale="100" YScale="100" Delay="1" Visible="true" RedTint="255" GreenTint="255" BlueTint="255" AlphaTint="255" RedOffset="0" GreenOffset="0" BlueOffset="0" Rotation="0" Interpolated="false"/>
24+
<Frame XPosition="0" YPosition="0" XPivot="0" YPivot="0" XCrop="64" YCrop="0" Width="16" Height="16" XScale="100" YScale="100" Delay="1" Visible="true" RedTint="255" GreenTint="255" BlueTint="255" AlphaTint="255" RedOffset="0" GreenOffset="0" BlueOffset="0" Rotation="0" Interpolated="false"/>
25+
</LayerAnimation>
26+
</LayerAnimations>
27+
<NullAnimations/>
28+
<Triggers/>
29+
</Animation>
30+
</Animations>
31+
</AnimatedActor>
656 Bytes
Loading

0 commit comments

Comments
 (0)