[Tutorial] How to Display a Custom Image and Sounds on Screen Using a Client Mod (OTClient + TFS 1.4.2)

In this tutorial, I’ll show you how to create a client mod that allows the server to display custom images and play sounds on the player's screen using ExtendedOpcode. This is perfect for showing ques

💻Client Side

  1. Create a mod folder: Inside your client folder, navigate to mods/ and create a new folder named mod_imagem

  2. Inside mod_imagem, add the following files:

mod_imagem.otmod
Module
  name: mod_imagem
  description: Displays an image when triggered by opcode
  author: YourName
  version: 1.0
  autoload: true
  sandboxed: true
  scripts: [ mod_imagem ]
  @onLoad: init()
  @onUnload: terminate()
mod_imagem.lua
local window = nil
local imageWidget = nil
local soundChannel = nil
local uninterruptibleSoundChannel = nil
local lastSoundName = nil

function init()
    g_ui.importStyle("mod_imagem.otui")
    ProtocolGame.registerExtendedOpcode(45, onExtendedOpcode)
    rootWidget:grabKeyboard()
    g_keyboard.bindKeyDown(KeyEscape, onKeyPress)
end

function terminate()
    ProtocolGame.unregisterExtendedOpcode(45)
    if window then
        window:destroy()
        window = nil
    end
    if soundChannel then
        soundChannel:stop()
        soundChannel = nil
    end
    if uninterruptibleSoundChannel then
        uninterruptibleSoundChannel:stop()
        uninterruptibleSoundChannel = nil
    end
    rootWidget:ungrabKeyboard()
end

function onExtendedOpcode(protocol, opcode, buffer)
    if opcode ~= 45 then return end
    showImage(buffer)
end

function showImage(buffer)
    if window then
        window:destroy()
        window = nil
    end
    local imageName = nil
    local soundName = nil
    local isUninterruptible = false
    
    -- Se o buffer conter '=', tratamos como imagem=som ou só imagem
    if string.find(buffer, "=") then
        for part in string.gmatch(buffer, "([^,]+)") do
            local key, value = string.match(part, "(%w+)=(.+)")
            if key == "imagem" then
                imageName = value
            elseif key == "som" then
                soundName = value
            elseif key == "somFalse" then
                soundName = value
                isUninterruptible = true
            end
        end
    else
        -- Se for só um nome simples, trata como som direto
        playSound(buffer)
        return
    end
    
    -- Exibir imagem (se houver)
    if imageName then
        window = g_ui.createWidget('ImagemViewerWindow', rootWidget)
        if not window then
            print('ERRO: não foi possível criar a janela')
            return
        end
        imageWidget = window:getChildById('image')
        if imageWidget then
            local imagePath = '/mods/mod_imagem/img/' .. imageName .. '.png'
            imageWidget:setImageSource(imagePath)
        else
            print('ERRO: não encontrou o widget da imagem')
            window:destroy()
            window = nil
            return
        end
        local closeButton = window:getChildById('closeButton')
        if closeButton then
            closeButton.onClick = function()
                if window then
                    window:destroy()
                    window = nil
                end
            end
        else
            print('ERRO: não encontrou o botão de fechar')
        end
        window:raise()
        window:focus()
    end
    
    -- Tocar som (se houver)
    if soundName then
        playSound(soundName, isUninterruptible)
    end
end

function playSound(name, isUninterruptible)
    if not name then return end
    
    local soundPath = '/mods/mod_imagem/sounds/' .. name .. '.ogg'
    
    if isUninterruptible then
        -- Para sons não interrompíveis, usa um canal separado
        uninterruptibleSoundChannel = g_sounds.play(soundPath)
        -- Limpa o canal após o tempo do som (aproximadamente)
        scheduleEvent(function()
            if uninterruptibleSoundChannel then
                uninterruptibleSoundChannel = nil
            end
        end, 2000) -- 2 segundos, ajuste conforme necessário
    else
        -- Para sons normais, interrompe o som normal anterior se houver
        if soundChannel then
            soundChannel:stop()
            soundChannel = nil
        end
        soundChannel = g_sounds.play(soundPath)
    end
    
    lastSoundName = name
end

function onKeyPress(keyCode, keyboardModifiers)
    if keyCode == KeyEscape and window then
        window:destroy()
        window = nil
        return true
    end
    return false
end
mod_imagem.otui​
ImagemViewerWindow < MainWindow
  size: 810 630
  anchors.centerIn: parent
  image-source: /images/ui/window
  draggable: true
  padding: 10

  Label
    id: image
    size: 800 600
    anchors.centerIn: parent
    image-fixed-ratio: false
    image-smooth: true
    margin-top: 10

  UIButton
    id: closeButton
    text: Close
    anchors.top: parent.top
    anchors.right: parent.right
    margin-top: -5
    margin-right: 10
    focusable: true
    color: red

3. Create a folder named img/ inside mod_imagem/ and other folder named sounds/ inside mod_imagem/ • Place your PNG images there. For example: /mods/mod_imagem/img/imagem_01.png /mods/mod_imagem/img/imagem_02.png

: Always use images sized 800x600 for the best display quality, format PNG. • Place your Sounds .OGG there. For example: /mods/mod_imagem/sounds/som_01.ogg

: The image and sound filenames don’t need to follow a specific format like image_01 or sound_01. You can name them whatever you want, including using spaces. For example: sound test one.ogg or image test test.png

4. Register the mod in interface.otmod Open modules/game_interface/interface.otmod and at the bottom, add: - mod_imagem

(Make sure the indentation matches the rest of the list.)

💻Server Side

(TFS 1.4.2 used: BlackTek Server) 1. Register the ExtendedOpcode event: data/creaturescripts/creaturescripts.xml

<event type="extendedopcode" name="ExtendedOpcode" script="extendedopcode.lua" />

2. Create data/creaturescripts/scripts/extendedopcode.lua

-- Lista de opcodes utilizados
local OPCODE_LANGUAGE     = 1
local OPCODE_MOD_IMAGEM = 45  -- usado pelo mod para exibir mensagem no client
function onExtendedOpcode(player, opcode, buffer)
    if opcode == OPCODE_LANGUAGE then
        if buffer == "pt" or buffer == "en" then
        end
    elseif opcode == OPCODE_MOD_IMAGEM then
    else
    end
end

Triggering the Image (RevScript) To trigger the image when a player steps on a tile ActionID 4454, create a new file data/scripts/movements/enviar_imagem_mod.lua

-- Script ativado ao pisar no tile com actionid 4452
local OPCODE_ID = 45 -- mesmo usado no cliente
local tileActionId = 4452
local imagem = "imagem_01"
local som = "som_01"
local tileTrigger = MoveEvent()
function tileTrigger.onStepIn(creature, item, position, fromPosition)
  if not creature:isPlayer() then return true end
  local player = creature:getPlayer()
  if player then
    sendCustomOpcode(player, imagem, som)
    -- Example: Send only an imagem
    -- player:sendExtendedOpcode(OPCODE_ID, imagem, false)
    -- Example: Send only a sound
    -- player:sendExtendedOpcode(OPCODE_ID, false, sound)
    -- Example: Send both imagem and sound
    -- player:sendExtendedOpcode(OPCODE_ID, imagem, sound)
  end
  return true
end
-- Envia imagem e/ou som como ExtendedOpcode
function sendCustomOpcode(player, imagem, som)
  local params = {}
  if imagem and imagem ~= false then
    table.insert(params, "imagem=" .. imagem)
  end
  if som and som ~= false then
    table.insert(params, "som=" .. som)
  end
  if #params > 0 then
    player:sendExtendedOpcode(OPCODE_ID, table.concat(params, ","))
  end
end
tileTrigger:aid(tileActionId)
tileTrigger:type("stepin")
tileTrigger:register()
🚨 Example use in direct other scripts:
player:sendExtendedOpcode(45, "imagem=imagem_01") -- Send only an image:
player:sendExtendedOpcode(45, "som=som_01") -- Send only an sound. (If a sound is played this way, it stops the previous sound.)
player:sendExtendedOpcode(45, "somFalse=music_01") -- Send only an sound. (If this sound is played this way, it won’t be interrupted by another one ("som=som_01").)
player:sendExtendedOpcode(45, "imagem=imagem_01,som=som_01") -- Send only an image and sound:
🚨 Attention: 🚨
This mod uses opcode 45, so be careful not to have another mod using opcode 45.
If you do, change it to another one, for example 33. The maximum is up to 250, if I’m not mistaken.
Therefore, whenever you see 45, you need to change it in the following files:
• mod_imagem.lua (Client)
• extendedopcode.lua (Server)
• enviar_imagem_mod.lua (Server)

Example Use Cases​

  • Display a quest hint image once by using storage to check if the player already saw it.

  • Show a tutorial image for new players.

  • Show warning images on special areas (e.g., boss rooms).


I hope this helps anyone who needs it! 😄 If you'd like to support me, here are my details:

💖 PayPal: darcio1989@gmail.com 💸 PIX: (14) 99665-9919

Any help is greatly appreciated! Thank you so much for your support! 🙏✨

Atualizado